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:
2026-02-15 13:54:51 +01:00
parent 88e7f319db
commit 76e16db0d8
57 changed files with 3123 additions and 181 deletions

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
describe('Pagination', () => {
it('renders nothing when totalPages <= 1', () => {
const { container } = render(Pagination, {
props: { currentPage: 1, totalPages: 1, onPageChange: vi.fn() }
});
expect(container.querySelector('nav')).toBeNull();
});
it('renders nothing when totalPages is 0', () => {
const { container } = render(Pagination, {
props: { currentPage: 1, totalPages: 0, onPageChange: vi.fn() }
});
expect(container.querySelector('nav')).toBeNull();
});
it('renders all pages when totalPages <= 7', () => {
render(Pagination, {
props: { currentPage: 1, totalPages: 5, onPageChange: vi.fn() }
});
for (let i = 1; i <= 5; i++) {
expect(screen.getByRole('button', { name: `Page ${i}` })).toBeTruthy();
}
});
it('renders ellipsis for large page counts', () => {
const { container } = render(Pagination, {
props: { currentPage: 10, totalPages: 20, onPageChange: vi.fn() }
});
const ellipses = container.querySelectorAll('.pagination-ellipsis');
expect(ellipses.length).toBeGreaterThanOrEqual(1);
});
it('marks current page with aria-current="page"', () => {
render(Pagination, {
props: { currentPage: 3, totalPages: 5, onPageChange: vi.fn() }
});
const currentButton = screen.getByRole('button', { name: 'Page 3' });
expect(currentButton.getAttribute('aria-current')).toBe('page');
const otherButton = screen.getByRole('button', { name: 'Page 1' });
expect(otherButton.getAttribute('aria-current')).toBeNull();
});
it('disables "Précédent" button on page 1', () => {
render(Pagination, {
props: { currentPage: 1, totalPages: 5, onPageChange: vi.fn() }
});
const prevButton = screen.getByRole('button', { name: 'Page précédente' });
expect(prevButton.hasAttribute('disabled')).toBe(true);
});
it('disables "Suivant" button on last page', () => {
render(Pagination, {
props: { currentPage: 5, totalPages: 5, onPageChange: vi.fn() }
});
const nextButton = screen.getByRole('button', { name: 'Page suivante' });
expect(nextButton.hasAttribute('disabled')).toBe(true);
});
it('calls onPageChange with correct page on click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 2, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page 3' }));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it('calls onPageChange on next button click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 2, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page suivante' }));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it('calls onPageChange on previous button click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 3, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page précédente' }));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('clamps currentPage > totalPages gracefully', () => {
render(Pagination, {
props: { currentPage: 9999, totalPages: 5, onPageChange: vi.fn() }
});
// Page 5 should be marked as active (clamped)
const lastPageButton = screen.getByRole('button', { name: 'Page 5' });
expect(lastPageButton.getAttribute('aria-current')).toBe('page');
});
it('has aria-hidden on ellipsis elements', () => {
const { container } = render(Pagination, {
props: { currentPage: 10, totalPages: 20, onPageChange: vi.fn() }
});
const ellipses = container.querySelectorAll('.pagination-ellipsis');
ellipses.forEach((el) => {
expect(el.getAttribute('aria-hidden')).toBe('true');
});
});
});