From a708af3a8f77b85c55c48994582e26fd710b23aa Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Mon, 16 Mar 2026 22:36:50 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Parall=C3=A9liser=20les=20appels=20API?= =?UTF-8?q?=20de=20la=20page=20devoirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page devoirs enchaînait séquentiellement les appels classes, subjects, assignments et homework, produisant un waterfall de ~2.5s. En les lançant dans un seul Promise.all, le temps de chargement correspond désormais au plus lent des appels (~600ms) au lieu de leur somme. Pour résoudre la dépendance de assignments sur le userId (nécessaire dans l'URL), un nouveau helper getAuthenticatedUserId() encapsule le mécanisme de token refresh côté module auth, évitant aux pages d'importer refreshToken directement. Chaque branche side-effect (loadAssignments, loadHomeworks) gère ses erreurs via .catch() local pour éviter l'état partiel si l'une échoue pendant que les autres réussissent. --- frontend/e2e/homework.spec.ts | 99 ++++++++++++++++--- frontend/src/lib/auth/auth.svelte.ts | 12 +++ frontend/src/lib/auth/index.ts | 1 + .../dashboard/teacher/homework/+page.svelte | 42 ++++---- 4 files changed, 124 insertions(+), 30 deletions(-) diff --git a/frontend/e2e/homework.spec.ts b/frontend/e2e/homework.spec.ts index 400b1d5..92be8eb 100644 --- a/frontend/e2e/homework.spec.ts +++ b/frontend/e2e/homework.spec.ts @@ -109,13 +109,17 @@ test.describe('Homework Management (Story 5.1)', () => { { encoding: 'utf-8' } ); - // Ensure class and subject exist + // Ensure classes and subject exist const { schoolId, academicYearId } = resolveDeterministicIds(); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` @@ -143,6 +147,10 @@ test.describe('Homework Management (Story 5.1)', () => { `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` @@ -422,9 +430,17 @@ test.describe('Homework Management (Story 5.1)', () => { }); async function createHomework(page: import('@playwright/test').Page, title: string) { + await createHomeworkInClass(page, title, { index: 1 }); + } + + async function createHomeworkInClass( + page: import('@playwright/test').Page, + title: string, + classOption: { index: number } | { label: string }, + ) { await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-class').selectOption(classOption); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill(title); await page.locator('#hw-due-date').fill(getNextWeekday(5)); @@ -576,24 +592,81 @@ test.describe('Homework Management (Story 5.1)', () => { await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 }); }); + test('shows validation errors per class when duplication rules fail', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir validation règles'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select first target class + const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); + await firstCheckbox.check(); + + // Intercept the POST duplicate API call to simulate a validation error + await page.route( + '**/api/homework/*/duplicate', + async (route, request) => { + // Extract targetClassIds from the request body + const body = request.postDataJSON(); + const targetClassIds: string[] = body.targetClassIds ?? []; + + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Validation échouée pour certaines classes.', + results: targetClassIds.map((classId: string) => ({ + classId, + valid: false, + error: "L'enseignant n'est pas affecté à cette classe pour cette matière.", + })), + }), + }); + }, + ); + + // Click duplicate + await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); + + // Verify: error alert appears in the modal + await expect(dialog.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + await expect(dialog.getByText(/certaines classes ne passent pas la validation/i)).toBeVisible(); + + // Verify: validation error message appears next to the class + await expect(dialog.locator('.validation-error')).toBeVisible(); + await expect( + dialog.getByText(/l'enseignant n'est pas affecté à cette classe/i), + ).toBeVisible(); + + // Verify: modal stays open (not closed on validation error) + await expect(dialog).toBeVisible(); + }); + test('can filter homework list by class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); - await createHomework(page, 'Devoir filtre classe'); - // Class filter should be visible + // Create homework in two different classes + await createHomeworkInClass(page, 'Devoir classe A', { label: 'E2E-HW-6A' }); + await createHomeworkInClass(page, 'Devoir classe B', { label: 'E2E-HW-6B' }); + + // Both visible initially + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); + + // Filter by class A — class B homework must disappear const classFilter = page.locator('select[aria-label="Filtrer par classe"]'); - await expect(classFilter).toBeVisible(); + await classFilter.selectOption({ label: 'E2E-HW-6A' }); + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).not.toBeVisible({ timeout: 5000 }); - // Select a class to filter - await classFilter.selectOption({ index: 1 }); - - // Should still show homework (it matches the filter) - await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); - - // Select "Toutes les classes" to reset + // Reset — both visible again await classFilter.selectOption({ index: 0 }); - await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); }); }); diff --git a/frontend/src/lib/auth/auth.svelte.ts b/frontend/src/lib/auth/auth.svelte.ts index 16e2314..18b5020 100644 --- a/frontend/src/lib/auth/auth.svelte.ts +++ b/frontend/src/lib/auth/auth.svelte.ts @@ -354,6 +354,18 @@ export function getCurrentUserId(): string | null { return currentUserId; } +/** + * Retourne l'ID utilisateur après avoir garanti que le token est rafraîchi. + * Encapsule le mécanisme de refresh pour que les appelants n'aient pas + * à manipuler refreshToken() directement. + */ +export async function getAuthenticatedUserId(): Promise { + if (!currentUserId) { + await refreshToken(); + } + return currentUserId; +} + /** * Register a callback to be called on logout. * Used to clear user-specific caches (e.g., sessions query cache). diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index 8f53137..1bbaee1 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -5,6 +5,7 @@ export { authenticatedFetch, isAuthenticated, getAccessToken, + getAuthenticatedUserId, getJwtRoles, getCurrentUserId, type LoginCredentials, diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index d55e2fd..8c3c922 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { getApiBaseUrl } from '$lib/api/config'; - import { authenticatedFetch, getCurrentUserId } from '$lib/auth'; + import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; import { untrack } from 'svelte'; @@ -121,15 +121,35 @@ return []; } + async function loadAssignments() { + const userId = await getAuthenticatedUserId(); + if (!userId) return; + const apiUrl = getApiBaseUrl(); + const res = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`); + if (!res.ok) throw new Error('Erreur lors du chargement des affectations'); + const data = await res.json(); + assignments = extractCollection(data); + } + async function loadAll() { try { isLoading = true; error = null; const apiUrl = getApiBaseUrl(); + // classesRes/subjectsRes are used below; loadAssignments & loadHomeworks + // apply side-effects internally and their results are intentionally ignored const [classesRes, subjectsRes] = await Promise.all([ authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`), authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`), + loadAssignments().catch((e) => { + error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations'; + }), + loadHomeworks().catch((e) => { + homeworks = []; + totalItems = 0; + error = e instanceof Error ? e.message : 'Erreur inconnue'; + }), ]); if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes'); @@ -142,19 +162,6 @@ classes = extractCollection(classesData); subjects = extractCollection(subjectsData); - - // getCurrentUserId() must be called AFTER authenticatedFetch, - // which triggers token refresh and sets currentUserId in memory - const userId = getCurrentUserId(); - if (userId) { - const assignmentsRes = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`); - if (assignmentsRes.ok) { - const assignmentsData = await assignmentsRes.json(); - assignments = extractCollection(assignmentsData); - } - } - - await loadHomeworks(); } catch (e) { error = e instanceof Error ? e.message : 'Erreur inconnue'; } finally { @@ -884,8 +891,8 @@ {#if availableTargetClasses.length === 0}

Aucune autre classe disponible pour cette matière.

{:else} -
- +
+ Classes cibles *
{#each availableTargetClasses as cls (cls.id)} {@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)} @@ -1279,7 +1286,8 @@ margin-bottom: 1.25rem; } - .form-group label { + .form-group label, + .field-label { display: block; margin-bottom: 0.5rem; font-weight: 500;