chore(primevue): F3 — PrimeVue foundation with parallel-mode operation #24
4
apps/app/auto-imports.d.ts
vendored
4
apps/app/auto-imports.d.ts
vendored
@@ -10,6 +10,7 @@ declare global {
|
||||
const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']
|
||||
const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const FORM_API_ERRORS_KEY: typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']
|
||||
const PUBLIC_FORM_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']
|
||||
const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
@@ -245,6 +246,7 @@ declare global {
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFormDraft: typeof import('./src/composables/useFormDraft')['useFormDraft']
|
||||
const useFormError: typeof import('./src/composables/useFormError')['useFormError']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
@@ -394,6 +396,7 @@ declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef<typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly FORM_API_ERRORS_KEY: UnwrapRef<typeof import('./src/composables/useFormError')['FORM_API_ERRORS_KEY']>
|
||||
readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY']>
|
||||
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
@@ -621,6 +624,7 @@ declare module 'vue' {
|
||||
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
|
||||
readonly useFormError: UnwrapRef<typeof import('./src/composables/useFormError')['useFormError']>
|
||||
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
|
||||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
|
||||
2
apps/app/components.d.ts
vendored
2
apps/app/components.d.ts
vendored
@@ -96,9 +96,11 @@ declare module 'vue' {
|
||||
FormErrorState: typeof import('./src/components/shared/public-form/FormErrorState.vue')['default']
|
||||
FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default']
|
||||
FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default']
|
||||
FormField: typeof import('./src/components/forms/FormField.vue')['default']
|
||||
FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default']
|
||||
GridBg: typeof import('./src/components/timetable/GridBg.vue')['default']
|
||||
I18n: typeof import('./src/@core/components/I18n.vue')['default']
|
||||
Icon: typeof import('./src/components/Icon.vue')['default']
|
||||
IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default']
|
||||
ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default']
|
||||
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
|
||||
|
||||
@@ -1,9 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultLayoutWithVerticalNav from '@/layouts/components/DefaultLayoutWithVerticalNav.vue'
|
||||
// OrganizerLayout — F3 rewrite. Delegates to the PrimeVue-only
|
||||
// AppShell with org + (conditionally) platform nav items. Replaces
|
||||
// the previous DefaultLayoutWithVerticalNav (Vuexy) carrier.
|
||||
//
|
||||
// Per RFC AD-3 + R-10: filename preserved, contents rewritten in
|
||||
// isolation. Vuetify-based topbar features (search, theme switcher,
|
||||
// notifications, org switcher, context switcher, shortcuts,
|
||||
// impersonation banner) are absent during F3 and return in F4 when
|
||||
// each is rewritten to PrimeVue. See the B7 commit body for the
|
||||
// regression list.
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppShell from '@/layouts/components/AppShell.vue'
|
||||
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
const hasOrganisation = computed(() => !!authStore.organisations.length)
|
||||
|
||||
const navItems = computed(() => {
|
||||
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
||||
|
||||
let orgItems = orgNavItems.map(item => {
|
||||
if ('heading' in item && item.heading === 'Beheer')
|
||||
return { ...item, heading: orgName }
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
|
||||
orgItems = orgItems.filter(item => {
|
||||
if ('heading' in item)
|
||||
return false
|
||||
|
||||
return 'to' in item && item.to?.name === 'dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
|
||||
return [...orgItems, ...platformNavItems]
|
||||
|
||||
return orgItems
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayoutWithVerticalNav>
|
||||
<AppShell :nav-items="navItems">
|
||||
<RouterView />
|
||||
</DefaultLayoutWithVerticalNav>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -1,234 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
// 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.
|
||||
// PortalLayout — F3 rewrite. Delegates to AppShell with portal nav
|
||||
// items, replacing the previous Vuexy-based 234-line layout.
|
||||
//
|
||||
// Per RFC AD-3 + R-10, filename preserved, contents rewritten. The
|
||||
// previous PortalLayout's event-mode/platform-mode topbar variant
|
||||
// (driven by route.meta.navMode), the embedded UserAvatarMenu, and
|
||||
// the ContextSwitcher widget are absent during F3 — they re-enter in
|
||||
// F4 when each is rewritten to PrimeVue. See the B7 commit body for
|
||||
// the regression list.
|
||||
|
||||
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'
|
||||
import AppShell from '@/layouts/components/AppShell.vue'
|
||||
|
||||
const { injectSkinClasses } = useSkins()
|
||||
|
||||
injectSkinClasses()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
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')
|
||||
}
|
||||
const portalNavItems = [
|
||||
{
|
||||
title: 'Mijn evenementen',
|
||||
to: { name: 'portal-evenementen' },
|
||||
icon: { icon: 'tabler-calendar-event' },
|
||||
},
|
||||
{
|
||||
title: 'Mijn Profiel',
|
||||
to: { name: 'portal-profiel' },
|
||||
icon: { icon: 'tabler-user' },
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp>
|
||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
||||
|
||||
<!-- Navbar: only shown when authenticated -->
|
||||
<VAppBar
|
||||
v-if="authStore.isAuthenticated"
|
||||
flat
|
||||
color="surface"
|
||||
border="b"
|
||||
height="64"
|
||||
<AppShell
|
||||
:nav-items="portalNavItems"
|
||||
title="Crewli Portal"
|
||||
>
|
||||
<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: context switcher + avatar menu (desktop) -->
|
||||
<div class="d-none d-md-flex align-center">
|
||||
<ContextSwitcher class="me-2" />
|
||||
<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>
|
||||
<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
|
||||
color="transparent"
|
||||
class="justify-center text-caption text-medium-emphasis py-3"
|
||||
>
|
||||
Powered by Crewli
|
||||
</VFooter>
|
||||
</VApp>
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// Public layout skeleton — WS-3 session 1a.
|
||||
//
|
||||
// Used for unauthenticated routes: login, password-reset, public
|
||||
// form viewer. Intentionally minimal — no nav, no branding chrome
|
||||
// in this skeleton. Branding (logo + responsive shell) lands in a
|
||||
// later session.
|
||||
// PublicLayout.vue — F3 rewrite. Used by unauthenticated public-facing
|
||||
// pages (form-fill flows, public registration). Minimal Tailwind
|
||||
// container, no chrome. Per RFC AD-3 the previous VApp + VMain wrapper
|
||||
// is replaced; no PrimeVue chrome is needed because the public form
|
||||
// renderer brings its own visual frame.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp>
|
||||
<VMain>
|
||||
<main class="crewli-public-layout min-h-screen bg-surface-50">
|
||||
<RouterView />
|
||||
</VMain>
|
||||
</VApp>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
// Mock the import path so OrganizerLayout's `import DefaultLayoutWithVerticalNav`
|
||||
// statement doesn't pull in the real component (which transitively imports
|
||||
// Vuetify .css files that the trimmed-down vitest config can't transform).
|
||||
vi.mock('@/layouts/components/DefaultLayoutWithVerticalNav.vue', () => ({
|
||||
default: { template: '<div><slot /></div>' },
|
||||
// Mock AppShell so the OrganizerLayout test isolates the layout
|
||||
// wrapper's job (compute nav items, pass them to AppShell, expose
|
||||
// RouterView via default slot). AppShell's own behavior is exercised
|
||||
// by its own tests / Playwright CT in F4.
|
||||
vi.mock('@/layouts/components/AppShell.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-test="app-shell"><slot /></div>',
|
||||
props: ['navItems', 'title'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/useAuthStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
organisations: [],
|
||||
currentOrganisation: null,
|
||||
isSuperAdmin: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/useImpersonationStore', () => ({
|
||||
useImpersonationStore: () => ({
|
||||
isImpersonating: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const OrganizerLayout = (await import('../OrganizerLayout.vue')).default
|
||||
|
||||
describe('OrganizerLayout', () => {
|
||||
@@ -19,6 +40,7 @@ describe('OrganizerLayout', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a RouterView in its slot', () => {
|
||||
|
||||
@@ -2,97 +2,54 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
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/useAuthStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
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" />' },
|
||||
}))
|
||||
// PortalLayout was rewritten in F3 (B7) to delegate its chrome to
|
||||
// AppShell. Mock AppShell so this test isolates the wrapper's job
|
||||
// (define portal nav items, pass them to AppShell, expose RouterView
|
||||
// via default slot). AppShell's own behavior — sidebar rendering,
|
||||
// drawer toggle, user menu — is covered by AppShell's own tests
|
||||
// (F4 will add them via Playwright CT against the mounted shell).
|
||||
|
||||
// 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. 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() {} })
|
||||
vi.mock('@/layouts/components/AppShell.vue', () => ({
|
||||
default: {
|
||||
name: 'AppShell',
|
||||
template: '<div data-test="app-shell"><slot /></div>',
|
||||
props: ['navItems', 'title'],
|
||||
},
|
||||
},
|
||||
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())
|
||||
|
||||
const PortalLayout = (await import('../PortalLayout.vue')).default
|
||||
|
||||
describe('PortalLayout', () => {
|
||||
it('mounts without throwing', () => {
|
||||
const wrapper = mount(PortalLayout, {
|
||||
global: { stubs: { ...vuetifyStubs, RouterView: true } },
|
||||
global: { stubs: { RouterView: true } },
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="app-shell"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a main region and footer (header is auth-conditional)', () => {
|
||||
const wrapper = mount(PortalLayout, {
|
||||
global: { stubs: { ...vuetifyStubs, RouterView: 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', () => {
|
||||
it('renders a RouterView inside the AppShell slot', () => {
|
||||
const wrapper = mount(PortalLayout, {
|
||||
global: {
|
||||
stubs: {
|
||||
...vuetifyStubs,
|
||||
RouterView: { template: '<div data-test="router-view" />' },
|
||||
},
|
||||
stubs: { RouterView: { template: '<div data-test="router-view" />' } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('main [data-test="router-view"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="app-shell"] [data-test="router-view"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards portal-specific nav items and title to AppShell', () => {
|
||||
const wrapper = mount(PortalLayout, {
|
||||
global: { stubs: { RouterView: true } },
|
||||
})
|
||||
|
||||
const shell = wrapper.findComponent({ name: 'AppShell' })
|
||||
const navItems = shell.props('navItems') as Array<{ title: string }>
|
||||
|
||||
expect(shell.props('title')).toBe('Crewli Portal')
|
||||
expect(navItems.map(i => i.title)).toEqual(['Mijn evenementen', 'Mijn Profiel'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
||||
|
||||
const { injectSkinClasses } = useSkins()
|
||||
|
||||
// ℹ️ This will inject classes in body tag for accurate styling
|
||||
injectSkinClasses()
|
||||
|
||||
// SECTION: Loading Indicator
|
||||
const isFallbackStateActive = ref(false)
|
||||
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
|
||||
|
||||
// watching if the fallback state is active and the refLoadingIndicator component is available
|
||||
watch([isFallbackStateActive, refLoadingIndicator], () => {
|
||||
if (isFallbackStateActive.value && refLoadingIndicator.value)
|
||||
refLoadingIndicator.value.fallbackHandle()
|
||||
|
||||
if (!isFallbackStateActive.value && refLoadingIndicator.value)
|
||||
refLoadingIndicator.value.resolveHandle()
|
||||
}, { immediate: true })
|
||||
// !SECTION
|
||||
<script setup lang="ts">
|
||||
// blank.vue — F3 rewrite. Minimal chrome-less layout used by routes
|
||||
// with meta.layout = 'blank' (login, password-reset, etc.). No
|
||||
// AppShell, no sidebar, no topbar — just a centered content area.
|
||||
//
|
||||
// Per RFC AD-3, filename preserved. The previous Vuexy Suspense
|
||||
// fallback + AppLoadingIndicator stack is dropped; pages handle their
|
||||
// own loading state via PrimeVue ProgressSpinner where needed.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
||||
|
||||
<div
|
||||
class="layout-wrapper layout-blank"
|
||||
data-allow-mismatch
|
||||
>
|
||||
<RouterView #="{Component}">
|
||||
<Suspense
|
||||
:timeout="0"
|
||||
@fallback="isFallbackStateActive = true"
|
||||
@resolve="isFallbackStateActive = false"
|
||||
>
|
||||
<Component :is="Component" />
|
||||
</Suspense>
|
||||
</RouterView>
|
||||
<div class="crewli-blank-layout flex min-h-screen flex-col bg-surface-50">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.layout-wrapper.layout-blank {
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
214
apps/app/src/layouts/components/AppShell.vue
Normal file
214
apps/app/src/layouts/components/AppShell.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
// AppShell — PrimeVue-only application chrome introduced in F3 per the
|
||||
// RFC-WS-FRONTEND-PRIMEVUE AD-3 layout rewrite, B7-option-B (alongside
|
||||
// the Vuexy carrier DefaultLayoutWithVerticalNav.vue).
|
||||
//
|
||||
// Hard constraint per F3 sprint plan: no Vuetify or @core/@layouts
|
||||
// imports inside this component. Vuetify-based features that previously
|
||||
// lived in the topbar (search, theme switcher, notifications,
|
||||
// org switcher, context switcher, shortcuts, impersonation banner,
|
||||
// rich user profile menu) are absent from AppShell — they migrate in
|
||||
// F4 sub-packages and re-enter through this component then. See the
|
||||
// B7 commit body for the explicit regression list.
|
||||
//
|
||||
// Layout: Tailwind CSS grid. Desktop (lg+) shows a permanent sidebar;
|
||||
// mobile (<lg) hides it and shows a hamburger toggle that opens a
|
||||
// PrimeVue Drawer overlay. Content area renders the default slot
|
||||
// (a RouterView from the wrapping layout file).
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import Button from 'primevue/button'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Menu from 'primevue/menu'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
interface NavHeading {
|
||||
heading: string
|
||||
}
|
||||
interface NavLink {
|
||||
title: string
|
||||
to: { name: string }
|
||||
icon: { icon: string }
|
||||
}
|
||||
type NavItem = NavHeading | NavLink
|
||||
|
||||
interface Props {
|
||||
navItems: NavItem[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: 'Crewli',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const userInitial = computed(() => {
|
||||
const name = authStore.user?.full_name ?? ''
|
||||
|
||||
return name.charAt(0).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
const userMenuItems = computed(() => [
|
||||
{
|
||||
label: authStore.user?.full_name ?? 'Gebruiker',
|
||||
items: [
|
||||
{
|
||||
label: 'Mijn Profiel',
|
||||
icon: 'tabler-user',
|
||||
command: () => router.push({ name: 'account-settings' }),
|
||||
},
|
||||
{
|
||||
label: 'Uitloggen',
|
||||
icon: 'tabler-logout',
|
||||
command: async () => {
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function isHeading(item: NavItem): item is NavHeading {
|
||||
return 'heading' in item
|
||||
}
|
||||
|
||||
function navigate(item: NavLink) {
|
||||
mobileNavOpen.value = false
|
||||
router.push(item.to)
|
||||
}
|
||||
|
||||
function toggleUserMenu(event: Event) {
|
||||
userMenuRef.value?.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crewli-app-shell flex min-h-screen">
|
||||
<!-- Desktop sidebar (lg+) -->
|
||||
<aside class="hidden lg:flex w-64 flex-col border-r border-surface-200 bg-surface-0">
|
||||
<div class="flex h-16 items-center justify-center border-b border-surface-200">
|
||||
<span class="text-xl font-semibold text-primary-500">{{ title }}</span>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto p-3">
|
||||
<template
|
||||
v-for="(item, idx) in navItems"
|
||||
:key="idx"
|
||||
>
|
||||
<div
|
||||
v-if="isHeading(item)"
|
||||
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
||||
>
|
||||
{{ item.heading }}
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<Icon
|
||||
:name="item.icon.icon"
|
||||
size="20"
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile drawer (overlay, <lg) -->
|
||||
<Drawer
|
||||
v-model:visible="mobileNavOpen"
|
||||
position="left"
|
||||
class="lg:hidden"
|
||||
:pt="{ root: { style: { width: '16rem' } } }"
|
||||
>
|
||||
<template #header>
|
||||
<span class="text-lg font-semibold text-primary-500">{{ title }}</span>
|
||||
</template>
|
||||
<nav class="flex flex-col">
|
||||
<template
|
||||
v-for="(item, idx) in navItems"
|
||||
:key="idx"
|
||||
>
|
||||
<div
|
||||
v-if="isHeading(item)"
|
||||
class="mt-4 mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-surface-500"
|
||||
>
|
||||
{{ item.heading }}
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
||||
@click="navigate(item)"
|
||||
>
|
||||
<Icon
|
||||
:name="item.icon.icon"
|
||||
size="20"
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</Drawer>
|
||||
|
||||
<!-- Main column -->
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
<!-- Top bar -->
|
||||
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
class="lg:hidden"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
aria-label="Menu openen"
|
||||
@click="mobileNavOpen = true"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-menu-2"
|
||||
size="24"
|
||||
/>
|
||||
</Button>
|
||||
<span class="text-base font-medium text-surface-700 lg:hidden">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
aria-label="Gebruikersmenu openen"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<Avatar
|
||||
:label="userInitial"
|
||||
shape="circle"
|
||||
class="bg-primary-500 text-white"
|
||||
/>
|
||||
</Button>
|
||||
<Menu
|
||||
ref="userMenuRef"
|
||||
:model="userMenuItems"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 overflow-x-hidden p-4 lg:p-6">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,57 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
import type AppLoadingIndicator from '@/components/AppLoadingIndicator.vue'
|
||||
import { useConfigStore } from '@core/stores/config'
|
||||
import { AppContentLayoutNav } from '@layouts/enums'
|
||||
import { switchToVerticalNavOnLtOverlayNavBreakpoint } from '@layouts/utils'
|
||||
<script setup lang="ts">
|
||||
// default.vue — F3 rewrite. The vite-plugin-vue-meta-layouts plugin
|
||||
// resolves routes without an explicit meta.layout to this file.
|
||||
// Delegates to AppShell with org + platform nav, mirroring
|
||||
// OrganizerLayout (the implicit "logged-in user" layout).
|
||||
//
|
||||
// Per RFC AD-3, the filename and routing behaviour are preserved;
|
||||
// contents rewritten to PrimeVue. The Vuexy horizontal/vertical
|
||||
// switch (driven by useConfigStore.appContentLayoutNav) and the
|
||||
// Suspense+fallback loading indicator are removed — F4 will
|
||||
// reintroduce loading state via PrimeVue ProgressBar/Spinner if/where
|
||||
// needed.
|
||||
|
||||
const DefaultLayoutWithHorizontalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithHorizontalNav.vue'))
|
||||
const DefaultLayoutWithVerticalNav = defineAsyncComponent(() => import('./components/DefaultLayoutWithVerticalNav.vue'))
|
||||
import { computed } from 'vue'
|
||||
import AppShell from '@/layouts/components/AppShell.vue'
|
||||
import { orgNavItems, platformNavItems } from '@/navigation/vertical'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
// ℹ️ This will switch to vertical nav when define breakpoint is reached when in horizontal nav layout
|
||||
// Remove below composable usage if you are not using horizontal nav layout in your app
|
||||
switchToVerticalNavOnLtOverlayNavBreakpoint()
|
||||
const hasOrganisation = computed(() => !!authStore.organisations.length)
|
||||
|
||||
const { layoutAttrs, injectSkinClasses } = useSkins()
|
||||
const navItems = computed(() => {
|
||||
const orgName = authStore.currentOrganisation?.name ?? 'Beheer'
|
||||
|
||||
injectSkinClasses()
|
||||
let orgItems = orgNavItems.map(item => {
|
||||
if ('heading' in item && item.heading === 'Beheer')
|
||||
return { ...item, heading: orgName }
|
||||
|
||||
// SECTION: Loading Indicator
|
||||
const isFallbackStateActive = ref(false)
|
||||
const refLoadingIndicator = ref<InstanceType<typeof AppLoadingIndicator> | null>(null)
|
||||
return item
|
||||
})
|
||||
|
||||
// watching if the fallback state is active and the refLoadingIndicator component is available
|
||||
watch([isFallbackStateActive, refLoadingIndicator], () => {
|
||||
if (isFallbackStateActive.value && refLoadingIndicator.value)
|
||||
refLoadingIndicator.value.fallbackHandle()
|
||||
if (impersonationStore.isImpersonating && !hasOrganisation.value) {
|
||||
orgItems = orgItems.filter(item => {
|
||||
if ('heading' in item)
|
||||
return false
|
||||
|
||||
if (!isFallbackStateActive.value && refLoadingIndicator.value)
|
||||
refLoadingIndicator.value.resolveHandle()
|
||||
}, { immediate: true })
|
||||
// !SECTION
|
||||
return 'to' in item && item.to?.name === 'dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
if (authStore.isSuperAdmin && !impersonationStore.isImpersonating)
|
||||
return [...orgItems, ...platformNavItems]
|
||||
|
||||
return orgItems
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
v-bind="layoutAttrs"
|
||||
:is="configStore.appContentLayoutNav === AppContentLayoutNav.Vertical ? DefaultLayoutWithVerticalNav : DefaultLayoutWithHorizontalNav"
|
||||
>
|
||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Suspense
|
||||
:timeout="0"
|
||||
@fallback="isFallbackStateActive = true"
|
||||
@resolve="isFallbackStateActive = false"
|
||||
>
|
||||
<Component :is="Component" />
|
||||
</Suspense>
|
||||
</RouterView>
|
||||
</Component>
|
||||
<AppShell :nav-items="navItems">
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
// As we are using `layouts` plugin we need its styles to be imported
|
||||
@use "@layouts/styles/default-layout";
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user