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 @@
+
+
+