Compare commits

...

2 Commits

Author SHA1 Message Date
56bc808d85 fix: Corriger les tests fonctionnels cassés par le routing tenant
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
Le commit 8c70ed1 a introduit le routing runtime vers les bases tenant.
En environnement test, Doctrine ajoute un suffixe '_test' au dbname via
dbname_suffix, mais TenantAwareConnection.useTenantDatabase() parsait
l'URL brute sans préserver ce suffixe. Résultat : les données persistées
par les tests allaient dans classeo_master_test, mais les requêtes HTTP
(après le switch de connexion) lisaient depuis classeo_master.

La correction préserve le dbname_suffix des paramètres par défaut lors
du switch tenant, garantissant la cohérence entre persist et read.
2026-03-12 00:41:57 +01:00
8f83dafb7a 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.
2026-03-12 00:41:41 +01:00
7 changed files with 868 additions and 45 deletions

View File

@@ -73,6 +73,13 @@ final class TenantAwareConnection extends Connection implements TenantDatabaseSw
/** @phpstan-var Params $connectionParams */ /** @phpstan-var Params $connectionParams */
$connectionParams = array_merge($this->defaultConnectionParams, $this->dsnParser->parse($databaseUrl)); $connectionParams = array_merge($this->defaultConnectionParams, $this->dsnParser->parse($databaseUrl));
// Preserve dbname_suffix from Doctrine config (e.g. '_test' in test env)
// so that tenant connections target the same database as the default one.
$suffix = $this->defaultConnectionParams['dbname_suffix'] ?? null;
if (is_string($suffix) && isset($connectionParams['dbname']) && is_string($connectionParams['dbname'])) {
$connectionParams['dbname'] .= $suffix;
}
$this->applyConnectionParams($connectionParams, $databaseUrl); $this->applyConnectionParams($connectionParams, $databaseUrl);
} }

View File

