feat(layout): Plan 2.5 P4 — WorkspaceSwitcher no-sub + SidebarNav APP_NAVIGATION

Per RFC-WS-PRIMEVUE-PLAN-2-5 §4 AD-2.5-W1 and AD-2.5-B1, §5.4 Fix 4.

Changes:
- WorkspaceSwitcher: sub field removed from template, WorkspaceDisplay
  type, and buildDisplay derivation. Stories did not carry sub args
  (auto-derived from seeded org.role); no WithSub story existed. New
  regression spec (WorkspaceSwitcher.spec.ts) locks the no-sub render.
- SidebarNav: now consumes APP_NAVIGATION from src/config/navigation.ts
  as the single source of truth (shared with breadcrumb derivation in
  useNavBreadcrumb). The groups: V2NavGroup[] prop is removed; render
  walks top-level NavItems (branch nodes render label-heading + children;
  leaf nodes render as rows; items without routeName render as
  non-clickable dormant placeholders). Previous nav data source:
  groups prop fed by useV2Nav(orgNavItems) in OrganizerLayoutV2.
- APP_NAVIGATION expanded with 7 entries to preserve visual sidebar
  continuity (Evenementen at top-level + Beheer branch with 5 children).
  All new entries use routeName: undefined until the corresponding v2
  page lands (TODOs noted per entry); only Dashboard maps to v2-dashboard.
- AppSidebar: groups prop removed; passes only :collapsed to SidebarNav.
- OrganizerLayoutV2: useV2Nav(orgNavItems) plumbing retired; the layout
  now renders <AppSidebar /> with no nav-data wiring.
- Tests: AppSidebar.spec drops the "passes groups prop to SidebarNav"
  assertion; OrganizerLayoutV2.spec drops the "forwards orgNavItems"
  assertion. New WorkspaceSwitcher no-sub regression spec (+2 tests).
- Storybook: SidebarNav.stories and AppSidebar.stories updated to no
  longer thread navFixture/groups; WithActiveItem pushes v2-dashboard.

Position of WorkspaceSwitcher (Fix 3), workspace dropdown panel (Fix 5),
and AppBreadcrumb wiring (Fix 2) remain unchanged in P4 — both lands in
P5. The legacy useBreadcrumb composable also remains untouched until P5
(atomic with AppTopbar refactor).

Orphans flagged for follow-up cleanup (intentionally not deleted in P4):
useV2Nav composable + spec, V2NavGroup/V2NavItem types, sidebarNavActive
helper + spec, navFixture in stories/v2/_helpers.ts.

