feat: Remplacer le champ UUID par une recherche autocomplete pour la liaison parent-élève

L'ajout d'un parent à un élève nécessitait de connaître et coller
manuellement l'UUID du compte parent, ce qui était source d'erreurs
et très peu ergonomique pour les administrateurs.

Le nouveau composant ParentSearchInput offre une recherche par nom/email
avec autocomplétion (debounce 300ms, navigation clavier, ARIA combobox).
Les parents déjà liés sont exclus des résultats, et la sélection se
réinitialise proprement quand l'admin retape dans le champ.
This commit is contained in:
2026-03-12 00:41:41 +01:00
parent 8c70ed1324
commit 8f83dafb7a
6 changed files with 861 additions and 45 deletions

View File

@@ -0,0 +1,361 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
vi.mock('$lib/api/config', () => ({
getApiBaseUrl: () => 'http://test.classeo.local:18000/api'
}));
const mockAuthenticatedFetch = vi.fn();
vi.mock('$lib/auth', () => ({
authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args)
}));
import ParentSearchInput from '$lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte';
function mockApiResponse(members: Record<string, string>[]) {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ member: members })
});
}
const PARENT_DUPONT = { id: 'uuid-1', firstName: 'Jean', lastName: 'Dupont', email: 'jean@test.com' };
const PARENT_MARTIN = { id: 'uuid-2', firstName: 'Marie', lastName: 'Martin', email: 'marie@test.com' };
const PARENT_NO_NAME = { id: 'uuid-3', firstName: '', lastName: '', email: 'noname@test.com' };
describe('ParentSearchInput', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders with combobox role', () => {
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
expect(screen.getByRole('combobox')).toBeTruthy();
});
it('renders with default placeholder', () => {
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
expect(screen.getByPlaceholderText('Rechercher un parent par nom ou email...')).toBeTruthy();
});
it('renders with custom placeholder', () => {
render(ParentSearchInput, {
props: { onSelect: vi.fn(), placeholder: 'Chercher...' }
});
expect(screen.getByPlaceholderText('Chercher...')).toBeTruthy();
});
it('does not search with less than 2 characters', async () => {
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'a' } });
vi.advanceTimersByTime(300);
expect(mockAuthenticatedFetch).not.toHaveBeenCalled();
});
it('debounces and searches after 300ms with 2+ characters', async () => {
mockApiResponse([PARENT_DUPONT]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
// Not called immediately
expect(mockAuthenticatedFetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(mockAuthenticatedFetch).toHaveBeenCalledOnce();
const url = mockAuthenticatedFetch.mock.calls[0]![0] as string;
expect(url).toContain('role=ROLE_PARENT');
expect(url).toContain('search=dup');
expect(url).toContain('itemsPerPage=10');
});
it('displays results in dropdown', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
const options = screen.getAllByRole('option');
expect(options).toHaveLength(2);
expect(options[0]!.textContent).toContain('Jean Dupont');
expect(options[0]!.textContent).toContain('jean@test.com');
expect(options[1]!.textContent).toContain('Marie Martin');
});
it('shows "Aucun parent trouvé" when no results', async () => {
mockApiResponse([]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'xyz' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(screen.getByText('Aucun parent trouvé')).toBeTruthy();
});
it('calls onSelect when clicking a result', async () => {
mockApiResponse([PARENT_DUPONT]);
const onSelect = vi.fn();
render(ParentSearchInput, { props: { onSelect } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
const option = screen.getAllByRole('option')[0]!;
await fireEvent.click(option);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(PARENT_DUPONT);
});
it('closes dropdown after selection', async () => {
mockApiResponse([PARENT_DUPONT]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
await fireEvent.click(screen.getAllByRole('option')[0]!);
expect(screen.queryByRole('listbox')).toBeNull();
});
it('updates input value with selected parent name', async () => {
mockApiResponse([PARENT_DUPONT]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
await fireEvent.click(screen.getAllByRole('option')[0]!);
expect(input.value).toBe('Jean Dupont');
});
it('shows email as fallback when names are empty', async () => {
mockApiResponse([PARENT_NO_NAME]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'noname' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
const option = screen.getAllByRole('option')[0]!;
expect(option.textContent).toContain('noname@test.com');
});
it('sets input to email when selecting parent without name', async () => {
mockApiResponse([PARENT_NO_NAME]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'noname' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
await fireEvent.click(screen.getAllByRole('option')[0]!);
expect(input.value).toBe('noname@test.com');
});
it('closes dropdown on Escape', async () => {
mockApiResponse([PARENT_DUPONT]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(screen.getByRole('listbox')).toBeTruthy();
await fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
});
it('navigates options with ArrowDown', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
await fireEvent.keyDown(input, { key: 'ArrowDown' });
const options = screen.getAllByRole('option');
expect(options[0]!.getAttribute('aria-selected')).toBe('true');
expect(options[1]!.getAttribute('aria-selected')).toBe('false');
await fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(options[0]!.getAttribute('aria-selected')).toBe('false');
expect(options[1]!.getAttribute('aria-selected')).toBe('true');
});
it('navigates options with ArrowUp', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
// ArrowUp from -1 wraps to last item
await fireEvent.keyDown(input, { key: 'ArrowUp' });
const options = screen.getAllByRole('option');
expect(options[1]!.getAttribute('aria-selected')).toBe('true');
await fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(options[0]!.getAttribute('aria-selected')).toBe('true');
expect(options[1]!.getAttribute('aria-selected')).toBe('false');
});
it('selects option with Enter key', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
const onSelect = vi.fn();
render(ParentSearchInput, { props: { onSelect } });
const input = screen.getByRole('combobox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
// Navigate to first option and press Enter
await fireEvent.keyDown(input, { key: 'ArrowDown' });
await fireEvent.keyDown(input, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(PARENT_DUPONT);
expect(input.value).toBe('Jean Dupont');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('wraps ArrowDown from last to first option', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
// Navigate to last, then one more wraps to first
await fireEvent.keyDown(input, { key: 'ArrowDown' }); // index 0
await fireEvent.keyDown(input, { key: 'ArrowDown' }); // index 1
await fireEvent.keyDown(input, { key: 'ArrowDown' }); // wraps to 0
const options = screen.getAllByRole('option');
expect(options[0]!.getAttribute('aria-selected')).toBe('true');
expect(options[1]!.getAttribute('aria-selected')).toBe('false');
});
it('does not select with Enter when no option is active', async () => {
mockApiResponse([PARENT_DUPONT]);
const onSelect = vi.fn();
render(ParentSearchInput, { props: { onSelect } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
// Press Enter without navigating (activeIndex = -1)
await fireEvent.keyDown(input, { key: 'Enter' });
expect(onSelect).not.toHaveBeenCalled();
expect(screen.getByRole('listbox')).toBeTruthy();
});
it('filters out excluded IDs from results', async () => {
mockApiResponse([PARENT_DUPONT, PARENT_MARTIN]);
render(ParentSearchInput, {
props: { onSelect: vi.fn(), excludeIds: ['uuid-1'] }
});
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]!.textContent).toContain('Marie Martin');
});
it('calls onClear when user retypes after selection', async () => {
mockApiResponse([PARENT_DUPONT]);
const onClear = vi.fn();
const onSelect = vi.fn();
render(ParentSearchInput, { props: { onSelect, onClear } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
// Select a parent
await fireEvent.click(screen.getAllByRole('option')[0]!);
expect(onSelect).toHaveBeenCalledOnce();
expect(onClear).not.toHaveBeenCalled();
// Retype in the input — should trigger onClear
mockApiResponse([PARENT_DUPONT]);
await fireEvent.input(input, { target: { value: 'mar' } });
expect(onClear).toHaveBeenCalledOnce();
});
it('does not call onClear when typing without prior selection', async () => {
const onClear = vi.fn();
render(ParentSearchInput, { props: { onSelect: vi.fn(), onClear } });
const input = screen.getByRole('combobox');
await fireEvent.input(input, { target: { value: 'test' } });
expect(onClear).not.toHaveBeenCalled();
});
it('sets aria-expanded correctly', async () => {
mockApiResponse([PARENT_DUPONT]);
render(ParentSearchInput, { props: { onSelect: vi.fn() } });
const input = screen.getByRole('combobox');
expect(input.getAttribute('aria-expanded')).toBe('false');
await fireEvent.input(input, { target: { value: 'dup' } });
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(input.getAttribute('aria-expanded')).toBe('true');
});
});