@@ -15,12 +15,13 @@ const ADMIN_EMAIL = 'e2e-childselector-admin@example.com';
const ADMIN_PASSWORD = 'AdminCSTest123'; const ADMIN_PASSWORD = 'AdminCSTest123';
const PARENT_EMAIL = 'e2e-childselector-parent@example.com'; const PARENT_EMAIL = 'e2e-childselector-parent@example.com';
const PARENT_PASSWORD = 'ChildSelectorTest123'; const PARENT_PASSWORD = 'ChildSelectorTest123';
const PARENT_FIRST_NAME = 'CSParent';
const PARENT_LAST_NAME = 'TestSelector';
const STUDENT1_EMAIL = 'e2e-childselector-student1@example.com'; const STUDENT1_EMAIL = 'e2e-childselector-student1@example.com';
const STUDENT1_PASSWORD = 'Student1Test123'; const STUDENT1_PASSWORD = 'Student1Test123';
const STUDENT2_EMAIL = 'e2e-childselector-student2@example.com'; const STUDENT2_EMAIL = 'e2e-childselector-student2@example.com';
const STUDENT2_PASSWORD = 'Student2Test123'; const STUDENT2_PASSWORD = 'Student2Test123';
let parentUserId: string;
let student1UserId: string; let student1UserId: string;
let student2UserId: string; let student2UserId: string;
@@ -42,7 +43,7 @@ async function loginAsAdmin(page: Page) {
]); ]);
} }
async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId: string, relationship: string) { async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`); await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect( await expect(
@@ -53,11 +54,24 @@ async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId:
const addButton = page.getByRole('button', { name: /ajouter un parent/i }); const addButton = page.getByRole('button', { name: /ajouter un parent/i });
if (!(await addButton.isVisible())) return; if (!(await addButton.isVisible())) return;
// Skip if parent is already linked (email visible in guardian list)
const sectionText = await page.locator('.guardian-section').textContent();
if (sectionText && sectionText.includes(parentSearchTerm)) return;
await addButton.click(); await addButton.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel(/id du parent/i).fill(guardianId); const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(parentSearchTerm);
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption(relationship); await dialog.getByLabel(/type de relation/i).selectOption(relationship);
await dialog.getByRole('button', { name: 'Ajouter' }).click(); await dialog.getByRole('button', { name: 'Ajouter' }).click();
@@ -99,11 +113,10 @@ test.describe('Child Selector', () => {
); );
// Create parent user // Create parent user
const parentOutput = execSync( execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`, `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
); );
parentUserId = extractUserId(parentOutput);
// Create student 1 // Create student 1
const student1Output = execSync( const student1Output = execSync(
@@ -122,8 +135,8 @@ test.describe('Child Selector', () => {
// Use admin UI to link parent to both students // Use admin UI to link parent to both students
const page = await browser.newPage(); const page = await browser.newPage();
await loginAsAdmin(page); await loginAsAdmin(page);
await addGuardianIfNotLinked(page, student1UserId, parentUserId, 'tuteur'); await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur');
await addGuardianIfNotLinked(page, student2UserId, parentUserId, 'tutrice'); await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice');
await page.close(); await page.close();
}); });
@@ -192,7 +205,7 @@ test.describe('Child Selector', () => {
// Restore the second link via admin UI for clean state // Restore the second link via admin UI for clean state
const restorePage = await browser.newPage(); const restorePage = await browser.newPage();
await loginAsAdmin(restorePage); await loginAsAdmin(restorePage);
await addGuardianIfNotLinked(restorePage, student2UserId, parentUserId, 'tutrice'); await addGuardianIfNotLinked(restorePage, student2UserId, PARENT_EMAIL, 'tutrice');
await restorePage.close(); await restorePage.close();
}); });
}); });

View File

@@ -18,12 +18,14 @@ const STUDENT_EMAIL = 'e2e-guardian-student@example.com';
const STUDENT_PASSWORD = 'StudentTest123'; const STUDENT_PASSWORD = 'StudentTest123';
const PARENT_EMAIL = 'e2e-guardian-parent@example.com'; const PARENT_EMAIL = 'e2e-guardian-parent@example.com';
const PARENT_PASSWORD = 'ParentTest123'; const PARENT_PASSWORD = 'ParentTest123';
const PARENT_FIRST_NAME = 'GuardParent';
const PARENT_LAST_NAME = 'TestOne';
const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com'; const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com';
const PARENT2_PASSWORD = 'Parent2Test123'; const PARENT2_PASSWORD = 'Parent2Test123';
const PARENT2_FIRST_NAME = 'GuardParent';
const PARENT2_LAST_NAME = 'TestTwo';
let studentUserId: string; let studentUserId: string;
let parentUserId: string;
let parent2UserId: string;
/** /**
* Extracts the User ID from the Symfony console table output. * Extracts the User ID from the Symfony console table output.
@@ -60,19 +62,17 @@ test.describe('Guardian Management', () => {
); );
studentUserId = extractUserId(studentOutput); studentUserId = extractUserId(studentOutput);
// Create first parent user and capture userId // Create first parent user (with name for search-based linking)
const parentOutput = execSync( execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`, `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
); );
parentUserId = extractUserId(parentOutput);
// Create second parent user for the max guardians test // Create second parent user for the max guardians test
const parent2Output = execSync( execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT 2>&1`, `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT2_FIRST_NAME} --lastName=${PARENT2_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
); );
parent2UserId = extractUserId(parent2Output);
// Clean up any existing guardian links for this student (DB + cache) // Clean up any existing guardian links for this student (DB + cache)
try { try {
@@ -116,12 +116,13 @@ test.describe('Guardian Management', () => {
} }
/** /**
* Opens the add-guardian dialog, fills the form, and submits. * Opens the add-guardian dialog, searches for a parent by name,
* selects them from results, and submits.
* Waits for the success message before returning. * Waits for the success message before returning.
*/ */
async function addGuardianViaDialog( async function addGuardianViaDialog(
page: import('@playwright/test').Page, page: import('@playwright/test').Page,
guardianId: string, parentSearchTerm: string,
relationshipType: string relationshipType: string
) { ) {
await page.getByRole('button', { name: /ajouter un parent/i }).click(); await page.getByRole('button', { name: /ajouter un parent/i }).click();
@@ -129,7 +130,19 @@ test.describe('Guardian Management', () => {
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel(/id du parent/i).fill(guardianId); // Type in search field and wait for results
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(parentSearchTerm);
// Wait for autocomplete results
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
// Verify a parent was selected
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption(relationshipType); await dialog.getByLabel(/type de relation/i).selectOption(relationshipType);
await dialog.getByRole('button', { name: 'Ajouter' }).click(); await dialog.getByRole('button', { name: 'Ajouter' }).click();
@@ -170,8 +183,8 @@ test.describe('Guardian Management', () => {
await waitForGuardianSection(page); await waitForGuardianSection(page);
// Add the guardian via the dialog // Add the guardian via the dialog (search by email)
await addGuardianViaDialog(page, parentUserId, 'père'); await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Verify success message // Verify success message
await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i); await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i);
@@ -186,6 +199,37 @@ test.describe('Guardian Management', () => {
await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible(); await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible();
}); });
test('[P1] should not show already-linked parent in search results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// The parent from the previous test is still linked
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
// Open the add-guardian dialog and search for the already-linked parent
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
// The listbox should show "Aucun parent trouvé" since the parent is excluded
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
await expect(listbox.getByText(/aucun parent trouvé/i)).toBeVisible();
// No option should be selectable
await expect(listbox.locator('[role="option"]')).toHaveCount(0);
// The guardian count should still be 1
await dialog.locator('.modal-close').click();
await expect(page.locator('.guardian-item')).toHaveCount(1);
});
test('[P1] should unlink a guardian from a student', async ({ page }) => { test('[P1] should unlink a guardian from a student', async ({ page }) => {
await loginAsAdmin(page); await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
@@ -211,14 +255,14 @@ test.describe('Guardian Management', () => {
await waitForGuardianSection(page); await waitForGuardianSection(page);
// Link first guardian (père) // Link first guardian (père) — search by email
await addGuardianViaDialog(page, parentUserId, 'père'); await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Wait for the add button to still be available after first link // Wait for the add button to still be available after first link
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 }); await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 });
// Link second guardian (mère) // Link second guardian (mère) — search by email
await addGuardianViaDialog(page, parent2UserId, 'mère'); await addGuardianViaDialog(page, PARENT2_EMAIL, 'mère');
// Now with 2 guardians linked, the add button should NOT be visible // Now with 2 guardians linked, the add button should NOT be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 }); await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 });
@@ -234,4 +278,31 @@ test.describe('Guardian Management', () => {
// Verify empty state returns // Verify empty state returns
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 }); await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
}); });
test('[P1] should clear search field when reopening add dialog after successful link', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Link a guardian via dialog (this fills the search, selects, and submits)
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Reopen the add-guardian dialog
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// The search field should be empty (reset after successful link)
await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toHaveValue('');
// No parent should be marked as selected
await expect(dialog.getByText(/sélectionné/i)).not.toBeVisible();
// Close modal and clean up
await dialog.locator('.modal-close').click();
await removeFirstGuardian(page);
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
}); });

View File

@@ -19,9 +19,10 @@ const STUDENT_EMAIL = 'e2e-students-eleve@example.com';
const STUDENT_PASSWORD = 'StudentTest123'; const STUDENT_PASSWORD = 'StudentTest123';
const PARENT_EMAIL = 'e2e-students-parent@example.com'; const PARENT_EMAIL = 'e2e-students-parent@example.com';
const PARENT_PASSWORD = 'ParentTest123'; const PARENT_PASSWORD = 'ParentTest123';
const PARENT_FIRST_NAME = 'StudParent';
const PARENT_LAST_NAME = 'TestLink';
let studentUserId: string; let studentUserId: string;
let parentUserId: string;
/** /**
* Extracts the User ID from the Symfony console table output. * Extracts the User ID from the Symfony console table output.
@@ -58,12 +59,11 @@ test.describe('Student Management', () => {
); );
studentUserId = extractUserId(studentOutput); studentUserId = extractUserId(studentOutput);
// Create parent user and capture userId // Create parent user (with name for search-based linking)
const parentOutput = execSync( execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`, `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }
); );
parentUserId = extractUserId(parentOutput);
// Clean up any existing guardian links for this student (DB + cache) // Clean up any existing guardian links for this student (DB + cache)
try { try {
@@ -197,7 +197,7 @@ test.describe('Student Management', () => {
await expect(dialog.getByRole('heading', { name: /ajouter un parent\/tuteur/i })).toBeVisible(); await expect(dialog.getByRole('heading', { name: /ajouter un parent\/tuteur/i })).toBeVisible();
// Form fields should be present // Form fields should be present
await expect(dialog.getByLabel(/id du parent/i)).toBeVisible(); await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toBeVisible();
await expect(dialog.getByLabel(/type de relation/i)).toBeVisible(); await expect(dialog.getByLabel(/type de relation/i)).toBeVisible();
}); });
@@ -235,8 +235,17 @@ test.describe('Student Management', () => {
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill in the guardian details // Search for the parent by name
await dialog.getByLabel(/id du parent/i).fill(parentUserId); const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
// Wait for autocomplete results
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption('père'); await dialog.getByLabel(/type de relation/i).selectOption('père');
// Submit // Submit
@@ -292,7 +301,15 @@ test.describe('Student Management', () => {
await page.getByRole('button', { name: /ajouter un parent/i }).click(); await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel(/id du parent/i).fill(parentUserId);
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
const listbox2 = dialog.locator('#parent-search-listbox');
await expect(listbox2).toBeVisible({ timeout: 10000 });
const option = listbox2.locator('[role="option"]').first();
await option.click();
await dialog.getByLabel(/type de relation/i).selectOption('mère'); await dialog.getByLabel(/type de relation/i).selectOption('mère');
await dialog.getByRole('button', { name: 'Ajouter' }).click(); await dialog.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface ParentResult {
id: string;
firstName: string;
lastName: string;
email: string;
}
let {
onSelect,
onClear,
excludeIds = [],
placeholder = 'Rechercher un parent par nom ou email...'
}: {
onSelect: (parent: ParentResult) => void;
onClear?: () => void;
excludeIds?: string[];
placeholder?: string;
} = $props();
let query = $state('');
let results = $state<ParentResult[]>([]);
let isOpen = $state(false);
let isLoading = $state(false);
let activeIndex = $state(-1);
let selectedLabel = $state('');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let abortController: AbortController | null = null;
let containerEl: HTMLDivElement | undefined = $state();
let inputEl: HTMLInputElement | undefined = $state();
const listboxId = 'parent-search-listbox';
$effect(() => {
return () => {
if (timeoutId !== null) clearTimeout(timeoutId);
abortController?.abort();
};
});
async function searchParents(searchQuery: string) {
if (searchQuery.length < 2) {
results = [];
isOpen = false;
return;
}
abortController?.abort();
abortController = new AbortController();
try {
isLoading = true;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams({
role: 'ROLE_PARENT',
search: searchQuery,
itemsPerPage: '10'
});
const response = await authenticatedFetch(
`${apiUrl}/users?${params.toString()}`,
{ signal: abortController.signal }
);
if (!response.ok) return;
const data = await response.json();
const members = data['hydra:member'] ?? data['member'] ?? [];
const mapped = members.map((u: Record<string, string>) => ({
id: u['id'] ?? '',
firstName: u['firstName'] ?? '',
lastName: u['lastName'] ?? '',
email: u['email'] ?? ''
}));
results = excludeIds.length > 0
? mapped.filter((p: ParentResult) => !excludeIds.includes(p.id))
: mapped;
isOpen = true;
activeIndex = -1;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
results = [];
} finally {
isLoading = false;
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
query = target.value;
if (selectedLabel !== '') {
selectedLabel = '';
onClear?.();
}
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = globalThis.setTimeout(() => {
searchParents(query);
}, 300);
}
function formatParentLabel(parent: ParentResult): string {
const name = `${parent.firstName} ${parent.lastName}`.trim();
return name || parent.email;
}
function selectParent(parent: ParentResult) {
selectedLabel = formatParentLabel(parent);
query = selectedLabel;
isOpen = false;
results = [];
activeIndex = -1;
onSelect(parent);
}
function handleKeydown(event: KeyboardEvent) {
if (!isOpen || results.length === 0) {
if (event.key === 'Escape') {
isOpen = false;
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
break;
case 'ArrowUp':
event.preventDefault();
activeIndex = activeIndex <= 0 ? results.length - 1 : activeIndex - 1;
break;
case 'Enter': {
event.preventDefault();
const selected = results[activeIndex];
if (activeIndex >= 0 && selected) {
selectParent(selected);
}
break;
}
case 'Escape':
isOpen = false;
activeIndex = -1;
break;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target;
if (containerEl && target instanceof HTMLElement && !containerEl.contains(target)) {
isOpen = false;
activeIndex = -1;
}
}
function optionId(index: number): string {
return `${listboxId}-option-${index}`;
}
export function clear() {
query = '';
selectedLabel = '';
results = [];
isOpen = false;
activeIndex = -1;
}
</script>
<svelte:document onclick={handleClickOutside} />
<div class="parent-search" bind:this={containerEl}>
<label for="parent-search-input" class="sr-only">{placeholder}</label>
<div class="input-wrapper">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<input
bind:this={inputEl}
id="parent-search-input"
type="text"
role="combobox"
autocomplete="off"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-label={placeholder}
{placeholder}
value={query}
oninput={handleInput}
onkeydown={handleKeydown}
/>
{#if isLoading}
<span class="loading-indicator" aria-hidden="true">...</span>
{/if}
</div>
{#if isOpen}
<ul id={listboxId} role="listbox" class="dropdown" aria-label="Résultats de recherche parents">
{#if results.length === 0}
<li class="no-results" role="presentation">Aucun parent trouvé</li>
{:else}
{#each results as parent, index (parent.id)}
<li
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
class="option"
class:active={index === activeIndex}
onclick={() => selectParent(parent)}
onmouseenter={() => { activeIndex = index; }}
>
<span class="option-name">{formatParentLabel(parent)}</span>
{#if parent.firstName || parent.lastName}
<span class="option-email">{parent.email}</span>
{/if}
</li>
{/each}
{/if}
</ul>
{/if}
</div>
<style>
.parent-search {
position: relative;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 0.75rem;
width: 1.125rem;
height: 1.125rem;
color: #9ca3af;
pointer-events: none;
}
input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.25rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #374151;
background: white;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
input::placeholder {
color: #9ca3af;
}
.loading-indicator {
position: absolute;
right: 0.75rem;
color: #9ca3af;
font-size: 0.75rem;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
max-height: 15rem;
overflow-y: auto;
z-index: 10;
list-style: none;
padding: 0.25rem 0;
margin: 0.25rem 0 0;
}
.option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.125rem;
transition: background 0.1s;
}
.option:hover,
.option.active {
background: #eff6ff;
}
.option-name {
font-size: 0.875rem;
font-weight: 500;
color: #1f2937;
}
.option-email {
font-size: 0.75rem;
color: #6b7280;
}
.no-results {
padding: 0.75rem;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getApiBaseUrl } from '$lib/api/config'; import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth'; import { authenticatedFetch } from '$lib/auth';
import ParentSearchInput from '$lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte';
interface Guardian { interface Guardian {
id: string; id: string;
@@ -37,8 +38,10 @@
// Add guardian modal // Add guardian modal
let showAddModal = $state(false); let showAddModal = $state(false);
let newGuardianId = $state(''); let newGuardianId = $state('');
let selectedParentLabel = $state('');
let newRelationshipType = $state('autre'); let newRelationshipType = $state('autre');
let isSubmitting = $state(false); let isSubmitting = $state(false);
let parentSearchInput: { clear: () => void } | undefined = $state();
// Confirm remove // Confirm remove
let confirmRemoveId = $state<string | null>(null); let confirmRemoveId = $state<string | null>(null);
@@ -92,7 +95,9 @@
successMessage = 'Parent ajouté avec succès'; successMessage = 'Parent ajouté avec succès';
showAddModal = false; showAddModal = false;
newGuardianId = ''; newGuardianId = '';
selectedParentLabel = '';
newRelationshipType = 'autre'; newRelationshipType = 'autre';
parentSearchInput?.clear();
await loadGuardians(); await loadGuardians();
globalThis.setTimeout(() => { successMessage = null; }, 3000); globalThis.setTimeout(() => { successMessage = null; }, 3000);
} catch (e) { } catch (e) {
@@ -209,14 +214,23 @@
onsubmit={(e) => { e.preventDefault(); addGuardian(); }} onsubmit={(e) => { e.preventDefault(); addGuardian(); }}
> >
<div class="form-group"> <div class="form-group">
<label for="guardianId">ID du parent</label> <label for="parent-search-input">Parent</label>
<input <ParentSearchInput
id="guardianId" bind:this={parentSearchInput}
type="text" excludeIds={guardians.map(g => g.guardianId)}
bind:value={newGuardianId} onSelect={(parent) => {
placeholder="UUID du compte parent" newGuardianId = parent.id;
required const name = `${parent.firstName} ${parent.lastName}`.trim();
selectedParentLabel = name || parent.email;
}}
onClear={() => {
newGuardianId = '';
selectedParentLabel = '';
}}
/> />
{#if selectedParentLabel}
<span class="selected-parent">Sélectionné : {selectedParentLabel}</span>
{/if}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="relationshipType">Type de relation</label> <label for="relationshipType">Type de relation</label>
@@ -466,7 +480,12 @@
color: #374151; color: #374151;
} }
.form-group input, .selected-parent {
font-size: 0.8125rem;
color: #166534;
font-weight: 500;
}
.form-group select { .form-group select {
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
@@ -474,7 +493,6 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.form-group input:focus,
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: #3b82f6;

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');
});
});