Suite delta: 575 → 575 (+2 WorkspaceSwitcher no-sub spec, -1 AppSidebar
groups-prop assertion, -1 OrganizerLayoutV2 groups-forward assertion).
vue-tsc clean. Scoped ESLint clean (0 errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 18:14:31 +02:00
parent d0dd45c03a
commit 864cc558e2
10 changed files with 339 additions and 178 deletions

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { navFixture, orgA, userFixture, withPinia } from '@/stories/v2/_helpers'
import { orgA, userFixture, withPinia } from '@/stories/v2/_helpers'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useShellUiStore } from '@/stores/useShellUiStore'
@@ -10,6 +10,9 @@ import { useShellUiStore } from '@/stores/useShellUiStore'
* via WorkspaceSwitcher, useAuthStore. Each story seeds both stores on a
* fresh Pinia. The desktop <aside> is `hidden lg:flex`, so view at a
* viewport ≥ 1024px to see the permanent column.
*
* Plan 2.5 P4 (AD-2.5-B1): SidebarNav reads APP_NAVIGATION directly; no
* `groups` prop is threaded through AppSidebar.
*/
const meta: Meta<typeof AppSidebar> = {
title: 'v2 Shell/AppSidebar',
@@ -21,7 +24,6 @@ export default meta
type Story = StoryObj<typeof AppSidebar>
export const Expanded: Story = {
args: { groups: navFixture },
decorators: [
withPinia(() => {
const auth = useAuthStore()
@@ -34,21 +36,17 @@ export const Expanded: Story = {
shellUi.sidebarCollapsed = false
}),
],
render: args => ({
render: () => ({
components: { AppSidebar },
setup() {
return { args }
},
template: `
<div class="flex h-[600px]">
<AppSidebar :groups="args.groups" />
<AppSidebar />
</div>
`,
}),
}
export const Collapsed: Story = {
args: { groups: navFixture },
decorators: [
withPinia(() => {
const auth = useAuthStore()
@@ -61,14 +59,11 @@ export const Collapsed: Story = {
shellUi.sidebarCollapsed = true
}),
],
render: args => ({
render: () => ({
components: { AppSidebar },
setup() {
return { args }
},
template: `
<div class="flex h-[600px]">
<AppSidebar :groups="args.groups" />
<AppSidebar />
</div>
`,
}),

View File

@@ -19,8 +19,11 @@
* teleported overlay. By unmounting on desktop we guarantee no modal backdrop /
* focus-trapped dialog can appear on wide viewports regardless of mobileOpen state.
*
* nav groups arrive as a prop — this file must NOT import @/navigation directly
* (components-v2 import boundary; the layout page supplies nav data).
* Nav data: as of Plan 2.5 P4, SidebarNav reads APP_NAVIGATION from
* @/config/navigation directly — no prop is threaded through AppSidebar.
* The components-v2 import boundary still applies to @/navigation (the v1
* source), but @/config/navigation is the unclassified central registry
* and is consumed in-place by SidebarNav.
*
* Deliberate simplification: crewli-starter's bespoke Teleport tooltip (shown in
* collapsed mode for nav items) is NOT ported here. SidebarNav already provides
@@ -41,19 +44,10 @@ import { computed } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Drawer from 'primevue/drawer'
import { useShellUiStore } from '@/stores/useShellUiStore'
import type { V2NavGroup } from '@/types/v2/nav'
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
import SidebarNav from '@/components-v2/layout/SidebarNav.vue'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
defineProps<{
/**
* Nav groups passed in from the layout — components-v2 files may NOT
* import from @/navigation; the parent layout supplies this.
*/
groups: V2NavGroup[]
}>()
const shell = useShellUiStore()
// Shared breakpoint signal — must match SidebarHeader and the CSS `lg:` boundary (1024px).
@@ -84,10 +78,7 @@ const mobileVisible = computed<boolean>({
:class="shell.sidebarCollapsed ? 'w-16' : 'w-64'"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="shell.sidebarCollapsed"
/>
<SidebarNav :collapsed="shell.sidebarCollapsed" />
<WorkspaceSwitcher :collapsed="shell.sidebarCollapsed" />
</aside>
@@ -119,10 +110,7 @@ const mobileVisible = computed<boolean>({
}"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="false"
/>
<SidebarNav :collapsed="false" />
<WorkspaceSwitcher :collapsed="false" />
</Drawer>
</template>

View File

@@ -1,13 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { useRouter } from 'vue-router'
import { navFixture } from '@/stories/v2/_helpers'
import SidebarNav from '@/components-v2/layout/SidebarNav.vue'
/**
* SidebarNav needs no Pinia, but vue-router is installed app-wide in
* preview.ts (the component uses useRoute + RouterLink). The
* WithActiveItem story pushes the `events` route in setup so the
* Evenementen item renders in its active state.
* SidebarNav now reads APP_NAVIGATION from `@/config/navigation` directly
* (AD-2.5-B1 / Plan 2.5 P4). The previous `groups` prop is gone — stories
* only vary the `collapsed` axis and the active route. vue-router is
* installed app-wide in preview.ts; the WithActiveItem story pushes the
* `v2-dashboard` route so the Dashboard item renders in its active state.
*/
const meta: Meta<typeof SidebarNav> = {
title: 'v2 Shell/SidebarNav',
@@ -22,7 +22,7 @@ export default meta
type Story = StoryObj<typeof SidebarNav>
export const Expanded: Story = {
args: { groups: navFixture, collapsed: false },
args: { collapsed: false },
render: args => ({
components: { SidebarNav },
setup() {
@@ -30,14 +30,14 @@ export const Expanded: Story = {
},
template: `
<div class="w-64 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
<SidebarNav :collapsed="args.collapsed" />
</div>
`,
}),
}
export const Collapsed: Story = {
args: { groups: navFixture, collapsed: true },
args: { collapsed: true },
render: args => ({
components: { SidebarNav },
setup() {
@@ -45,26 +45,26 @@ export const Collapsed: Story = {
},
template: `
<div class="w-16 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
<SidebarNav :collapsed="args.collapsed" />
</div>
`,
}),
}
export const WithActiveItem: Story = {
args: { groups: navFixture, collapsed: false },
args: { collapsed: false },
render: args => ({
components: { SidebarNav },
setup() {
const router = useRouter()
router.push({ name: 'events' })
router.push({ name: 'v2-dashboard' })
return { args }
},
template: `
<div class="w-64 bg-[var(--p-content-background)]">
<SidebarNav :groups="args.groups" :collapsed="args.collapsed" />
<SidebarNav :collapsed="args.collapsed" />
</div>
`,
}),

View File

@@ -1,71 +1,145 @@
<script setup lang="ts">
/**
* SidebarNav — registry-driven sidebar (AD-2.5-B1 / Plan 2.5 P4).
*
* Reads `APP_NAVIGATION` directly from `@/config/navigation`. This is the
* single source of truth for sidebar items and breadcrumb derivation
* (the breadcrumb side is served by `useNavBreadcrumb` over the same
* registry). Prior to P4, this component received a `groups` prop the
* parent layout built from `@/navigation/vertical`; the prop chain
* (OrganizerLayoutV2 → AppSidebar → SidebarNav) is removed.
*
* Render contract:
* - Top-level leaf nodes (routeName set, no children) render as a row.
* - Top-level branch nodes (children set) render their label as a
* section heading followed by their children as rows.
* - Nodes without a `routeName` render as a non-clickable row
* (label-only) — dormant placeholders until their v2 page lands.
*/
import { useRoute } from 'vue-router'
import type { RouteLocationRaw } from 'unplugin-vue-router'
import Icon from '@/components/Icon.vue'
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
import { APP_NAVIGATION, type NavItem } from '@/config/navigation'
defineProps<{
groups: V2NavGroup[]
collapsed: boolean
}>()
const route = useRoute()
function checkActive(item: V2NavItem): boolean {
return isNavItemActive(item, route.name)
function isActive(item: NavItem): boolean {
if (!item.routeName)
return false
const current = route.name
if (typeof current !== 'string')
return false
return current === item.routeName
|| current.startsWith(`${item.routeName}-`)
}
// Cast V2NavItem.to (vue-router generic RouteLocationRaw) to the typed version
// expected by RouterLinkTyped. V2NavItem.to values are always named-route objects
// from the RouteNamedMap so the cast is sound.
function itemTo(item: V2NavItem): RouteLocationRaw {
return item.to as RouteLocationRaw
// NavItem.routeName values come from APP_NAVIGATION and are always valid
// named routes from the RouteNamedMap when present — the cast to the typed
// RouterLink RouteLocationRaw is sound. Items without routeName render via
// the non-clickable branch (see template) so this function is never called
// for them.
function itemTo(item: NavItem): RouteLocationRaw {
return { name: item.routeName } as RouteLocationRaw
}
</script>
<template>
<!--
.nav flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]
.nav-group mt-[18px] (only from the second group onward, via CSS sibling selector
Tailwind cannot express `+ .nav-group { margin-top }` on a class without
a group-based trick; using first:mt-0 / not-first:mt-[18px] via the
:not(:first-child) pseudo equivalent `[&:not(:first-child)]:mt-[18px]`)
.nav-label text-[11px] font-semibold text-surface-500 dark:text-surface-400
uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden
.nav-item flex items-center gap-3 py-[9px] px-2.5 rounded-md
text-surface-600 dark:text-surface-300 text-[13.5px] font-medium
transition-[background,color] duration-150 whitespace-nowrap relative
cursor-pointer w-full text-start min-w-0
.nav-item:hover hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0
.nav-item.active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold
.nav-item.active::before the left accent bar inexpressible in Tailwind without a plugin
(position:absolute left:-10px, custom width:3px, height:18px,
background:primary, transform:translateY(-50%)). Using <style scoped>.
.nav-item .iconify text-[18px] flex-shrink-0 (handled by Icon's own sizing via :size)
.count → ms-auto text-[11px] font-semibold bg-surface-100 dark:bg-surface-800
text-surface-500 dark:text-surface-400 px-1.5 py-px rounded-full
.nav-item.active .count → bg-primary-600 dark:bg-primary-500 text-white
-->
<nav class="flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]">
<div
v-for="(group, gi) in groups"
:key="gi"
class="[&:not(:first-child)]:mt-[18px]"
<template
v-for="topItem in APP_NAVIGATION"
:key="topItem.key"
>
<!-- Group label: hidden in collapsed mode -->
<!-- Branch node: section label + children -->
<div
v-if="group.label && !collapsed"
class="text-[11px] font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden"
v-if="topItem.children"
class="[&:not(:first-child)]:mt-[18px]"
>
{{ group.label }}
<div
v-if="!collapsed"
class="text-[11px] font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden"
>
{{ topItem.label }}
</div>
<template
v-for="child in topItem.children"
:key="child.key"
>
<!-- Leaf with route -->
<RouterLink
v-if="child.routeName"
v-slot="{ href, navigate }"
:to="itemTo(child)"
custom
>
<a
:href="href"
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium transition-[background,color] duration-150 whitespace-nowrap relative cursor-pointer w-full text-start min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
isActive(child)
? 'nav-item-active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0',
]"
:aria-current="isActive(child) ? 'page' : undefined"
:aria-label="child.label"
:title="collapsed ? child.label : undefined"
@click="navigate"
>
<Icon
v-if="child.icon"
:name="child.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ child.label }}
</span>
</a>
</RouterLink>
<!-- Leaf without route: dormant placeholder -->
<div
v-else
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium whitespace-nowrap relative w-full text-start min-w-0 text-surface-400 dark:text-surface-500 cursor-not-allowed"
:class="collapsed ? 'justify-center px-0' : 'px-2.5'"
:aria-disabled="true"
:title="collapsed ? child.label : undefined"
>
<Icon
v-if="child.icon"
:name="child.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ child.label }}
</span>
</div>
</template>
</div>
<!-- Top-level leaf with route -->
<RouterLink
v-for="item in group.items"
:key="item.id"
v-else-if="topItem.routeName"
v-slot="{ href, navigate }"
:to="itemTo(item)"
:to="itemTo(topItem)"
custom
>
<a
@@ -73,44 +147,54 @@ function itemTo(item: V2NavItem): RouteLocationRaw {
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium transition-[background,color] duration-150 whitespace-nowrap relative cursor-pointer w-full text-start min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
checkActive(item)
isActive(topItem)
? 'nav-item-active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0',
]"
:aria-current="checkActive(item) ? 'page' : undefined"
:aria-label="item.label"
:title="collapsed ? item.label : undefined"
:aria-current="isActive(topItem) ? 'page' : undefined"
:aria-label="topItem.label"
:title="collapsed ? topItem.label : undefined"
@click="navigate"
>
<Icon
:name="item.icon"
v-if="topItem.icon"
:name="topItem.icon"
:size="18"
class="flex-shrink-0"
/>
<!-- Text label: hidden in collapsed mode -->
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ item.label }}
</span>
<!-- Count badge: hidden in collapsed mode -->
<span
v-if="item.count != null && !collapsed"
class="ms-auto text-[11px] font-semibold px-1.5 py-px rounded-full"
:class="[
checkActive(item)
? 'bg-primary-600 dark:bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400',
]"
>
{{ item.count }}
{{ topItem.label }}
</span>
</a>
</RouterLink>
</div>
<!-- Top-level leaf without route: dormant placeholder -->
<div
v-else
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium whitespace-nowrap relative w-full text-start min-w-0 text-surface-400 dark:text-surface-500 cursor-not-allowed"
:class="collapsed ? 'justify-center px-0' : 'px-2.5'"
:aria-disabled="true"
:title="collapsed ? topItem.label : undefined"
>
<Icon
v-if="topItem.icon"
:name="topItem.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ topItem.label }}
</span>
</div>
</template>
</nav>
</template>

View File

@@ -23,7 +23,7 @@ import type { Organisation } from '@/types/auth'
defineProps<{
/**
* When true (collapsed sidebar), hide the name/sub meta text and
* When true (collapsed sidebar), hide the name meta text and
* show only the logo square — mirrors crewli-starter's collapsed prop.
*/
collapsed?: boolean
@@ -43,8 +43,6 @@ interface WorkspaceDisplay {
id: string
initials: string
name: string
/** The role string is the relevant context identifier in Crewli. */
sub: string
gradient: [string, string]
}
@@ -60,7 +58,6 @@ function buildDisplay(org: Organisation): WorkspaceDisplay {
id: org.id,
initials,
name: org.name,
sub: org.role,
gradient: computeOrgGradient(org.id),
}
}
@@ -129,7 +126,7 @@ function selectOrg(ws: WorkspaceDisplay): void {
{{ current.initials }}
</span>
<!-- Meta: name + sub (hidden in collapsed mode) -->
<!-- Meta: name (hidden in collapsed mode) -->
<!-- .ws-switcher .meta: flex-1, min-w-0, flex-col, line-height, text-left -->
<span
v-if="!collapsed && current"
@@ -139,10 +136,6 @@ function selectOrg(ws: WorkspaceDisplay): void {
<span class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
{{ current.name }}
</span>
<!-- .ws-switcher .meta .sub: text-[11.5px], muted, truncate -->
<span class="truncate text-[11.5px] text-[var(--p-text-muted-color)]">
{{ current.sub }}
</span>
</span>
<!-- Chevron (.ws-switcher .chev: color fg-subtle, flex-shrink-0) -->
@@ -185,12 +178,10 @@ function selectOrg(ws: WorkspaceDisplay): void {
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
>{{ ws.initials }}</span>
<!-- Name + sub stack -->
<!-- Name -->
<span>
<!-- .pop-ws .opt .name -->
<div class="text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
<!-- .pop-ws .opt .sub -->
<div class="mt-[2px] text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
</span>
<!-- Check mark for active org (.pop-ws .opt .check-mark) -->

View File

@@ -4,11 +4,14 @@
* Strategy: mount with @vue/test-utils stubs for all heavy children (SidebarHeader,
* SidebarNav, WorkspaceSwitcher, Drawer) so we test only:
* 1. Renders the 3 child components (SidebarHeader, SidebarNav, WorkspaceSwitcher).
* 2. Passes `groups` prop to SidebarNav.
* 3. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
* 4. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
* 5. Drawer is NOT rendered when isMobile=false (desktop); IS rendered when isMobile=true.
* 6. Desktop <aside> applies correct width class based on sidebarCollapsed.
* 2. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
* 3. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
* 4. Drawer is NOT rendered when isMobile=false (desktop); IS rendered when isMobile=true.
* 5. Desktop <aside> applies correct width class based on sidebarCollapsed.
*
* Plan 2.5 P4 (AD-2.5-B1): the `groups` prop was retired from AppSidebar/SidebarNav;
* SidebarNav now reads APP_NAVIGATION from `@/config/navigation` directly. The
* prior "passes groups prop to SidebarNav" assertion is therefore gone.
*
* Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
* whether its `visible` prop is correctly bound to the store.
@@ -22,7 +25,6 @@ import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useShellUiStore } from '@/stores/useShellUiStore'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import type { V2NavGroup } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test.
@@ -42,19 +44,6 @@ vi.mock('@vueuse/core', () => ({
}),
}))
// ---------------------------------------------------------------------------
// A minimal nav group fixture
// ---------------------------------------------------------------------------
const testGroups: V2NavGroup[] = [
{
label: 'Main',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-home', to: { name: 'dashboard' } },
],
},
]
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
@@ -77,8 +66,8 @@ const globalStubs = {
SidebarHeader: { name: 'SidebarHeader', template: '<div class="sidebar-header-stub" />' },
SidebarNav: {
name: 'SidebarNav',
props: ['groups', 'collapsed'],
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" :data-groups-count="groups.length" />',
props: ['collapsed'],
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" />',
},
WorkspaceSwitcher: {
name: 'WorkspaceSwitcher',
@@ -87,9 +76,8 @@ const globalStubs = {
},
}
function mountSidebar(groups: V2NavGroup[] = testGroups) {
function mountSidebar() {
return mount(AppSidebar, {
props: { groups },
global: {
plugins: [createPinia()],
stubs: globalStubs,
@@ -127,14 +115,6 @@ describe('AppSidebar', () => {
expect(wrapper.find('.workspace-switcher-stub').exists()).toBe(true)
})
it('passes groups prop to SidebarNav', () => {
const wrapper = mountSidebar()
const nav = wrapper.find('.sidebar-nav-stub')
// Our stub renders data-groups-count from the groups prop
expect(nav.attributes('data-groups-count')).toBe(String(testGroups.length))
})
// -------------------------------------------------------------------------
// Drawer v-if: mount/unmount based on isMobile
// -------------------------------------------------------------------------

View File

@@ -0,0 +1,86 @@
/**
* WorkspaceSwitcher.spec.ts — regression coverage for AD-2.5-W1.
*
* Scope: assert that the workspace switcher no longer renders a "sub" line
* (previously the role string sourced from org.role). The full popover /
* org-switch behaviour is left to Storybook visual coverage; this file
* exists solely as the AD-2.5-W1 lock against re-introduction of the sub
* meta line in the trigger or in the popover row.
*/
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '@/stores/useAuthStore'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
import type { Organisation, User } from '@/types/auth'
const userFixture: User = {
id: 'usr_01',
first_name: 'Bert',
last_name: 'Hausmans',
full_name: 'Bert Hausmans',
date_of_birth: null,
email: 'bert@hausmans.nl',
phone: null,
timezone: 'Europe/Amsterdam',
locale: 'nl',
avatar: null,
}
const orgFixture: Organisation = {
id: 'org_a',
name: 'Festival Crew NL',
slug: 'festival-crew-nl',
role: 'org_admin',
}
const globalStubs = {
Icon: { template: '<span class="icon-stub" />' },
Popover: {
name: 'Popover',
template: '<div class="popover-stub"><slot /></div>',
methods: {
toggle() {},
hide() {},
},
},
}
function mountSwitcher() {
setActivePinia(createPinia())
const auth = useAuthStore()
auth.user = userFixture
auth.organisations = [orgFixture]
return mount(WorkspaceSwitcher, {
props: { collapsed: false },
global: {
stubs: globalStubs,
},
})
}
describe('WorkspaceSwitcher', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('does not render a sub line (AD-2.5-W1)', () => {
const wrapper = mountSwitcher()
// Role string used to back the sub line — must not leak into rendered output.
expect(wrapper.text()).not.toContain(orgFixture.role)
// No selector / class tied to the removed sub element.
expect(wrapper.html()).not.toMatch(/workspace-sub|ws-sub|meta-sub/)
})
it('renders the workspace name', () => {
const wrapper = mountSwitcher()
expect(wrapper.text()).toContain(orgFixture.name)
})
})

View File

@@ -3,10 +3,11 @@
// Future extensions (role filtering, feature flags, dynamic ordering) are
// explicit follow-up RFCs — DO NOT add those fields here without one.
//
// As of Plan 2.5 P1, only one v2 route exists (/v2/dashboard → v2-dashboard).
// As pages-v2/ grows, add an entry per route here so both the sidebar (via
// SidebarNav, wired in P4) and the breadcrumb (via useNavBreadcrumb) update
// from one place.
// As of Plan 2.5 P4, SidebarNav consumes this directly. Only the Dashboard
// entry currently has a v2 route (v2-dashboard); all other entries are
// dormant placeholders (routeName: undefined) until their respective
// pages-v2/<page>.vue lands. Cross-app linking to v1 routes is NOT allowed
// — dormant items render as label-only non-clickable in the sidebar.
export interface NavItem {
@@ -41,4 +42,51 @@ export const APP_NAVIGATION: NavItem[] = [
routeName: 'v2-dashboard',
icon: 'tabler-home',
},
// TODO: assign route when v2-events page lands
{
key: 'events',
label: 'Evenementen',
icon: 'tabler-calendar-event',
},
{
key: 'beheer',
label: 'Beheer',
children: [
// TODO: assign route when v2-organisation page lands
{
key: 'organisation',
label: 'Mijn Organisatie',
icon: 'tabler-building',
},
// TODO: assign route when v2-members page lands
{
key: 'members',
label: 'Leden',
icon: 'tabler-users',
},
// TODO: assign route when v2-organisation-companies page lands
{
key: 'organisation-companies',
label: 'Bedrijven',
icon: 'tabler-building',
},
// TODO: assign route when v2-organisation-form-failures page lands
{
key: 'organisation-form-failures',
label: 'Form failures',
icon: 'tabler-alert-triangle',
},
// TODO: assign route when v2-organisation-settings page lands
{
key: 'organisation-settings',
label: 'Instellingen',
icon: 'tabler-settings',
},
],
},
]

View File

@@ -8,28 +8,24 @@
* the ported PrimeVue shell pieces (AppSidebar / AppTopbar / RightDrawer)
* and renders routed pages via <RouterView/> in the default slot.
*
* Nav data is sourced HERE: the layouts zone may import @/navigation,
* whereas components-v2 may NOT (import-boundary matrix). orgNavItems is
* folded into V2NavGroup[] by useV2Nav() and passed to AppSidebar :groups.
* Plan 2.5 P4 (AD-2.5-B1): SidebarNav now consumes APP_NAVIGATION from
* `@/config/navigation` directly. This layout no longer derives or
* passes nav data; the `useV2Nav(orgNavItems)` plumbing has been retired.
*
* No provide/inject: each shell piece reads its own state from
* useShellUiStore / useAuthStore (RFC AD-G4). This layout wires
* composition + nav data only; it owns no shell state.
* composition only; it owns no shell state.
*/
import { orgNavItems } from '@/navigation/vertical'
import { useV2Nav } from '@/composables/useV2Nav'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
import RightDrawer from '@/components-v2/layout/RightDrawer.vue'
const { groups } = useV2Nav(orgNavItems)
</script>
<template>
<AppShellV2>
<template #sidebar>
<AppSidebar :groups="groups" />
<AppSidebar />
</template>
<template #topbar>

View File

@@ -7,13 +7,15 @@ import OrganizerLayoutV2 from '@/layouts/OrganizerLayoutV2.vue'
import AppShellV2 from '@/layouts/components/AppShellV2.vue'
// Stub the 3 leaf shell components: this test verifies COMPOSITION
// (right component in right slot + nav data forwarded), not their
// internals (those have their own unit tests). Stubs keep jsdom free
// of PrimeVue teleport/overlay/breakpoint machinery.
// (right component in right slot), not their internals (those have
// their own unit tests). Stubs keep jsdom free of PrimeVue teleport/
// overlay/breakpoint machinery.
//
// Plan 2.5 P4 (AD-2.5-B1): AppSidebar no longer takes a `groups` prop
// — SidebarNav reads APP_NAVIGATION from `@/config/navigation` directly.
const AppSidebarStub = defineComponent({
name: 'AppSidebarStub',
props: { groups: { type: Array, default: () => [] } },
template: '<aside data-testid="sidebar-stub" :data-groups-count="groups.length" />',
template: '<aside data-testid="sidebar-stub" />',
})
const AppTopbarStub = defineComponent({
@@ -59,15 +61,6 @@ describe('OrganizerLayoutV2 (wired shell)', () => {
expect(wrapper.find('[data-testid="page"]').exists()).toBe(true)
})
it('forwards orgNavItems folded into V2NavGroup[] to AppSidebar :groups', async () => {
const wrapper = await mountLayout()
const sidebar = wrapper.find('[data-testid="sidebar-stub"]')
// orgNavItems has link entries + a {heading:'Beheer'} → useV2Nav folds
// into ≥1 group. A non-zero count proves the nav chain is wired.
expect(Number(sidebar.attributes('data-groups-count'))).toBeGreaterThan(0)
})
it('no longer renders the Plan-1 skeleton placeholders', async () => {
const wrapper = await mountLayout()