Compare commits
2 Commits
8c70ed1324
...
56bc808d85
| Author | SHA1 | Date | |
|---|---|---|---|
| 56bc808d85 | |||
| 8f83dafb7a |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user