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