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:
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user