From 209e0ef682ea461dc9a8d55e5cb800742151b604 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 5 May 2026 21:35:32 +0200 Subject: [PATCH] feat(layout): context-switcher for multi-role users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds components/shared/ContextSwitcher.vue — a Vuetify menu-button that renders only when useAuthStore.showContextSwitcher is true (i.e. the user has both portal and organizer contexts available). Click calls useAuthStore.setLastContext + resolveLandingRoute and pushes the new route. Wired into both layouts: - PortalLayout.vue: navbar right section, before UserAvatarMenu - DefaultLayoutWithVerticalNav.vue (organizer navbar host): before NavbarThemeSwitcher (OrganizerLayout.vue itself is a 10-line wrapper around DefaultLayoutWithVerticalNav, so the component wires into the actual navbar host). Boundaries matrix update: components-shared now allows `stores` so canonical shared chrome (ContextSwitcher, future global indicators) can read useAuthStore directly without re-homing to components/layout/. stores-portal stays disallowed for components- shared by design — portal-specific state has no place in shared chrome. Adds 3 vitest specs covering: visibility gated by showContextSwitcher, click invokes setLastContext + router.push. Test count 189 → 192. Frontend lint + typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/.eslintrc.cjs | 8 ++- .../src/components/shared/ContextSwitcher.vue | 53 ++++++++++++++ .../shared/__tests__/ContextSwitcher.spec.ts | 72 +++++++++++++++++++ apps/app/src/layouts/PortalLayout.vue | 4 +- .../DefaultLayoutWithVerticalNav.vue | 2 + 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/components/shared/ContextSwitcher.vue create mode 100644 apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs index 8da0dda4..7ff5661b 100644 --- a/apps/app/.eslintrc.cjs +++ b/apps/app/.eslintrc.cjs @@ -238,7 +238,13 @@ module.exports = { // previously owned the cross-zone call (WS-3 PR-B2a). { from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] }, { from: 'navigation', allow: ['types', 'utils', 'navigation'] }, - { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'components-shared'] }, + + // components-shared may read app-wide stores (useAuthStore, + // useNotificationStore — not stores-portal) so canonical shared + // chrome (ContextSwitcher, future global indicators) can stay in + // components/shared/ without re-homing to components/layout/. + // Portal-specific state stays out of shared by design (WS-3 PR-B2a). + { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared'] }, { from: 'components-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'components-shared', 'components-portal'] }, { from: 'components-organizer', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared', 'components-organizer'] }, { from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] }, diff --git a/apps/app/src/components/shared/ContextSwitcher.vue b/apps/app/src/components/shared/ContextSwitcher.vue new file mode 100644 index 00000000..ef46227b --- /dev/null +++ b/apps/app/src/components/shared/ContextSwitcher.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts new file mode 100644 index 00000000..acca214e --- /dev/null +++ b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const mockSetLastContext = vi.fn() +const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' })) +const mockPush = vi.fn() + +const authStoreState: Record = { + showContextSwitcher: true, + setLastContext: mockSetLastContext, + resolveLandingRoute: mockResolveLandingRoute, +} + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: () => authStoreState, +})) + +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRoute: () => ({ meta: { context: 'organizer' } }), + useRouter: () => ({ push: mockPush }), +})) + +const ContextSwitcher = (await import('../ContextSwitcher.vue')).default + +const stubs = { + VMenu: { template: '
' }, + VBtn: { template: '' }, + VList: { template: '
' }, + VListItem: { + props: ['dataTest'], + template: '
', + emits: ['click'], + }, + VListItemTitle: { template: '' }, + VIcon: true, +} + +describe('ContextSwitcher', () => { + it('renders when showContextSwitcher is true', () => { + authStoreState.showContextSwitcher = true + + const wrapper = mount(ContextSwitcher, { global: { stubs } }) + + expect(wrapper.find('[data-test="menu"]').exists()).toBe(true) + }) + + it('does not render when showContextSwitcher is false (single-context user)', () => { + authStoreState.showContextSwitcher = false + + const wrapper = mount(ContextSwitcher, { global: { stubs } }) + + expect(wrapper.find('[data-test="menu"]').exists()).toBe(false) + }) + + it('clicking the alternative context calls setLastContext and router.push', async () => { + authStoreState.showContextSwitcher = true + mockSetLastContext.mockClear() + mockResolveLandingRoute.mockClear() + mockPush.mockClear() + + const wrapper = mount(ContextSwitcher, { global: { stubs } }) + + // current context is 'organizer' (from mocked route.meta.context), + // so the dropdown shows 'switch-to-portal'. + await wrapper.find('[data-test="switch-to-portal"]').trigger('click') + + expect(mockSetLastContext).toHaveBeenCalledWith('portal') + expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal') + expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' }) + }) +}) diff --git a/apps/app/src/layouts/PortalLayout.vue b/apps/app/src/layouts/PortalLayout.vue index 6c482b54..151a29ba 100644 --- a/apps/app/src/layouts/PortalLayout.vue +++ b/apps/app/src/layouts/PortalLayout.vue @@ -8,6 +8,7 @@ import { useRoute, useRouter } from 'vue-router' import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue' +import ContextSwitcher from '@/components/shared/ContextSwitcher.vue' import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue' import { useAuthStore } from '@/stores/useAuthStore' import { usePortalStore } from '@/stores/portal/usePortalStore' @@ -138,8 +139,9 @@ async function logout() { - +
+
diff --git a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue index a80c309e..f6e09810 100644 --- a/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue +++ b/apps/app/src/layouts/components/DefaultLayoutWithVerticalNav.vue @@ -11,6 +11,7 @@ import NavSearchBar from '@/layouts/components/NavSearchBar.vue' import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue' import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue' import UserProfile from '@/layouts/components/UserProfile.vue' +import ContextSwitcher from '@/components/shared/ContextSwitcher.vue' import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue' // @layouts plugin @@ -76,6 +77,7 @@ const navItems = computed(() => { +