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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user