diff --git a/docs/sites/ticket-03-spec.md b/docs/sites/ticket-03-spec.md index 7a04bc5..8c62cd1 100644 --- a/docs/sites/ticket-03-spec.md +++ b/docs/sites/ticket-03-spec.md @@ -1,5 +1,44 @@ # Ticket #03 — 3/4 — Barre de sélection de site (navbar horizontale) +## 0. Pivots post-implémentation (2026-04-20) + +Écarts assumés entre la spec initiale (écrite avant exploration de la lib) et +le code livré après implémentation et test visuel. À lire en premier pour +comprendre les divergences lors de la relecture. + +1. **Contraste texte auto supprimé, texte blanc forcé conforme Figma.** + La spec (sections 5, 6, 10) prévoyait un calcul de luminance WCAG pour + décider entre texte noir et blanc sur chaque tile. Après test visuel, le + choix design retenu est d'imposer **texte blanc partout** (default Malio + `text-white font-bold uppercase tracking-wide`). Conséquence : charge à + l'admin de choisir des couleurs de site suffisamment foncées pour que le + blanc reste lisible. Les utilitaires `parseHex`, `getRelativeLuminance`, + `getReadableTextColor` ont été supprimés comme code mort. Seul + `isValidSiteColor(hex)` reste dans `shared/utils/color.ts` (consommé par + `SiteDrawer`). + +2. **Taille texte explicite `text-2xl` (24 px) appliquée via `labelClass`.** + Malio applique `font-bold uppercase tracking-wide` sans taille explicite. + Le wrapper `SiteSelector.vue` passe `labelClass="text-2xl"` pour garantir + les 24 px de la maquette Figma. + +3. **A11y : `ariaGroupLabel` au niveau radiogroup** au lieu de + `ariaLabelActive` / `ariaLabelInactive` par tile. La raison : Malio rend + déjà un `role="radio"` avec `aria-checked` par tile — le lecteur d'écran + annonce "bouton radio coché/non coché" + le nom visible. Ajouter un + `aria-label` par tile aurait dupliqué l'info et alourdi sans bénéfice. + Le seul ajout nécessaire était un label au groupe, fait via + `:aria-label="t('sites.selector.ariaGroupLabel')"` sur `MalioSiteSelector`. + +4. **Auto-détection composables des layers dans `nuxt.config.ts`.** + Pas prévu dans la spec. Ajouté car `imports.dirs` explicite override les + auto-imports par défaut de Nuxt pour les composables de layer. Sans ça, + `useCurrentSite` n'est pas résolu par Nuxt. Scan dynamique aligné sur le + pattern `moduleLayers` existant. + +5. **Couleurs fixtures finales :** `#056CF2` (Châtellerault), `#F3CB00` + (Saint-Jean), `#74BF04` (Pommevic). Choix client post-maquette. + ## 1. Objectif Ce ticket livre l'UI de consommation du module Sites pour l'utilisateur final : une barre horizontale en haut de l'application qui liste les sites autorises de l'utilisateur connecte, met en avant le site courant et permet de basculer d'un site a l'autre en un clic. diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue index 01b4133..7669aa0 100644 --- a/frontend/app/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -14,6 +14,7 @@
+
+ isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0, +) + const translatedSections = computed(() => sections.value.map(section => ({ label: t(section.label), diff --git a/frontend/app/middleware/auth.global.ts b/frontend/app/middleware/auth.global.ts index 9699045..7c6a01f 100644 --- a/frontend/app/middleware/auth.global.ts +++ b/frontend/app/middleware/auth.global.ts @@ -15,9 +15,16 @@ export default defineNuxtRouteMiddleware(async (to) => { } if (auth.isAuthenticated) { - const { loaded, loadSidebar } = useSidebar() - if (!loaded.value) { - await loadSidebar() - } + const { loaded: sidebarLoaded, loadSidebar } = useSidebar() + const { loaded: modulesLoaded, loadModules } = useModules() + + // Chargement parallele sidebar + modules actifs : les deux sont + // consommes par layouts/default.vue (sidebar pour la nav, modules + // pour conditionner le SiteSelector). Charger en parallele evite + // le flash au premier paint de la barre. + await Promise.all([ + sidebarLoaded.value ? Promise.resolve() : loadSidebar(), + modulesLoaded.value ? Promise.resolve() : loadModules(), + ]) } }) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 2dd1243..5170bfe 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -60,6 +60,12 @@ "notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site." } }, + "sites": { + "selector": { + "ariaGroupLabel": "Sélecteur de site actif", + "switchSuccess": "Site courant changé" + } + }, "success": { "auth": { "logout": "Deconnexion reussie" diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index 3902549..917bdfe 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -9,10 +9,21 @@ definePageMeta({ layout: 'auth' }) const auth = useAuthStore() const { resetSidebar } = useSidebar() +const { resetModules } = useModules() +const { resetCurrentSite } = useCurrentSite() onMounted(async () => { - await auth.logout() - resetSidebar() + try { + await auth.logout() + } finally { + // Les resets sont garantis meme si auth.logout() rejette : eviter + // qu'un user suivant (connecte sur le meme onglet) voie l'etat de + // l'ancien. Les trois fonctions reset sont synchrones et ne + // peuvent pas throw (juste des assignations reactives). + resetSidebar() + resetModules() + resetCurrentSite() + } await navigateTo('/login') }) diff --git a/frontend/modules/sites/components/SiteDrawer.vue b/frontend/modules/sites/components/SiteDrawer.vue index 8d929c6..86fd26e 100644 --- a/frontend/modules/sites/components/SiteDrawer.vue +++ b/frontend/modules/sites/components/SiteDrawer.vue @@ -99,7 +99,7 @@ diff --git a/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts new file mode 100644 index 0000000..eddb486 --- /dev/null +++ b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { computed, defineComponent, h, ref, watchEffect } from 'vue' +import type { Site } from '~/shared/types/sites' +import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite' +import SiteSelector from '../SiteSelector.vue' + +const mockPatch = vi.hoisted(() => vi.fn()) +const mockAuthUser = vi.hoisted(() => ({ + value: null as { sites: Site[]; currentSite: Site | null } | null, +})) + +// Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite, +// useAuthStore, useI18n, watchEffect, computed sans import explicite +// (pattern Nuxt). En Vitest on les expose comme globals. +vi.stubGlobal('useCurrentSite', useCurrentSite) +vi.stubGlobal('useApi', () => ({ patch: mockPatch })) +vi.stubGlobal('useAuthStore', () => ({ + get user() { + return mockAuthUser.value + }, + setCurrentSite(site: Site | null) { + if (mockAuthUser.value) { + mockAuthUser.value.currentSite = site + } + }, +})) +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('watchEffect', watchEffect) +vi.stubGlobal('computed', computed) +vi.stubGlobal('ref', ref) + +// Stub de MalioSiteSelector : on se contente de tracker les props recues +// et de re-emettre `change` quand on le simule via `trigger`. Evite de +// monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge). +const MalioSiteSelectorStub = defineComponent({ + name: 'MalioSiteSelector', + props: { + sites: { type: Array, required: true }, + modelValue: { type: String, default: undefined }, + groupClass: { type: String, default: '' }, + tileClass: { type: String, default: '' }, + labelClass: { type: String, default: '' }, + }, + emits: ['update:modelValue', 'change'], + setup(props, { emit }) { + return () => h('div', { + 'data-testid': 'malio-site-selector', + 'data-sites-count': String((props.sites as unknown[]).length), + 'data-active-id': String(props.modelValue ?? ''), + 'data-label-class': props.labelClass, + }, [ + ...(props.sites as Array<{ id: string; name: string; color: string }>).map(site => + h('button', { + 'data-testid': `tile-${site.id}`, + // Emet les deux events comme le vrai MalioSiteSelector + // (update:modelValue + change). Le wrapper n'ecoute que + // change aujourd'hui, mais tracker les deux grave la + // signature et prepare un eventuel v-model futur. + onClick: () => { + emit('update:modelValue', site.id) + emit('change', site) + }, + }, site.name), + ), + ]) + }, +}) + +const SITE_A: Site = { + id: 1, + name: 'Chatellerault', + street: '14 All.', + complement: null, + postalCode: '86100', + city: 'Châtellerault', + color: '#056CF2', + fullAddress: '14 All.\n86100 Châtellerault', +} +const SITE_B: Site = { + id: 2, + name: 'Saint-Jean', + street: 'Z i', + complement: null, + postalCode: '17400', + city: 'Fontenet', + color: '#F3CB00', + fullAddress: 'Z i\n17400 Fontenet', +} + +function mountSelector() { + return mount(SiteSelector, { + global: { + stubs: { MalioSiteSelector: MalioSiteSelectorStub }, + }, + }) +} + +describe('SiteSelector', () => { + beforeEach(() => { + mockPatch.mockReset() + mockAuthUser.value = { + sites: [SITE_A, SITE_B], + currentSite: SITE_A, + } + }) + + it('rend un tile par site autorise', () => { + const wrapper = mountSelector() + const stub = wrapper.find('[data-testid="malio-site-selector"]') + + expect(stub.attributes('data-sites-count')).toBe('2') + }) + + it('marque le site courant via modelValue (string)', () => { + const wrapper = mountSelector() + const stub = wrapper.find('[data-testid="malio-site-selector"]') + + // Chatellerault id=1 => '1' + expect(stub.attributes('data-active-id')).toBe('1') + }) + + it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => { + // Decision design : texte blanc par defaut Malio mais taille 24px + // imposee par la maquette. Le reste des attributs text (white, bold, + // uppercase, tracking-wide) provient du default Malio via twMerge. + const wrapper = mountSelector() + const stub = wrapper.find('[data-testid="malio-site-selector"]') + + expect(stub.attributes('data-label-class')).toBe('text-2xl') + }) + + it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => { + mockPatch.mockResolvedValueOnce({}) + const wrapper = mountSelector() + + await wrapper.find('[data-testid="tile-2"]').trigger('click') + await flushPromises() + + expect(mockPatch).toHaveBeenCalledWith( + '/me/current-site', + { site: '/api/sites/2' }, + expect.anything(), + ) + }) + + it('clic sur le tile deja actif ne declenche aucun PATCH', async () => { + const wrapper = mountSelector() + + await wrapper.find('[data-testid="tile-1"]').trigger('click') + await flushPromises() + + expect(mockPatch).not.toHaveBeenCalled() + }) + + it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => { + // Scenario : admin clique sur Saint-Jean alors que Chatellerault est + // actif, mais le serveur rejette (ex : 500). Apres rollback dans + // useCurrentSite, le composant doit re-afficher Chatellerault actif. + mockPatch.mockRejectedValueOnce(new Error('server down')) + const wrapper = mountSelector() + + // Avant : Chatellerault (id=1) actif. + expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id')) + .toBe('1') + + await wrapper.find('[data-testid="tile-2"]').trigger('click') + await flushPromises() + + // Apres rollback : Chatellerault (id=1) de nouveau actif. + expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id')) + .toBe('1') + // Le store auth ne doit PAS avoir ete laisse avec SITE_B. + expect(mockAuthUser.value?.currentSite).toEqual(SITE_A) + }) +}) diff --git a/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts new file mode 100644 index 0000000..80819c8 --- /dev/null +++ b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Site } from '~/shared/types/sites' +import { useCurrentSite } from '../useCurrentSite' + +const mockPatch = vi.hoisted(() => vi.fn()) +const mockAuthUser = vi.hoisted(() => ({ + value: null as { sites: Site[]; currentSite: Site | null } | null, +})) + +// Stub des auto-imports Nuxt consommes par le composable. +vi.stubGlobal('useApi', () => ({ patch: mockPatch })) +vi.stubGlobal('useAuthStore', () => ({ + get user() { + return mockAuthUser.value + }, + // Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute + // user.currentSite si user present, no-op sinon. + setCurrentSite(site: Site | null) { + if (mockAuthUser.value) { + mockAuthUser.value.currentSite = site + } + }, +})) +vi.stubGlobal('useI18n', () => ({ + t: (key: string) => key, +})) + +const SITE_A: Site = { + id: 1, + name: 'Chatellerault', + street: '14 All. d\'Argenson', + complement: null, + postalCode: '86100', + city: 'Châtellerault', + color: '#056CF2', + fullAddress: '14 All. d\'Argenson\n86100 Châtellerault', +} +const SITE_B: Site = { + id: 2, + name: 'Saint-Jean', + street: 'Z i', + complement: null, + postalCode: '17400', + city: 'Fontenet', + color: '#F3CB00', + fullAddress: 'Z i\n17400 Fontenet', +} + +describe('useCurrentSite', () => { + beforeEach(() => { + mockPatch.mockReset() + mockAuthUser.value = { + sites: [SITE_A, SITE_B], + currentSite: SITE_A, + } + const { resetCurrentSite } = useCurrentSite() + resetCurrentSite() + }) + + it('syncFromAuth hydrate le state depuis le store auth', () => { + const { syncFromAuth, currentSite, availableSites } = useCurrentSite() + + syncFromAuth() + + expect(currentSite.value).toEqual(SITE_A) + expect(availableSites.value).toEqual([SITE_A, SITE_B]) + }) + + it('syncFromAuth gere le cas user null (deconnecte)', () => { + mockAuthUser.value = null + const { syncFromAuth, currentSite, availableSites } = useCurrentSite() + + syncFromAuth() + + expect(currentSite.value).toBeNull() + expect(availableSites.value).toEqual([]) + }) + + it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => { + mockPatch.mockImplementation(async () => { + // Au moment du resolve, currentSite est deja basculé. + const state = useCurrentSite() + expect(state.currentSite.value).toEqual(SITE_B) + return {} + }) + + const { syncFromAuth, switchSite, currentSite } = useCurrentSite() + syncFromAuth() + await switchSite(SITE_B) + + expect(currentSite.value).toEqual(SITE_B) + expect(mockPatch).toHaveBeenCalledWith( + '/me/current-site', + { site: '/api/sites/2' }, + expect.objectContaining({ toastSuccessMessage: expect.any(String) }), + ) + }) + + it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => { + mockPatch.mockResolvedValueOnce({}) + const { syncFromAuth, switchSite } = useCurrentSite() + syncFromAuth() + + await switchSite(SITE_B) + + expect(mockAuthUser.value?.currentSite).toEqual(SITE_B) + }) + + it('switchSite rollback le currentSite local si la requete echoue', async () => { + mockPatch.mockRejectedValueOnce(new Error('network')) + const { syncFromAuth, switchSite, currentSite } = useCurrentSite() + syncFromAuth() + + await expect(switchSite(SITE_B)).rejects.toThrow('network') + + expect(currentSite.value).toEqual(SITE_A) + }) + + it('switchSite ne propage pas au store auth en cas d\'echec', async () => { + mockPatch.mockRejectedValueOnce(new Error('network')) + const { syncFromAuth, switchSite } = useCurrentSite() + syncFromAuth() + + await expect(switchSite(SITE_B)).rejects.toThrow() + + expect(mockAuthUser.value?.currentSite).toEqual(SITE_A) + }) + + it('switching est vrai pendant la requete et faux apres', async () => { + let resolveRequest: (value: unknown) => void = () => {} + mockPatch.mockImplementation( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + + const { syncFromAuth, switchSite, switching } = useCurrentSite() + syncFromAuth() + + const pending = switchSite(SITE_B) + expect(switching.value).toBe(true) + + resolveRequest({}) + await pending + + expect(switching.value).toBe(false) + }) + + it('double switchSite concurrent : le second appel est un no-op silencieux', async () => { + let resolveRequest: (value: unknown) => void = () => {} + mockPatch.mockImplementation( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + + const { syncFromAuth, switchSite } = useCurrentSite() + syncFromAuth() + + const first = switchSite(SITE_B) + await switchSite(SITE_A) // doit etre no-op (switching=true) + + // Le second appel ne declenche pas de PATCH additionnel. + expect(mockPatch).toHaveBeenCalledTimes(1) + + resolveRequest({}) + await first + }) + + it('resetCurrentSite vide tout l\'etat singleton', () => { + const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite() + syncFromAuth() + expect(currentSite.value).not.toBeNull() + + resetCurrentSite() + + expect(currentSite.value).toBeNull() + expect(availableSites.value).toEqual([]) + expect(switching.value).toBe(false) + }) + + it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => { + // Historique : une premiere version du composable appelait useI18n() + // dans `switchSite` plutot qu'au top du setup. Consequence en runtime : + // l'appel depuis un event handler (click) hors contexte setup levait + // "Must be called at the top of a setup function". Ce test grave le + // contrat : useCurrentSite() DOIT capturer les 3 services a + // l'initialisation, pas paresseusement. + // + // Verification : on remplace useI18n par un mock qui throw au 2e appel. + // Si switchSite invoque useI18n() lui-meme, ce test cassera. + let i18nCallCount = 0 + vi.stubGlobal('useI18n', () => { + i18nCallCount++ + if (i18nCallCount > 1) { + throw new Error('useI18n called more than once — regression bug runtime') + } + return { t: (key: string) => key } + }) + + mockPatch.mockResolvedValueOnce({}) + const { syncFromAuth, switchSite } = useCurrentSite() + syncFromAuth() + + // Si switchSite appelait useI18n() en interne, ce call incrementerait + // i18nCallCount a 2 et throw. La garde du test passe uniquement si + // la capture a bien eu lieu au setup (i18nCallCount reste a 1). + await switchSite(SITE_B) + + expect(i18nCallCount).toBe(1) + + // Restaure le stub par defaut pour les tests suivants. + vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) + }) +}) diff --git a/frontend/modules/sites/composables/useCurrentSite.ts b/frontend/modules/sites/composables/useCurrentSite.ts new file mode 100644 index 0000000..2e727e9 --- /dev/null +++ b/frontend/modules/sites/composables/useCurrentSite.ts @@ -0,0 +1,104 @@ +/** + * Composable de gestion du site courant (ticket 3 module Sites). + * + * Pattern aligne sur `useSidebar` : state singleton au niveau module, + * hydrate depuis `useAuthStore().user`, mute de maniere optimistic avec + * rollback si la requete PATCH `/api/me/current-site` echoue. + * + * Garantie d'unicite : le flag `switching` bloque les double-clicks + * concurrents. Le reset explicite est appele au logout + * (voir `modules/core/pages/logout.vue`). + * + * Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`) + * garantit deja l'invariant "user avec sites non vide => currentSite non null" + * apres tout PATCH /rbac. Le front consomme l'etat renvoye tel quel. + * + * Contrainte d'appel : `useCurrentSite()` doit etre invoque au top du + * `setup()` d'un composant (ou d'un autre composable appele au setup). + * Les dependances `useI18n`, `useApi` et `useAuthStore` sont resolues + * a l'initialisation et reutilisees par `switchSite` — ceci evite le + * "Must be called at the top of a setup function" qui se produirait + * si on les appelait paresseusement depuis une fonction async declenchee + * par un handler d'event (hors contexte setup). + */ +import { ref } from 'vue' +import type { Site } from '~/shared/types/sites' + +const currentSite = ref(null) +const availableSites = ref([]) +const switching = ref(false) + +export function useCurrentSite() { + // Resolution au setup : les 3 services doivent etre invoques dans un + // contexte composant. Leur capture ici permet a switchSite() de + // s'executer plus tard (handler de click, async) sans crash. + const auth = useAuthStore() + const api = useApi() + const { t } = useI18n() + + /** + * Synchronise le state singleton depuis le store auth. A appeler au + * mount du SiteSelector (ou via un watcher sur `auth.user`). + */ + function syncFromAuth(): void { + availableSites.value = auth.user?.sites ?? [] + currentSite.value = auth.user?.currentSite ?? null + } + + /** + * Bascule le site courant. Optimistic UI : la mutation locale precede + * la requete HTTP. En cas d'echec (`api.patch` throw), l'etat local est + * restaure — le store auth n'a PAS ete muté a ce stade (la propagation + * `auth.setCurrentSite` se fait uniquement apres un succes HTTP), donc + * aucun rollback cote auth n'est necessaire. + * + * Garde anti-double-submit : si un switch est deja en vol, le second + * appel est un no-op silencieux. + */ + async function switchSite(site: Site): Promise { + if (switching.value) { + return + } + + const previousLocal = currentSite.value + currentSite.value = site + switching.value = true + + try { + await api.patch( + '/me/current-site', + { site: `/api/sites/${site.id}` }, + { toastSuccessMessage: t('sites.selector.switchSuccess') }, + ) + // Propage au store auth via l'action dediee — plus tracable que + // la mutation directe et garantit la notification des watchers. + // N'est appele qu'apres un succes HTTP donc pas de rollback a + // prevoir sur cette ligne. + auth.setCurrentSite(site) + } catch (error) { + currentSite.value = previousLocal + throw error + } finally { + switching.value = false + } + } + + /** + * Vide l'etat singleton. Appele au logout pour eviter qu'un user + * suivant (connecte sur le meme onglet) voie les sites de l'ancien. + */ + function resetCurrentSite(): void { + currentSite.value = null + availableSites.value = [] + switching.value = false + } + + return { + currentSite, + availableSites, + switching, + switchSite, + syncFromAuth, + resetCurrentSite, + } +} diff --git a/frontend/modules/sites/utils/color.ts b/frontend/modules/sites/utils/color.ts deleted file mode 100644 index 2530d30..0000000 --- a/frontend/modules/sites/utils/color.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Validation du format de couleur d'un site. - * - * Aligne sur la regex backend (Site entity) : seul le format #RRGGBB - * strict est accepte, avec 6 caracteres hexadecimaux apres le #. - * Tolere la casse (majuscules, minuscules, mixte). - * - * Utilise par SiteDrawer pour bloquer le submit cote front avant qu'une - * requete invalide parte au backend. - */ -const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/ - -export function isValidSiteColor(hex: string): boolean { - return HEX_COLOR_REGEX.test(hex) -} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 18dd366..9f17d13 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -3,11 +3,21 @@ import { resolve } from 'node:path' // Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer. const modulesDir = resolve(__dirname, 'modules') -const moduleLayers = existsSync(modulesDir) +const moduleDirs = existsSync(modulesDir) ? readdirSync(modulesDir, { withFileTypes: true }) .filter(d => d.isDirectory()) - .map(d => `./modules/${d.name}`) + .map(d => d.name) : [] +const moduleLayers = moduleDirs.map(name => `./modules/${name}`) + +// Auto-detect composables dirs pour chaque layer module. Necessaire car le +// `imports.dirs` explicite ci-dessous override le comportement par defaut +// de Nuxt (qui scannerait composables/ de chaque layer automatiquement). +// Sans ca, useCurrentSite / autres composables des modules ne seraient pas +// resolus a l'execution — cf. ticket 3 bug detecte apres review. +const moduleComposableDirs = moduleDirs + .map(name => `./modules/${name}/composables`) + .filter(path => existsSync(resolve(__dirname, path))) export default defineNuxtConfig({ compatibilityDate: '2025-07-15', @@ -51,6 +61,7 @@ export default defineNuxtConfig({ 'shared/composables', 'shared/utils', 'shared/stores', + ...moduleComposableDirs, ], }, vite: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6695e2e..539a0bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "coltura-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.3.0", + "@malio/layer-ui": "^1.4.0", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -22,6 +22,7 @@ "@nuxt/eslint-config": "^1.9.0", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-vue": "^6.0.6", "@vue/test-utils": "^2.4.6", "eslint": "^9.36.0", "eslint-plugin-vue": "^10.5.0", @@ -83,6 +84,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -580,27 +582,6 @@ "integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1839,9 +1820,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.3.0", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.3.0/layer-ui-1.3.0.tgz", - "integrity": "sha512-Gs4pnlWTWrhoF3QQKxYBu4IxN65O9B4bls7s+ONm05qvI2Y2x7N4VNFGjWvT+rNQ4BzHFCxSCzN4V3o6p0Q7uw==", + "version": "1.4.0", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.0/layer-ui-1.4.0.tgz", + "integrity": "sha512-2LBe/WqOwNw61Y+9y2SDgsB3/JCTS7VOYfQHFLMb6GXOIj1Vmjxqf8GEzQOzre4pGI+n8w2o+VVn6ttQIkBtzA==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", @@ -2186,6 +2167,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -2288,6 +2270,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz", "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "^3.5.30", "defu": "^6.1.4", @@ -3957,9 +3940,9 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "license": "MIT" }, "node_modules/@rollup/plugin-alias": { @@ -4628,6 +4611,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -4690,6 +4674,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -5206,12 +5191,12 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", - "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2" + "@rolldown/pluginutils": "1.0.0-rc.13" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -5469,6 +5454,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", @@ -5712,6 +5698,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6099,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6296,6 +6284,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6410,6 +6399,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6604,7 +6594,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/clean-regexp": { "version": "1.0.0", @@ -7657,6 +7648,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8815,6 +8807,7 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -11205,6 +11198,7 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", "integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==", "license": "MIT", + "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.0", "@nuxt/cli": "^3.34.0", @@ -12263,6 +12257,7 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -12314,6 +12309,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz", "integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.112.0" }, @@ -12580,6 +12576,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -12658,6 +12655,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13201,6 +13199,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13820,6 +13819,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14717,6 +14717,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -15372,6 +15373,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -15638,6 +15640,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16556,6 +16559,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -16600,6 +16604,7 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -16636,6 +16641,7 @@ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz", "integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==", "license": "MIT", + "peer": true, "dependencies": { "@intlify/core-base": "11.3.1", "@intlify/devtools-types": "11.3.1", diff --git a/frontend/package.json b/frontend/package.json index 5d025f4..4b12611 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest" }, "dependencies": { - "@malio/layer-ui": "^1.3.0", + "@malio/layer-ui": "^1.4.0", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -30,6 +30,7 @@ "@nuxt/eslint-config": "^1.9.0", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-vue": "^6.0.6", "@vue/test-utils": "^2.4.6", "eslint": "^9.36.0", "eslint-plugin-vue": "^10.5.0", diff --git a/frontend/shared/composables/__tests__/useModules.test.ts b/frontend/shared/composables/__tests__/useModules.test.ts new file mode 100644 index 0000000..0854fe7 --- /dev/null +++ b/frontend/shared/composables/__tests__/useModules.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useModules } from '../useModules' + +// Mock de useApi : on peut scripter la reponse de /api/modules. +const mockApiGet = vi.hoisted(() => vi.fn()) + +// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le +// mock comme global pour que l'appel sans import dans useModules.ts +// (pattern aligne sur useSidebar) fonctionne. +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) + +describe('useModules', () => { + beforeEach(() => { + mockApiGet.mockReset() + // Reset l'etat singleton entre tests. + const { resetModules } = useModules() + resetModules() + }) + + it('charge la liste des modules actifs depuis /api/modules', async () => { + mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] }) + const { loadModules, activeModuleIds, loaded } = useModules() + + await loadModules() + + expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false }) + expect(activeModuleIds.value).toEqual(['core', 'sites']) + expect(loaded.value).toBe(true) + }) + + it('isModuleActive retourne true pour un id present', async () => { + mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] }) + const { loadModules, isModuleActive } = useModules() + await loadModules() + + expect(isModuleActive('sites')).toBe(true) + expect(isModuleActive('core')).toBe(true) + }) + + it('isModuleActive retourne false pour un id absent', async () => { + mockApiGet.mockResolvedValueOnce({ modules: ['core'] }) + const { loadModules, isModuleActive } = useModules() + await loadModules() + + expect(isModuleActive('sites')).toBe(false) + expect(isModuleActive('inexistant')).toBe(false) + }) + + it('swallow les erreurs reseau et laisse la liste vide', async () => { + mockApiGet.mockRejectedValueOnce(new Error('boom')) + const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules() + + await loadModules() + + expect(activeModuleIds.value).toEqual([]) + expect(loaded.value).toBe(true) + expect(isModuleActive('sites')).toBe(false) + }) + + it('resetModules vide l\'etat', async () => { + mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] }) + const { loadModules, resetModules, activeModuleIds, loaded } = useModules() + await loadModules() + expect(activeModuleIds.value.length).toBeGreaterThan(0) + + resetModules() + + expect(activeModuleIds.value).toEqual([]) + expect(loaded.value).toBe(false) + }) +}) diff --git a/frontend/shared/composables/__tests__/useSidebar.test.ts b/frontend/shared/composables/__tests__/useSidebar.test.ts new file mode 100644 index 0000000..237d279 --- /dev/null +++ b/frontend/shared/composables/__tests__/useSidebar.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useSidebar } from '../useSidebar' + +const mockApiGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) + +/** + * Tests de l'invariant "loadSidebar ne reject jamais". + * + * Garantie utilisee par le middleware auth.global.ts qui fait un + * Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le + * middleware echoue et toute l'app avec. Le swallow interne est donc + * load-bearing et ce test le verrouille. + */ +describe('useSidebar', () => { + beforeEach(() => { + mockApiGet.mockReset() + const { resetSidebar } = useSidebar() + resetSidebar() + }) + + it('charge sections et disabledRoutes depuis /api/sidebar', async () => { + mockApiGet.mockResolvedValueOnce({ + sections: [{ label: 's', icon: 'i', items: [] }], + disabledRoutes: ['/foo'], + }) + const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar() + + await loadSidebar() + + expect(sections.value).toHaveLength(1) + expect(disabledRoutes.value).toEqual(['/foo']) + expect(loaded.value).toBe(true) + }) + + it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => { + mockApiGet.mockRejectedValueOnce(new Error('boom')) + const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar() + + // Assertion principale : la promise resout normalement meme sur erreur. + await expect(loadSidebar()).resolves.toBeUndefined() + expect(sections.value).toEqual([]) + expect(disabledRoutes.value).toEqual([]) + expect(loaded.value).toBe(true) + }) + + it('isRouteDisabled matche exactement un chemin', async () => { + mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] }) + const { loadSidebar, isRouteDisabled } = useSidebar() + await loadSidebar() + + expect(isRouteDisabled('/foo')).toBe(true) + expect(isRouteDisabled('/foo/bar')).toBe(true) + expect(isRouteDisabled('/other')).toBe(false) + }) + + it('resetSidebar vide l\'etat', async () => { + mockApiGet.mockResolvedValueOnce({ + sections: [{ label: 's', icon: 'i', items: [] }], + disabledRoutes: ['/foo'], + }) + const { loadSidebar, resetSidebar, sections, loaded } = useSidebar() + await loadSidebar() + expect(loaded.value).toBe(true) + + resetSidebar() + + expect(sections.value).toEqual([]) + expect(loaded.value).toBe(false) + }) +}) diff --git a/frontend/shared/composables/useModules.ts b/frontend/shared/composables/useModules.ts new file mode 100644 index 0000000..73d7550 --- /dev/null +++ b/frontend/shared/composables/useModules.ts @@ -0,0 +1,49 @@ +/** + * Composable de lecture des modules actifs (source : `/api/modules`). + * + * State singleton au niveau module : `useSidebar` suit la meme convention. + * Chargement idempotent via le flag `loaded`, reset explicite au logout + * (voir pages/logout.vue). + */ +import { ref } from 'vue' + +const activeModuleIds = ref([]) +const loaded = ref(false) + +export function useModules() { + async function loadModules() { + try { + const api = useApi() + const data = await api.get<{ modules: string[] }>( + '/modules', + {}, + { toast: false }, + ) + activeModuleIds.value = data.modules ?? [] + loaded.value = true + } catch { + // Swallow volontaire aligne sur useSidebar : un echec reseau ne + // doit pas bloquer le rendu, l'app affichera juste sans la + // granularite module (selector masque par defaut). + activeModuleIds.value = [] + loaded.value = true + } + } + + function isModuleActive(id: string): boolean { + return activeModuleIds.value.includes(id) + } + + function resetModules() { + activeModuleIds.value = [] + loaded.value = false + } + + return { + activeModuleIds, + loaded, + loadModules, + isModuleActive, + resetModules, + } +} diff --git a/frontend/shared/composables/useSidebar.ts b/frontend/shared/composables/useSidebar.ts index 0e0bb0a..adbdbbf 100644 --- a/frontend/shared/composables/useSidebar.ts +++ b/frontend/shared/composables/useSidebar.ts @@ -1,3 +1,4 @@ +import { ref } from 'vue' import type { SidebarSection } from '~/shared/types' const sections = ref([]) diff --git a/frontend/shared/stores/auth.ts b/frontend/shared/stores/auth.ts index 35b5ed1..76aece3 100644 --- a/frontend/shared/stores/auth.ts +++ b/frontend/shared/stores/auth.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import type { UserData } from '~/shared/types/user-data' +import type { Site } from '~/shared/types/sites' import { getCurrentUser, login, logout } from '~/shared/services/auth' export const useAuthStore = defineStore('auth', { @@ -66,6 +67,18 @@ export const useAuthStore = defineStore('auth', { } catch { // Silently fail — user session might have expired } + }, + /** + * Action dediee au switch du site courant (ticket 3 module Sites). + * Utilisee par useCurrentSite apres la confirmation serveur, et en + * rollback si la requete PATCH echoue apres une mutation optimistic. + * Passer explicitement par une action plutot que muter user.currentSite + * directement garantit la tracabilite Pinia (devtools). + */ + setCurrentSite(site: Site | null) { + if (this.user) { + this.user.currentSite = site + } } } }) diff --git a/frontend/modules/sites/utils/__tests__/color.test.ts b/frontend/shared/utils/__tests__/color.test.ts similarity index 72% rename from frontend/modules/sites/utils/__tests__/color.test.ts rename to frontend/shared/utils/__tests__/color.test.ts index e07cad7..4210eae 100644 --- a/frontend/modules/sites/utils/__tests__/color.test.ts +++ b/frontend/shared/utils/__tests__/color.test.ts @@ -16,15 +16,15 @@ describe('isValidSiteColor', () => { it('accepte les couleurs fixtures du projet', () => { expect(isValidSiteColor('#056CF2')).toBe(true) - expect(isValidSiteColor('#10B981')).toBe(true) - expect(isValidSiteColor('#F59E0B')).toBe(true) + expect(isValidSiteColor('#F3CB00')).toBe(true) + expect(isValidSiteColor('#74BF04')).toBe(true) }) it('rejette un nom CSS', () => { expect(isValidSiteColor('red')).toBe(false) }) - it('rejette un hex court (3 caracteres)', () => { + it('rejette un hex court', () => { expect(isValidSiteColor('#FFF')).toBe(false) }) @@ -32,14 +32,6 @@ describe('isValidSiteColor', () => { expect(isValidSiteColor('FFFFFF')).toBe(false) }) - it('rejette un format rgb()', () => { - expect(isValidSiteColor('rgb(255, 0, 0)')).toBe(false) - }) - - it('rejette un hex trop long', () => { - expect(isValidSiteColor('#1234567')).toBe(false) - }) - it('rejette un caractere non hex', () => { expect(isValidSiteColor('#12345G')).toBe(false) }) diff --git a/frontend/shared/utils/color.ts b/frontend/shared/utils/color.ts new file mode 100644 index 0000000..8396973 --- /dev/null +++ b/frontend/shared/utils/color.ts @@ -0,0 +1,19 @@ +/** + * Utilitaires de couleur partages. + * + * Aligne sur la regex backend stricte #RRGGBB (voir Site.php). + */ + +const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/ + +/** + * Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres, + * 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules, + * minuscules, mixte). + * + * Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi + * backend — miroir du pattern Symfony Assert\Regex sur Site::$color. + */ +export function isValidSiteColor(hex: string): boolean { + return HEX_COLOR_REGEX.test(hex) +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 9ee0720..946c045 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' import { fileURLToPath } from 'node:url' export default defineConfig({ + plugins: [vue()], test: { environment: 'happy-dom', globals: true, diff --git a/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php index 2362271..cd316fc 100644 --- a/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php +++ b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php @@ -36,7 +36,7 @@ class SitesFixtures extends Fixture public function load(ObjectManager $manager): void { - // Chatellerault : couleur imposee par le ticket (bleu Coltura). + // Chatellerault : bleu Coltura. $this->ensureSite( $manager, name: 'Chatellerault', @@ -47,9 +47,9 @@ class SitesFixtures extends Fixture color: '#056CF2', ); - // Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault. - // Note : le nom du site (identifier) ne reflete pas la ville reelle - // (Fontenet) — c'est une nomenclature interne client. + // Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete + // pas la ville reelle (Fontenet) — c'est une nomenclature interne + // client. $this->ensureSite( $manager, name: 'Saint-Jean', @@ -57,10 +57,10 @@ class SitesFixtures extends Fixture complement: null, postalCode: '17400', city: 'Fontenet', - color: '#10B981', + color: '#F3CB00', ); - // Pommevic : ambre pour une troisieme teinte nettement distincte. + // Pommevic : vert clair. $this->ensureSite( $manager, name: 'Pommevic', @@ -68,7 +68,7 @@ class SitesFixtures extends Fixture complement: null, postalCode: '82400', city: 'Pommevic', - color: '#F59E0B', + color: '#74BF04', ); $manager->flush();