feat(sites) : barre de selection de site (ticket 3/4)
Barre horizontale en haut de l'app qui liste les sites autorises de l'utilisateur et permet de switcher d'un click. Consomme le composant MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0). Composables : - useModules (shared) : consomme /api/modules, expose isModuleActive. Pattern aligne sur useSidebar. - useCurrentSite (layer sites) : singleton state, switchSite optimistic avec rollback sur erreur, garde anti-double-submit, propagation au store auth via action setCurrentSite dediee. Composant : - SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte blanc uniforme (conforme maquette Figma) avec taille 24px forcee via labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n. Integration : - Middleware auth.global.ts : chargement parallele sidebar + modules. - layouts/default.vue : render conditionnel si module Sites actif ET user.sites.length > 0. - logout.vue : reset des 3 composables (sidebar, modules, currentSite) dans un try/finally. - nuxt.config.ts : auto-detection des composables/ de chaque layer module (necessaire car imports.dirs explicite override les defaults Nuxt). Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00, Pommevic #74BF04. Charge aux admins de choisir des teintes foncees (texte blanc non contrastable via calcul WCAG, design choisi). Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite, SiteSelector) incluant garde anti-regression pour useI18n hors setup. 182/182 PHPUnit backend, avec et sans module actif. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
176
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user