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:
@@ -9,7 +9,7 @@ vi.mock('@/lib/axios', () => ({
|
|||||||
|
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
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> }
|
interface MockedApi { get: ReturnType<typeof vi.fn> }
|
||||||
const mocked = apiClient as unknown as MockedApi
|
const mocked = apiClient as unknown as MockedApi
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ vi.mock('@/lib/axios', () => ({
|
|||||||
|
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
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> }
|
interface MockedApi { get: ReturnType<typeof vi.fn> }
|
||||||
const mocked = apiClient as unknown as MockedApi
|
const mocked = apiClient as unknown as MockedApi
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
SaveDraftBody,
|
SaveDraftBody,
|
||||||
StartDraftBody,
|
StartDraftBody,
|
||||||
SubmitBody,
|
SubmitBody,
|
||||||
} from '@form-schema/types/formBuilder'
|
} from '@/composables/forms/types/formBuilder'
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
data: T
|
data: T
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
|
import type { PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
data: T
|
data: T
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { apiClient } from '@/lib/axios'
|
import { apiClient } from '@/lib/axios'
|
||||||
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
|
import type { PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
data: T
|
data: T
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
useSaveFormDraft,
|
useSaveFormDraft,
|
||||||
useSubmitForm,
|
useSubmitForm,
|
||||||
} from '@/composables/api/usePublicForm'
|
} 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. */
|
/** sessionStorage key for reusing an idempotency key across reloads. */
|
||||||
export function draftIdempotencyKey(token: string): string {
|
export function draftIdempotencyKey(token: string): string {
|
||||||
|
|||||||
@@ -1,32 +1,232 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Portal layout skeleton — WS-3 session 1a.
|
// Portal layout — content migrated from apps/portal/src/layouts/portal.vue
|
||||||
//
|
// during WS-3 PR-B1 (single-SPA consolidation). Used by every page
|
||||||
// This is a foundation file. Content migration from apps/portal/ is a
|
// under /portal/** plus selected non-auth portal entry points
|
||||||
// later WS-3 session. The shape (top-bar / main / footer) is fixed
|
// (registreren, wachtwoord-instellen). Authenticated portal users see
|
||||||
// here so the router consolidation can target it with a stable name.
|
// the navbar + mobile drawer; unauthenticated visits render only the
|
||||||
//
|
// main content + footer.
|
||||||
// DO NOT add nav, branding, or auth logic in this file directly.
|
|
||||||
// Future sessions will compose those in via slots or child components.
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
|
<AppLoadingIndicator ref="refLoadingIndicator" />
|
||||||
|
|
||||||
|
<!-- Navbar: only shown when authenticated -->
|
||||||
<VAppBar
|
<VAppBar
|
||||||
density="compact"
|
v-if="authStore.isAuthenticated"
|
||||||
flat
|
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>
|
</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>
|
<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>
|
</VMain>
|
||||||
|
|
||||||
<VFooter
|
<VFooter
|
||||||
app
|
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>
|
</VFooter>
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,38 +1,85 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
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
|
// Stub Vuetify components as their semantic HTML equivalents so we can
|
||||||
// assert structure without pulling vuetify/components (which loads .css
|
// assert structure without pulling vuetify/components. AppLoadingIndicator
|
||||||
// files that the trimmed-down vitest config can't transform).
|
// is also stubbed here with the methods (.fallbackHandle / .resolveHandle)
|
||||||
|
// that PortalLayout's watcher invokes via the template ref.
|
||||||
const vuetifyStubs = {
|
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>' },
|
VApp: { template: '<div><slot /></div>' },
|
||||||
VAppBar: { template: '<header><slot /></header>' },
|
VAppBar: { template: '<header><slot /></header>' },
|
||||||
VMain: { template: '<main><slot /></main>' },
|
VMain: { template: '<main><slot /></main>' },
|
||||||
VFooter: { template: '<footer><slot /></footer>' },
|
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', () => {
|
describe('PortalLayout', () => {
|
||||||
it('mounts without throwing', () => {
|
it('mounts without throwing', () => {
|
||||||
const wrapper = mount(PortalLayout, {
|
const wrapper = mount(PortalLayout, {
|
||||||
global: {
|
global: { stubs: { ...vuetifyStubs, RouterView: true } },
|
||||||
stubs: { ...vuetifyStubs, RouterView: true },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.exists()).toBe(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, {
|
const wrapper = mount(PortalLayout, {
|
||||||
global: {
|
global: { stubs: { ...vuetifyStubs, RouterView: true } },
|
||||||
stubs: { ...vuetifyStubs, RouterView: true },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('header').exists()).toBe(true)
|
|
||||||
expect(wrapper.find('main').exists()).toBe(true)
|
expect(wrapper.find('main').exists()).toBe(true)
|
||||||
expect(wrapper.find('footer').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', () => {
|
it('renders a RouterView inside the main region', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user