feat(layout): context-switcher for multi-role users
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) <noreply@anthropic.com>
This commit is contained in:
@@ -238,7 +238,13 @@ module.exports = {
|
|||||||
// previously owned the cross-zone call (WS-3 PR-B2a).
|
// previously owned the cross-zone call (WS-3 PR-B2a).
|
||||||
{ from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
|
{ from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] },
|
||||||
{ from: 'navigation', allow: ['types', 'utils', 'navigation'] },
|
{ 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-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-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'] },
|
{ from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] },
|
||||||
|
|||||||
53
apps/app/src/components/shared/ContextSwitcher.vue
Normal file
53
apps/app/src/components/shared/ContextSwitcher.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import type { AuthContext } from '@/types/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const currentContext = computed<AuthContext>(() =>
|
||||||
|
route.meta.context === 'portal' ? 'portal' : 'organizer',
|
||||||
|
)
|
||||||
|
|
||||||
|
const otherContext = computed<AuthContext>(() =>
|
||||||
|
currentContext.value === 'portal' ? 'organizer' : 'portal',
|
||||||
|
)
|
||||||
|
|
||||||
|
const isVisible = computed(() => authStore.showContextSwitcher)
|
||||||
|
|
||||||
|
async function switchContext(target: AuthContext): Promise<void> {
|
||||||
|
authStore.setLastContext(target)
|
||||||
|
|
||||||
|
await router.push(authStore.resolveLandingRoute(target))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VMenu v-if="isVisible">
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<VBtn
|
||||||
|
v-bind="menuProps"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="tabler-arrows-shuffle"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ currentContext === 'portal' ? 'Portal' : 'Organizer' }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
:data-test="`switch-to-${otherContext}`"
|
||||||
|
@click="switchContext(otherContext)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="otherContext === 'portal' ? 'tabler-user' : 'tabler-building'" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
Wissel naar {{ otherContext === 'portal' ? 'portal' : 'organizer' }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</template>
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
showContextSwitcher: true,
|
||||||
|
setLastContext: mockSetLastContext,
|
||||||
|
resolveLandingRoute: mockResolveLandingRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/stores/useAuthStore', () => ({
|
||||||
|
useAuthStore: () => authStoreState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', async importOriginal => ({
|
||||||
|
...(await importOriginal<typeof import('vue-router')>()),
|
||||||
|
useRoute: () => ({ meta: { context: 'organizer' } }),
|
||||||
|
useRouter: () => ({ push: mockPush }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ContextSwitcher = (await import('../ContextSwitcher.vue')).default
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
VMenu: { template: '<div data-test="menu"><slot name="activator" :props="{}" /><slot /></div>' },
|
||||||
|
VBtn: { template: '<button><slot /></button>' },
|
||||||
|
VList: { template: '<div><slot /></div>' },
|
||||||
|
VListItem: {
|
||||||
|
props: ['dataTest'],
|
||||||
|
template: '<div :data-test="dataTest" @click="$emit(\'click\')"><slot /></div>',
|
||||||
|
emits: ['click'],
|
||||||
|
},
|
||||||
|
VListItemTitle: { template: '<span><slot /></span>' },
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
||||||
|
import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
|
||||||
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
|
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
||||||
@@ -138,8 +139,9 @@ async function logout() {
|
|||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<!-- Right section: Avatar menu (desktop) -->
|
<!-- Right section: context switcher + avatar menu (desktop) -->
|
||||||
<div class="d-none d-md-flex align-center">
|
<div class="d-none d-md-flex align-center">
|
||||||
|
<ContextSwitcher class="me-2" />
|
||||||
<UserAvatarMenu />
|
<UserAvatarMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
|
|||||||
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
|
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
|
||||||
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
|
||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
|
import ContextSwitcher from '@/components/shared/ContextSwitcher.vue'
|
||||||
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
|
import OrganisationSwitcher from '@/components/layout/OrganisationSwitcher.vue'
|
||||||
|
|
||||||
// @layouts plugin
|
// @layouts plugin
|
||||||
@@ -76,6 +77,7 @@ const navItems = computed(() => {
|
|||||||
|
|
||||||
<NavSearchBar class="flex-grow-1 ms-lg-n3 min-w-0" />
|
<NavSearchBar class="flex-grow-1 ms-lg-n3 min-w-0" />
|
||||||
|
|
||||||
|
<ContextSwitcher class="flex-shrink-0 me-2" />
|
||||||
<NavbarThemeSwitcher class="flex-shrink-0 me-2" />
|
<NavbarThemeSwitcher class="flex-shrink-0 me-2" />
|
||||||
<NavbarShortcuts class="flex-shrink-0" />
|
<NavbarShortcuts class="flex-shrink-0" />
|
||||||
<NavBarNotifications class="flex-shrink-0 me-1" />
|
<NavBarNotifications class="flex-shrink-0 me-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user