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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
|
||||
describe('SearchInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders input with default placeholder', () => {
|
||||
render(SearchInput, {
|
||||
props: { onSearch: vi.fn() }
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('Rechercher...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders input with custom placeholder', () => {
|
||||
render(SearchInput, {
|
||||
props: { onSearch: vi.fn(), placeholder: 'Chercher un utilisateur...' }
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('Chercher un utilisateur...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('debounces input and calls onSearch after delay', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(SearchInput, {
|
||||
props: { onSearch, debounceMs: 300 }
|
||||
});
|
||||
|
||||
const input = screen.getByRole('searchbox');
|
||||
await fireEvent.input(input, { target: { value: 'test' } });
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past the debounce time
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(onSearch).toHaveBeenCalledOnce();
|
||||
expect(onSearch).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('cancels pending debounce on new input', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(SearchInput, {
|
||||
props: { onSearch, debounceMs: 300 }
|
||||
});
|
||||
|
||||
const input = screen.getByRole('searchbox');
|
||||
|
||||
// Type first value
|
||||
await fireEvent.input(input, { target: { value: 'te' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
// Type second value before debounce fires
|
||||
await fireEvent.input(input, { target: { value: 'test' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should only be called once with the final value
|
||||
expect(onSearch).toHaveBeenCalledOnce();
|
||||
expect(onSearch).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('clears input on Escape key', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(SearchInput, {
|
||||
props: { value: 'initial', onSearch }
|
||||
});
|
||||
|
||||
const input = screen.getByRole('searchbox');
|
||||
await fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
expect(onSearch).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('calls onSearch immediately on clear button click', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(SearchInput, {
|
||||
props: { value: 'something', onSearch }
|
||||
});
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'Effacer la recherche' });
|
||||
await fireEvent.click(clearButton);
|
||||
|
||||
// Should be called immediately (no debounce)
|
||||
expect(onSearch).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('shows clear button only when input has value', () => {
|
||||
const { container } = render(SearchInput, {
|
||||
props: { onSearch: vi.fn() }
|
||||
});
|
||||
|
||||
// No clear button initially (empty value)
|
||||
expect(container.querySelector('.search-clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows clear button when initial value is provided', () => {
|
||||
const { container } = render(SearchInput, {
|
||||
props: { value: 'test', onSearch: vi.fn() }
|
||||
});
|
||||
|
||||
expect(container.querySelector('.search-clear')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user