feat: Paralléliser les appels API de la page devoirs
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.
This commit is contained in:
@@ -109,13 +109,17 @@ test.describe('Homework Management (Story 5.1)', () => {
|
|||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure class and subject exist
|
// Ensure classes and subject exist
|
||||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||||
try {
|
try {
|
||||||
runSql(
|
runSql(
|
||||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
`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`
|
`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(
|
runSql(
|
||||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
`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`
|
`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) ` +
|
`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`
|
`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(
|
runSql(
|
||||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
`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`
|
`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) {
|
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 page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
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-subject').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-title').fill(title);
|
await page.locator('#hw-title').fill(title);
|
||||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
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 });
|
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 }) => {
|
test('can filter homework list by class', async ({ page }) => {
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
await navigateToHomework(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"]');
|
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
|
// Reset — both visible again
|
||||||
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
|
|
||||||
await classFilter.selectOption({ index: 0 });
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,18 @@ export function getCurrentUserId(): string | null {
|
|||||||
return currentUserId;
|
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<string | null> {
|
||||||
|
if (!currentUserId) {
|
||||||
|
await refreshToken();
|
||||||
|
}
|
||||||
|
return currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback to be called on logout.
|
* Register a callback to be called on logout.
|
||||||
* Used to clear user-specific caches (e.g., sessions query cache).
|
* Used to clear user-specific caches (e.g., sessions query cache).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
authenticatedFetch,
|
authenticatedFetch,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
|
getAuthenticatedUserId,
|
||||||
getJwtRoles,
|
getJwtRoles,
|
||||||
getCurrentUserId,
|
getCurrentUserId,
|
||||||
type LoginCredentials,
|
type LoginCredentials,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { getApiBaseUrl } from '$lib/api/config';
|
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 Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -121,15 +121,35 @@
|
|||||||
return [];
|
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<TeacherAssignment>(data);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
try {
|
try {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
const apiUrl = getApiBaseUrl();
|
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([
|
const [classesRes, subjectsRes] = await Promise.all([
|
||||||
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
|
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
|
||||||
authenticatedFetch(`${apiUrl}/subjects?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');
|
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
|
||||||
@@ -142,19 +162,6 @@
|
|||||||
|
|
||||||
classes = extractCollection<SchoolClass>(classesData);
|
classes = extractCollection<SchoolClass>(classesData);
|
||||||
subjects = extractCollection<Subject>(subjectsData);
|
subjects = extractCollection<Subject>(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<TeacherAssignment>(assignmentsData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadHomeworks();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -884,8 +891,8 @@
|
|||||||
{#if availableTargetClasses.length === 0}
|
{#if availableTargetClasses.length === 0}
|
||||||
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
|
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="form-group">
|
<div class="form-group" role="group" aria-label="Classes cibles">
|
||||||
<label>Classes cibles *</label>
|
<span class="field-label">Classes cibles *</span>
|
||||||
<div class="checkbox-list">
|
<div class="checkbox-list">
|
||||||
{#each availableTargetClasses as cls (cls.id)}
|
{#each availableTargetClasses as cls (cls.id)}
|
||||||
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
|
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
|
||||||
@@ -1279,7 +1286,8 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label,
|
||||||
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
Reference in New Issue
Block a user