diff --git a/frontend/e2e/child-selector.spec.ts b/frontend/e2e/child-selector.spec.ts index 9cc8c47..c247b2c 100644 --- a/frontend/e2e/child-selector.spec.ts +++ b/frontend/e2e/child-selector.spec.ts @@ -15,12 +15,13 @@ const ADMIN_EMAIL = 'e2e-childselector-admin@example.com'; const ADMIN_PASSWORD = 'AdminCSTest123'; const PARENT_EMAIL = 'e2e-childselector-parent@example.com'; const PARENT_PASSWORD = 'ChildSelectorTest123'; +const PARENT_FIRST_NAME = 'CSParent'; +const PARENT_LAST_NAME = 'TestSelector'; const STUDENT1_EMAIL = 'e2e-childselector-student1@example.com'; const STUDENT1_PASSWORD = 'Student1Test123'; const STUDENT2_EMAIL = 'e2e-childselector-student2@example.com'; const STUDENT2_PASSWORD = 'Student2Test123'; -let parentUserId: string; let student1UserId: 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 expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 }); await expect( @@ -53,11 +54,24 @@ async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId: const addButton = page.getByRole('button', { name: /ajouter un parent/i }); 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(); const dialog = page.getByRole('dialog'); 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.getByRole('button', { name: 'Ajouter' }).click(); @@ -99,11 +113,10 @@ test.describe('Child Selector', () => { ); // Create parent user - const parentOutput = 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`, + 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 --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`, { encoding: 'utf-8' } ); - parentUserId = extractUserId(parentOutput); // Create student 1 const student1Output = execSync( @@ -122,8 +135,8 @@ test.describe('Child Selector', () => { // Use admin UI to link parent to both students const page = await browser.newPage(); await loginAsAdmin(page); - await addGuardianIfNotLinked(page, student1UserId, parentUserId, 'tuteur'); - await addGuardianIfNotLinked(page, student2UserId, parentUserId, 'tutrice'); + await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur'); + await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice'); await page.close(); }); @@ -192,7 +205,7 @@ test.describe('Child Selector', () => { // Restore the second link via admin UI for clean state const restorePage = await browser.newPage(); await loginAsAdmin(restorePage); - await addGuardianIfNotLinked(restorePage, student2UserId, parentUserId, 'tutrice'); + await addGuardianIfNotLinked(restorePage, student2UserId, PARENT_EMAIL, 'tutrice'); await restorePage.close(); }); }); diff --git a/frontend/e2e/guardian-management.spec.ts b/frontend/e2e/guardian-management.spec.ts index 75e9a07..a37d086 100644 --- a/frontend/e2e/guardian-management.spec.ts +++ b/frontend/e2e/guardian-management.spec.ts @@ -18,12 +18,14 @@ const STUDENT_EMAIL = 'e2e-guardian-student@example.com'; const STUDENT_PASSWORD = 'StudentTest123'; const PARENT_EMAIL = 'e2e-guardian-parent@example.com'; const PARENT_PASSWORD = 'ParentTest123'; +const PARENT_FIRST_NAME = 'GuardParent'; +const PARENT_LAST_NAME = 'TestOne'; const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com'; const PARENT2_PASSWORD = 'Parent2Test123'; +const PARENT2_FIRST_NAME = 'GuardParent'; +const PARENT2_LAST_NAME = 'TestTwo'; let studentUserId: string; -let parentUserId: string; -let parent2UserId: string; /** * Extracts the User ID from the Symfony console table output. @@ -60,19 +62,17 @@ test.describe('Guardian Management', () => { ); studentUserId = extractUserId(studentOutput); - // Create first parent user and capture userId - const parentOutput = 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`, + // Create first parent user (with name for search-based linking) + 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 --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`, { encoding: 'utf-8' } ); - parentUserId = extractUserId(parentOutput); // Create second parent user for the max guardians test - const parent2Output = 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`, + 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 --firstName=${PARENT2_FIRST_NAME} --lastName=${PARENT2_LAST_NAME} 2>&1`, { encoding: 'utf-8' } ); - parent2UserId = extractUserId(parent2Output); // Clean up any existing guardian links for this student (DB + cache) 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. */ async function addGuardianViaDialog( page: import('@playwright/test').Page, - guardianId: string, + parentSearchTerm: string, relationshipType: string ) { await page.getByRole('button', { name: /ajouter un parent/i }).click(); @@ -129,7 +130,19 @@ test.describe('Guardian Management', () => { const dialog = page.getByRole('dialog'); 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.getByRole('button', { name: 'Ajouter' }).click(); @@ -170,8 +183,8 @@ test.describe('Guardian Management', () => { await waitForGuardianSection(page); - // Add the guardian via the dialog - await addGuardianViaDialog(page, parentUserId, 'père'); + // Add the guardian via the dialog (search by email) + await addGuardianViaDialog(page, PARENT_EMAIL, 'père'); // Verify success message 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(); }); + 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 }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); @@ -211,14 +255,14 @@ test.describe('Guardian Management', () => { await waitForGuardianSection(page); - // Link first guardian (père) - await addGuardianViaDialog(page, parentUserId, 'père'); + // Link first guardian (père) — search by email + await addGuardianViaDialog(page, PARENT_EMAIL, 'père'); // 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 }); - // Link second guardian (mère) - await addGuardianViaDialog(page, parent2UserId, 'mère'); + // Link second guardian (mère) — search by email + await addGuardianViaDialog(page, PARENT2_EMAIL, 'mère'); // 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 }); @@ -234,4 +278,31 @@ test.describe('Guardian Management', () => { // Verify empty state returns 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 }); + }); }); diff --git a/frontend/e2e/students.spec.ts b/frontend/e2e/students.spec.ts index d96def4..2825209 100644 --- a/frontend/e2e/students.spec.ts +++ b/frontend/e2e/students.spec.ts @@ -19,9 +19,10 @@ const STUDENT_EMAIL = 'e2e-students-eleve@example.com'; const STUDENT_PASSWORD = 'StudentTest123'; const PARENT_EMAIL = 'e2e-students-parent@example.com'; const PARENT_PASSWORD = 'ParentTest123'; +const PARENT_FIRST_NAME = 'StudParent'; +const PARENT_LAST_NAME = 'TestLink'; let studentUserId: string; -let parentUserId: string; /** * Extracts the User ID from the Symfony console table output. @@ -58,12 +59,11 @@ test.describe('Student Management', () => { ); studentUserId = extractUserId(studentOutput); - // Create parent user and capture userId - const parentOutput = 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`, + // Create parent user (with name for search-based linking) + 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 --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`, { encoding: 'utf-8' } ); - parentUserId = extractUserId(parentOutput); // Clean up any existing guardian links for this student (DB + cache) try { @@ -197,7 +197,7 @@ test.describe('Student Management', () => { await expect(dialog.getByRole('heading', { name: /ajouter un parent\/tuteur/i })).toBeVisible(); // 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(); }); @@ -235,8 +235,17 @@ test.describe('Student Management', () => { const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); - // Fill in the guardian details - await dialog.getByLabel(/id du parent/i).fill(parentUserId); + // Search for the parent by name + 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'); // Submit @@ -292,7 +301,15 @@ test.describe('Student Management', () => { await page.getByRole('button', { name: /ajouter un parent/i }).click(); const dialog = page.getByRole('dialog'); 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.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); diff --git a/frontend/src/lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte b/frontend/src/lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte new file mode 100644 index 0000000..69170ab --- /dev/null +++ b/frontend/src/lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte @@ -0,0 +1,336 @@ + + + + + + + diff --git a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte index 9dd0312..6ace877 100644 --- a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte +++ b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte @@ -1,6 +1,7 @@