refactor(layouts): merge portal navbar/drawer into PortalLayout.vue

Migrates the navbar (event/platform two-mode toggle), mobile drawer
with avatar header + logout, RouterView Suspense wrapper, and footer
from apps/portal/src/layouts/portal.vue into the PortalLayout.vue
skeleton from PR-A. The skeleton's structure (VApp / VAppBar / VMain
/ VFooter) is preserved as the outer shell.

Notable adaptations:
  - useAuthStore → usePortalAuthStore (renamed in C.3)
  - usePortalStore import path → @/stores/portal/usePortalStore
  - mobile nav links now point at /portal/evenementen and /portal/profiel
    (the new sub-zone paths) instead of /evenementen and /profiel
  - explicit `import { useRoute, useRouter }` from vue-router so the
    vitest mock can intercept (auto-import not configured for these in
    the trimmed test config)

Updated PortalLayout.spec.ts to mock the two pinia stores plus
useSkins, vue-router, UserAvatarMenu, and AppLoadingIndicator. Tests
now assert the auth-conditional rendering: header + drawer hidden
when unauthenticated, main + footer always present.

Also pulls in the @form-schema → @/composables/forms/* import
rewrites in the C.4-moved composables that the previous commit's
rename-only diff left unstaged.

Vitest: 23 files / 162 tests, no errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:11:58 +02:00
parent 7282861a7e
commit e3452312d1
8 changed files with 278 additions and 31 deletions

View File

@@ -9,7 +9,7 @@ vi.mock('@/lib/axios', () => ({
import { apiClient } from '@/lib/axios'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
import type { PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
interface MockedApi { get: ReturnType<typeof vi.fn> }
const mocked = apiClient as unknown as MockedApi

View File

@@ -9,7 +9,7 @@ vi.mock('@/lib/axios', () => ({
import { apiClient } from '@/lib/axios'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
import type { PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
interface MockedApi { get: ReturnType<typeof vi.fn> }
const mocked = apiClient as unknown as MockedApi

View File

@@ -9,7 +9,7 @@ import type {
SaveDraftBody,
StartDraftBody,
SubmitBody,
} from '@form-schema/types/formBuilder'
} from '@/composables/forms/types/formBuilder'
interface ApiResponse<T> {
data: T

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
import type { PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
interface ApiResponse<T> {
data: T

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
import type { PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
interface ApiResponse<T> {
data: T

View File

@@ -7,7 +7,7 @@ import {
useSaveFormDraft,
useSubmitForm,
} from '@/composables/api/usePublicForm'
import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@form-schema/types/formBuilder'
import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/composables/forms/types/formBuilder'
/** sessionStorage key for reusing an idempotency key across reloads. */
export function draftIdempotencyKey(token: string): string {

View File

@@ -1,32 +1,232 @@
<script setup lang="ts">
// Portal layout skeleton — WS-3 session 1a.
//
// This is a foundation file. Content migration from apps/portal/ is a
// later WS-3 session. The shape (top-bar / main / footer) is fixed
// here so the router consolidation can target it with a stable name.
//
// DO NOT add nav, branding, or auth logic in this file directly.
// Future sessions will compose those in via slots or child components.
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
// under /portal/** plus selected non-auth portal entry points
// (registreren, wachtwoord-instellen). Authenticated portal users see
// the navbar + mobile drawer; unauthenticated visits render only the
// main content + footer.
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { usePortalAuthStore } from '@/stores/portal/usePortalAuthStore'
import { usePortalStore } from '@/stores/portal/usePortalStore'
import { useRoute, useRouter } from 'vue-router'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = usePortalAuthStore()
const portal = usePortalStore()
const route = useRoute()
const router = useRouter()
const isMobileMenuOpen = ref(false)
// Navbar mode: 'event' shows org name + event name + back link.
// Default ('platform') shows Crewli logo + page title.
const isEventMode = computed(() => route.meta.navMode === 'event')
const navTitle = computed(() => route.meta.navTitle)
const eventName = computed(() => portal.activeEvent?.event_name ?? '')
const orgName = computed(() => portal.activeEvent?.organisation_name ?? '')
const mobileNavItems = computed(() => [
{ title: 'Mijn evenementen', to: '/portal/evenementen', icon: 'tabler-calendar-event' },
{ title: 'Mijn Profiel', to: '/portal/profiel', icon: 'tabler-user' },
])
const isFallbackStateActive = ref(false)
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
watch([isFallbackStateActive, refLoadingIndicator], () => {
if (isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.fallbackHandle()
if (!isFallbackStateActive.value && refLoadingIndicator.value)
refLoadingIndicator.value.resolveHandle()
}, { immediate: true })
async function logout() {
isMobileMenuOpen.value = false
await authStore.logout()
await router.push('/login')
}
</script>
<template>
<VApp>
<AppLoadingIndicator ref="refLoadingIndicator" />
<!-- Navbar: only shown when authenticated -->
<VAppBar
density="compact"
v-if="authStore.isAuthenticated"
flat
color="surface"
border="b"
height="64"
>
<!-- Logo + portal nav land here in a later session -->
<VContainer
fluid
class="d-flex align-center py-0"
style="max-inline-size: 1440px;"
>
<!-- Event mode: Org name + Event name + Back link -->
<template v-if="isEventMode">
<div class="d-flex align-center gap-x-2 flex-shrink-0">
<VIcon
icon="tabler-building"
size="24"
color="primary"
/>
<span
v-if="orgName"
class="text-subtitle-1 font-weight-medium text-high-emphasis d-none d-sm-inline text-truncate"
style="max-width: 200px;"
>
{{ orgName }}
</span>
</div>
<span
v-if="eventName"
class="text-body-1 text-medium-emphasis ms-2 text-truncate d-none d-sm-inline"
style="max-width: 250px;"
>
{{ eventName }}
</span>
<VBtn
variant="text"
size="small"
color="default"
class="text-medium-emphasis ms-2 d-none d-md-flex"
to="/portal/evenementen"
>
<VIcon
start
icon="tabler-arrow-left"
size="16"
/>
Evenementen
</VBtn>
</template>
<!-- Platform mode: Crewli logo + optional page title -->
<template v-else>
<RouterLink
to="/portal/evenementen"
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
>
<VIcon
icon="tabler-users-group"
size="26"
color="primary"
/>
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
Crewli
</span>
</RouterLink>
<span
v-if="navTitle"
class="text-body-1 text-medium-emphasis ms-4 d-none d-md-inline"
>
{{ navTitle }}
</span>
</template>
<VSpacer />
<!-- Right section: Avatar menu (desktop) -->
<div class="d-none d-md-flex align-center">
<UserAvatarMenu />
</div>
<!-- Mobile nav toggle -->
<VAppBarNavIcon
class="d-md-none"
@click="isMobileMenuOpen = !isMobileMenuOpen"
/>
</VContainer>
</VAppBar>
<!-- Mobile navigation drawer -->
<VNavigationDrawer
v-if="authStore.isAuthenticated"
v-model="isMobileMenuOpen"
temporary
location="end"
class="d-md-none"
>
<div class="pa-4 pb-2">
<div class="d-flex align-center gap-3">
<VAvatar
size="40"
color="primary"
>
<span class="text-body-2 font-weight-medium text-white">
{{ (authStore.user?.first_name?.charAt(0) ?? '') + (authStore.user?.last_name?.charAt(0) ?? '') }}
</span>
</VAvatar>
<div class="min-w-0">
<div class="text-body-1 font-weight-bold text-truncate">
{{ authStore.user?.full_name }}
</div>
<div class="text-caption text-medium-emphasis text-truncate">
{{ authStore.user?.email }}
</div>
</div>
</div>
</div>
<VDivider />
<VList nav>
<VListItem
v-for="item in mobileNavItems"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:title="item.title"
@click="isMobileMenuOpen = false"
/>
<VDivider class="my-2" />
<VListItem
prepend-icon="tabler-logout"
title="Uitloggen"
class="text-error"
@click="logout"
/>
</VList>
</VNavigationDrawer>
<VMain>
<RouterView />
<VContainer
fluid
class="pa-4 pa-sm-6"
style="max-inline-size: 1440px;"
>
<RouterView v-slot="{ Component }">
<Suspense
:timeout="0"
@fallback="isFallbackStateActive = true"
@resolve="isFallbackStateActive = false"
>
<Component :is="Component" />
</Suspense>
</RouterView>
</VContainer>
</VMain>
<VFooter
app
class="text-caption"
color="transparent"
class="justify-center text-caption text-medium-emphasis py-3"
>
<!-- Portal footer content lands here in a later session -->
Powered by Crewli
</VFooter>
</VApp>
</template>

View File

@@ -1,38 +1,85 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PortalLayout from '../PortalLayout.vue'
import { createPinia, setActivePinia } from 'pinia'
// Mock auto-imported composables / external pinia stores so the layout's
// <script setup> doesn't reach into Vuexy or the real auth + portal
// stores during a unit test mount.
vi.mock('@core/composable/useSkins', () => ({
useSkins: () => ({ injectSkinClasses: () => {} }),
}))
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => ({ meta: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/stores/portal/usePortalAuthStore', () => ({
usePortalAuthStore: () => ({
isAuthenticated: false,
user: null,
logout: vi.fn(),
}),
}))
vi.mock('@/stores/portal/usePortalStore', () => ({
usePortalStore: () => ({ activeEvent: null }),
}))
vi.mock('@/components/portal/UserAvatarMenu.vue', () => ({
default: { template: '<div data-test="avatar" />' },
}))
// AppLoadingIndicator is stubbed at mount time below (see vuetifyStubs).
const PortalLayout = (await import('../PortalLayout.vue')).default
// Stub Vuetify components as their semantic HTML equivalents so we can
// assert structure without pulling vuetify/components (which loads .css
// files that the trimmed-down vitest config can't transform).
// assert structure without pulling vuetify/components. AppLoadingIndicator
// is also stubbed here with the methods (.fallbackHandle / .resolveHandle)
// that PortalLayout's watcher invokes via the template ref.
const vuetifyStubs = {
AppLoadingIndicator: {
template: '<div data-test="loading" />',
setup(_p: unknown, { expose }: { expose: (api: Record<string, () => void>) => void }) {
expose({ fallbackHandle() {}, resolveHandle() {} })
},
},
VApp: { template: '<div><slot /></div>' },
VAppBar: { template: '<header><slot /></header>' },
VMain: { template: '<main><slot /></main>' },
VFooter: { template: '<footer><slot /></footer>' },
VContainer: { template: '<div><slot /></div>' },
VNavigationDrawer: { template: '<aside><slot /></aside>' },
VAppBarNavIcon: true,
VBtn: true,
VIcon: true,
VSpacer: true,
VAvatar: true,
VList: true,
VListItem: true,
VDivider: true,
RouterLink: true,
}
setActivePinia(createPinia())
describe('PortalLayout', () => {
it('mounts without throwing', () => {
const wrapper = mount(PortalLayout, {
global: {
stubs: { ...vuetifyStubs, RouterView: true },
},
global: { stubs: { ...vuetifyStubs, RouterView: true } },
})
expect(wrapper.exists()).toBe(true)
})
it('renders a top app bar, main region, and footer', () => {
it('renders a main region and footer (header is auth-conditional)', () => {
const wrapper = mount(PortalLayout, {
global: {
stubs: { ...vuetifyStubs, RouterView: true },
},
global: { stubs: { ...vuetifyStubs, RouterView: true } },
})
expect(wrapper.find('header').exists()).toBe(true)
expect(wrapper.find('main').exists()).toBe(true)
expect(wrapper.find('footer').exists()).toBe(true)
// unauthenticated mock → no navbar, no drawer
expect(wrapper.find('header').exists()).toBe(false)
expect(wrapper.find('aside').exists()).toBe(false)
})
it('renders a RouterView inside the main region', () => {