feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient toutes les données d'un coup, ce qui dégradait l'expérience avec un volume croissant. La pagination côté serveur existait dans la config API Platform mais aucun Provider ne l'exploitait. Cette implémentation ajoute la pagination serveur (30 items/page, max 100) avec recherche textuelle sur toutes les sections, des composants frontend réutilisables (Pagination + SearchInput avec debounce), et la synchronisation URL pour le partage de liens filtrés. Les Query valident leurs paramètres (clamp page/limit, trim search) pour éviter les abus. Les affectations utilisent des lookup maps pour résoudre les noms sans N+1 queries. Les pages admin gèrent les race conditions via AbortController.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
interface Subject {
|
||||
@@ -37,6 +41,13 @@
|
||||
let showDeleteModal = $state(false);
|
||||
let subjectToDelete = $state<Subject | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Form state
|
||||
let newSubjectName = $state('');
|
||||
let newSubjectCode = $state('');
|
||||
@@ -45,32 +56,70 @@
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Load subjects on mount
|
||||
let loadAbortController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
loadSubjects();
|
||||
untrack(() => loadSubjects());
|
||||
});
|
||||
|
||||
async function loadSubjects() {
|
||||
loadAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortController = controller;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/subjects`);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const url = `${apiUrl}/subjects?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des matières');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API Platform peut retourner hydra:member, member, ou un tableau direct
|
||||
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? subjects.length;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
subjects = [];
|
||||
totalItems = 0;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (!controller.signal.aborted) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadSubjects();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
updateUrl();
|
||||
loadSubjects();
|
||||
}
|
||||
|
||||
async function handleCreateSubject() {
|
||||
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
|
||||
|
||||
@@ -216,17 +265,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher par nom, code..."
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des matières...</p>
|
||||
</div>
|
||||
{:else if subjects.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">📚</span>
|
||||
<h2>Aucune matière</h2>
|
||||
<p>Commencez par créer votre première matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
|
||||
{#if searchTerm}
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucune matière ne correspond à votre recherche</p>
|
||||
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
|
||||
{:else}
|
||||
<h2>Aucune matière</h2>
|
||||
<p>Commencez par créer votre première matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="subjects-grid">
|
||||
@@ -268,6 +329,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user