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(() => { +