diff --git a/frontend/e2e/admin-responsive-nav.spec.ts b/frontend/e2e/admin-responsive-nav.spec.ts index 743e51f..1396270 100644 --- a/frontend/e2e/admin-responsive-nav.spec.ts +++ b/frontend/e2e/admin-responsive-nav.spec.ts @@ -119,6 +119,7 @@ test.describe('Admin Responsive Navigation', () => { const drawer = page.locator('[role="dialog"][aria-modal="true"]'); await expect(drawer).toBeVisible(); + // Active category "Personnes" should be auto-expanded const activeLink = drawer.locator('.mobile-nav-link.active'); await expect(activeLink).toHaveText('Utilisateurs'); }); @@ -128,11 +129,13 @@ test.describe('Admin Responsive Navigation', () => { await page.goto(`${ALPHA_URL}/admin/users`); await page.waitForLoadState('networkidle'); - // Open menu and click Classes + // Open menu and expand "Organisation" section to find "Classes" await page.getByRole('button', { name: /ouvrir le menu/i }).click(); const drawer = page.locator('[role="dialog"][aria-modal="true"]'); await expect(drawer).toBeVisible(); + // Expand "Organisation" accordion + await drawer.getByRole('button', { name: 'Organisation' }).click(); await drawer.getByRole('link', { name: 'Classes' }).click(); // Menu should close and page should navigate @@ -143,6 +146,30 @@ test.describe('Admin Responsive Navigation', () => { const label = page.locator('.mobile-section-label'); await expect(label).toHaveText('Classes'); }); + + test('accordion sections expand and collapse', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: /ouvrir le menu/i }).click(); + const drawer = page.locator('[role="dialog"][aria-modal="true"]'); + await expect(drawer).toBeVisible(); + + // "Personnes" should be auto-expanded (active category) + await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible(); + + // "Organisation" should be collapsed initially + await expect(drawer.getByRole('link', { name: 'Classes' })).not.toBeVisible(); + + // Expand "Organisation" + await drawer.getByRole('button', { name: 'Organisation' }).click(); + await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible(); + + // Collapse "Organisation" + await drawer.getByRole('button', { name: 'Organisation' }).click(); + await expect(drawer.getByRole('link', { name: 'Classes' })).not.toBeVisible(); + }); }); // ========================================================================= @@ -163,7 +190,7 @@ test.describe('Admin Responsive Navigation', () => { await expect(desktopNav).not.toBeVisible(); }); - test('drawer opens and works', async ({ page }) => { + test('drawer opens and shows grouped nav', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/users`); await page.waitForLoadState('networkidle'); @@ -172,8 +199,11 @@ test.describe('Admin Responsive Navigation', () => { const drawer = page.locator('[role="dialog"][aria-modal="true"]'); await expect(drawer).toBeVisible(); - // All nav links should be visible in drawer + // "Personnes" auto-expanded (contains active link Utilisateurs) await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible(); + + // Expand "Organisation" to see its links + await drawer.getByRole('button', { name: 'Organisation' }).click(); await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible(); await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible(); }); @@ -197,18 +227,42 @@ test.describe('Admin Responsive Navigation', () => { await expect(desktopNav).toBeVisible(); }); - test('desktop nav shows all navigation links', async ({ page }) => { + test('desktop nav shows category dropdowns', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/users`); await page.waitForLoadState('networkidle'); const nav = page.locator('.desktop-nav'); - await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible(); - await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible(); - await expect(nav.getByRole('link', { name: 'Matières' })).toBeVisible(); - await expect(nav.getByRole('link', { name: 'Affectations' })).toBeVisible(); - await expect(nav.getByRole('link', { name: 'Périodes' })).toBeVisible(); - await expect(nav.getByRole('link', { name: 'Pédagogie' })).toBeVisible(); + + // Category triggers should be visible + await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible(); + await expect(nav.getByRole('button', { name: /organisation/i })).toBeVisible(); + await expect(nav.getByRole('button', { name: /année scolaire/i })).toBeVisible(); + await expect(nav.getByRole('button', { name: /paramètres/i })).toBeVisible(); + + // Hover "Personnes" to reveal dropdown + await nav.getByRole('button', { name: /personnes/i }).hover(); + const dropdown = nav.locator('.dropdown-panel').first(); + await expect(dropdown).toBeVisible(); + await expect(dropdown.getByRole('menuitem', { name: 'Utilisateurs' })).toBeVisible(); + await expect(dropdown.getByRole('menuitem', { name: 'Élèves' })).toBeVisible(); + + // Hover "Organisation" + await nav.getByRole('button', { name: /organisation/i }).hover(); + const orgDropdown = nav.locator('.dropdown-panel').first(); + await expect(orgDropdown.getByRole('menuitem', { name: 'Classes' })).toBeVisible(); + await expect(orgDropdown.getByRole('menuitem', { name: 'Matières' })).toBeVisible(); + await expect(orgDropdown.getByRole('menuitem', { name: 'Affectations' })).toBeVisible(); + }); + + test('active category trigger is highlighted', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + const nav = page.locator('.desktop-nav'); + const personnesTrigger = nav.getByRole('button', { name: /personnes/i }); + await expect(personnesTrigger).toHaveClass(/active/); }); test('hides mobile section label', async ({ page }) => { diff --git a/frontend/e2e/calendar.spec.ts b/frontend/e2e/calendar.spec.ts index 19e4457..42f0176 100644 --- a/frontend/e2e/calendar.spec.ts +++ b/frontend/e2e/calendar.spec.ts @@ -83,7 +83,10 @@ test.describe('Calendar Management (Story 2.11)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); - await page.getByRole('link', { name: /calendrier/i }).click(); + // Hover "Année scolaire" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /année scolaire/i }).hover(); + await nav.getByRole('menuitem', { name: /calendrier/i }).click(); await expect(page).toHaveURL(/\/admin\/calendar/); await expect( diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts index ef1ae7b..e38b893 100644 --- a/frontend/e2e/pedagogy.spec.ts +++ b/frontend/e2e/pedagogy.spec.ts @@ -59,7 +59,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); - const pedagogyLink = page.getByRole('link', { name: /pédagogie/i }); + // Hover "Paramètres" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /paramètres/i }).hover(); + const pedagogyLink = nav.getByRole('menuitem', { name: /pédagogie/i }); await expect(pedagogyLink).toBeVisible(); }); @@ -67,7 +70,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); - await page.getByRole('link', { name: /pédagogie/i }).click(); + // Hover "Paramètres" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /paramètres/i }).hover(); + await nav.getByRole('menuitem', { name: /pédagogie/i }).click(); await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 }); }); diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index c8eff24..32e7d23 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -288,8 +288,10 @@ test.describe('Periods Management (Story 2.3)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin`); - // Click on periods link in the admin navigation - await page.getByRole('link', { name: /périodes/i }).click(); + // Hover "Année scolaire" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /année scolaire/i }).hover(); + await nav.getByRole('menuitem', { name: /périodes/i }).click(); await expect(page).toHaveURL(/\/admin\/academic-year\/periods/); await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible(); diff --git a/frontend/e2e/role-access-control.spec.ts b/frontend/e2e/role-access-control.spec.ts index 7d31a73..bb64874 100644 --- a/frontend/e2e/role-access-control.spec.ts +++ b/frontend/e2e/role-access-control.spec.ts @@ -184,10 +184,14 @@ test.describe('Role-Based Access Control [P0]', () => { await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto(`${ALPHA_URL}/admin`); - // Admin layout should show navigation links (scoped to desktop nav to avoid action cards) + // Admin layout should show grouped navigation (category triggers in desktop nav) const nav = page.locator('.desktop-nav'); - await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 }); - await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible(); + await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible({ timeout: 15000 }); + await expect(nav.getByRole('button', { name: /organisation/i })).toBeVisible(); + + // Hover to reveal dropdown links + await nav.getByRole('button', { name: /personnes/i }).hover(); + await expect(nav.getByRole('menuitem', { name: 'Utilisateurs' })).toBeVisible(); }); test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => { diff --git a/frontend/e2e/student-creation.spec.ts b/frontend/e2e/student-creation.spec.ts index 0f0bd63..5ed309c 100644 --- a/frontend/e2e/student-creation.spec.ts +++ b/frontend/e2e/student-creation.spec.ts @@ -145,8 +145,10 @@ test.describe('Student Creation & Management (Story 3.0)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); - // The nav should have an active "Élèves" link - await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({ + // Hover "Personnes" category to reveal dropdown with "Élèves" link + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /personnes/i }).hover(); + await expect(nav.getByRole('menuitem', { name: /élèves/i })).toBeVisible({ timeout: 10000 }); }); diff --git a/frontend/e2e/teacher-assignments.spec.ts b/frontend/e2e/teacher-assignments.spec.ts index f4bbb05..5eaea48 100644 --- a/frontend/e2e/teacher-assignments.spec.ts +++ b/frontend/e2e/teacher-assignments.spec.ts @@ -118,7 +118,10 @@ test.describe('Teacher Assignments (Story 2.8)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin`); - const navLink = page.getByRole('link', { name: /affectations/i }); + // Hover "Organisation" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /organisation/i }).hover(); + const navLink = nav.getByRole('menuitem', { name: /affectations/i }); await expect(navLink).toBeVisible({ timeout: 15000 }); }); diff --git a/frontend/e2e/teacher-replacements.spec.ts b/frontend/e2e/teacher-replacements.spec.ts index ce10f71..8a5a10a 100644 --- a/frontend/e2e/teacher-replacements.spec.ts +++ b/frontend/e2e/teacher-replacements.spec.ts @@ -121,7 +121,10 @@ test.describe('Teacher Replacements (Story 2.9)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin`); - const navLink = page.getByRole('link', { name: /remplacements/i }); + // Hover "Organisation" category to reveal dropdown + const nav = page.locator('.desktop-nav'); + await nav.getByRole('button', { name: /organisation/i }).hover(); + const navLink = nav.getByRole('menuitem', { name: /remplacements/i }); await expect(navLink).toBeVisible({ timeout: 15000 }); }); diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 4791e56..39fcc94 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -22,20 +22,53 @@ 'ROLE_SECRETARIAT' ]; - const navLinks = [ - { href: '/dashboard', label: 'Tableau de bord', isActive: () => false }, - { href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive }, - { href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive }, - { href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive }, - { href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive }, - { href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive }, - { href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive }, - { href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive }, - { href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive }, - { href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive }, - { href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive }, - { href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }, - { href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive } + // Types + type NavLink = { href: string; label: string }; + type NavCategory = { id: string; label: string; links: NavLink[] }; + type NavEntry = NavLink | NavCategory; + + function isCategory(entry: NavEntry): entry is NavCategory { + return 'links' in entry; + } + + const navEntries: NavEntry[] = [ + { href: '/dashboard', label: 'Tableau de bord' }, + { + id: 'personnes', + label: 'Personnes', + links: [ + { href: '/admin/users', label: 'Utilisateurs' }, + { href: '/admin/parent-invitations', label: 'Invitations parents' }, + { href: '/admin/students', label: 'Élèves' } + ] + }, + { + id: 'organisation', + label: 'Organisation', + links: [ + { href: '/admin/classes', label: 'Classes' }, + { href: '/admin/subjects', label: 'Matières' }, + { href: '/admin/assignments', label: 'Affectations' }, + { href: '/admin/replacements', label: 'Remplacements' } + ] + }, + { + id: 'annee', + label: 'Année scolaire', + links: [ + { href: '/admin/academic-year/periods', label: 'Périodes' }, + { href: '/admin/calendar', label: 'Calendrier' } + ] + }, + { + id: 'parametres', + label: 'Paramètres', + links: [ + { href: '/admin/image-rights', label: "Droit à l'image" }, + { href: '/admin/pedagogy', label: 'Pédagogie' }, + { href: '/admin/branding', label: 'Identité visuelle' } + ] + } ]; // Load user roles and verify admin access @@ -81,43 +114,81 @@ mobileMenuOpen = false; } - // Determine which admin section is active - const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users')); - const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations')); - const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students')); - const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); - const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); - const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods')); - const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments')); - const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements')); - const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar')); - const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights')); - const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy')); - const isBrandingActive = $derived(page.url.pathname.startsWith('/admin/branding')); + // Active link detection + const activeLinkHref = $derived.by(() => { + const path = page.url.pathname; + for (const entry of navEntries) { + if (isCategory(entry)) { + for (const link of entry.links) { + if (path.startsWith(link.href)) return link.href; + } + } else if (entry.href !== '/dashboard' && path.startsWith(entry.href)) { + return entry.href; + } + } + return null; + }); + + const activeCategoryId = $derived.by(() => { + if (!activeLinkHref) return null; + for (const entry of navEntries) { + if (isCategory(entry) && entry.links.some((l) => l.href === activeLinkHref)) { + return entry.id; + } + } + return null; + }); const currentSectionLabel = $derived.by(() => { const path = page.url.pathname; - for (const link of navLinks) { - if (link.href !== '/dashboard' && path.startsWith(link.href)) { - return link.label; + for (const entry of navEntries) { + if (isCategory(entry)) { + for (const link of entry.links) { + if (path.startsWith(link.href)) return link.label; + } } } return 'Administration'; }); - // Close menu on route change + // Desktop dropdown state + let openDesktopCategory = $state(null); + + // Mobile accordion state + let openMobileCategories = $state>(new Set()); + + function toggleMobileCategory(id: string) { + const next = new Set(openMobileCategories); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + openMobileCategories = next; + } + + // Auto-expand active category in mobile when drawer opens + $effect(() => { + if (mobileMenuOpen && activeCategoryId) { + openMobileCategories = new Set([activeCategoryId]); + } + }); + + // Close menus on route change $effect(() => { void page.url.pathname; mobileMenuOpen = false; + openDesktopCategory = null; }); - // Close menu on Escape key + // Close on Escape key $effect(() => { - if (!mobileMenuOpen) return; + if (!mobileMenuOpen && !openDesktopCategory) return; function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { - mobileMenuOpen = false; + if (openDesktopCategory) openDesktopCategory = null; + if (mobileMenuOpen) mobileMenuOpen = false; } } @@ -125,7 +196,7 @@ return () => document.removeEventListener('keydown', handleKeydown); }); - // Lock body scroll when menu is open + // Lock body scroll when mobile menu is open $effect(() => { if (mobileMenuOpen) { document.body.style.overflow = 'hidden'; @@ -169,10 +240,52 @@