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>
177 lines
6.3 KiB
TypeScript
177 lines
6.3 KiB
TypeScript
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)
|
|
})
|
|
})
|