feat(layout): Plan 2.5 P1 foundation — APP_NAVIGATION + walkNavTree + AppBreadcrumb
Per RFC-WS-PRIMEVUE-PLAN-2-5 §8 step 1. Foundation scaffolding only — no shell fixes, no Public Sans removal, no useShellUiStore changes (P2–P6 scope). Implements: - theme darkModeSelector verified at '.dark' (already correct in plugins/primevue/index.ts — config site is here, not theme.ts). - src/config/navigation.ts: APP_NAVIGATION registry per AD-2.5-B1 (Dashboard entry only — v2-dashboard is the only v2 route today). - src/composables/useBreadcrumb.ts: walkNavTree pure helper + useNavBreadcrumb composable per AD-2.5-B1. The legacy meta-based useBreadcrumb is preserved (consumed by AppTopbar, P1 may not touch AppTopbar); P4 retires it and renames useNavBreadcrumb. - src/components-v2/layout/AppBreadcrumb.vue: layout primitive wrapping PrimeVue Breadcrumb, consuming useNavBreadcrumb. - Tests: walkNavTree (4 specs, co-located), AppBreadcrumb mount (2 specs, tests/component/layouts/). Suite 564 → 570 (+6, all new specs green). vue-tsc clean. Scoped ESLint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
4
apps/app/auto-imports.d.ts
vendored
4
apps/app/auto-imports.d.ts
vendored
@@ -284,6 +284,7 @@ declare global {
|
|||||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||||
|
const useNavBreadcrumb: typeof import('./src/composables/useBreadcrumb')['useNavBreadcrumb']
|
||||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||||
const useNow: typeof import('@vueuse/core')['useNow']
|
const useNow: typeof import('@vueuse/core')['useNow']
|
||||||
@@ -372,6 +373,7 @@ declare global {
|
|||||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||||
const usecreateUrl: typeof import('./src/@core/composable/usecreateUrl')['usecreateUrl']
|
const usecreateUrl: typeof import('./src/@core/composable/usecreateUrl')['usecreateUrl']
|
||||||
|
const walkNavTree: typeof import('./src/composables/useBreadcrumb')['walkNavTree']
|
||||||
const watch: typeof import('vue')['watch']
|
const watch: typeof import('vue')['watch']
|
||||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||||
@@ -668,6 +670,7 @@ declare module 'vue' {
|
|||||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||||
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
||||||
|
readonly useNavBreadcrumb: UnwrapRef<typeof import('./src/composables/useBreadcrumb')['useNavBreadcrumb']>
|
||||||
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
||||||
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||||
@@ -753,6 +756,7 @@ declare module 'vue' {
|
|||||||
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
||||||
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
||||||
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
|
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
|
||||||
|
readonly walkNavTree: UnwrapRef<typeof import('./src/composables/useBreadcrumb')['walkNavTree']>
|
||||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||||
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
|
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
|
||||||
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
|
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
|
||||||
|
|||||||
53
apps/app/src/components-v2/layout/AppBreadcrumb.vue
Normal file
53
apps/app/src/components-v2/layout/AppBreadcrumb.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AppBreadcrumb — layout primitive per RFC-WS-PRIMEVUE-PLAN-2-5 AD-2.5-B1.
|
||||||
|
*
|
||||||
|
* Consumes `useNavBreadcrumb` (registry-driven; data derives from
|
||||||
|
* APP_NAVIGATION + the current route name). Wired into AppTopbar in P5
|
||||||
|
* (RFC §5.2 Fix 2) — standalone in P1.
|
||||||
|
*
|
||||||
|
* Naming note: imports `useNavBreadcrumb` (not the literal `useBreadcrumb`
|
||||||
|
* the AD specifies) because the legacy meta-based `useBreadcrumb` is still
|
||||||
|
* consumed by AppTopbar; P1 may not touch AppTopbar (P4–P6 scope). P4
|
||||||
|
* retires the legacy API and renames `useNavBreadcrumb` → `useBreadcrumb`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Breadcrumb from 'primevue/breadcrumb'
|
||||||
|
import { useNavBreadcrumb } from '@/composables/useBreadcrumb'
|
||||||
|
|
||||||
|
const crumbs = useNavBreadcrumb()
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
crumbs.value.map(c => ({
|
||||||
|
label: c.label,
|
||||||
|
route: c.routeName ? { name: c.routeName } : undefined,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Breadcrumb
|
||||||
|
:model="items"
|
||||||
|
:pt="{
|
||||||
|
root: { class: 'border-none p-0 bg-transparent' },
|
||||||
|
list: { class: 'gap-1' },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<RouterLink
|
||||||
|
v-if="item.route"
|
||||||
|
:to="item.route"
|
||||||
|
class="text-sm text-surface-600 hover:text-surface-900 dark:text-surface-400 dark:hover:text-surface-100 transition-colors"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm font-medium text-surface-900 dark:text-surface-100"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Breadcrumb>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { toBreadcrumbItems } from '@/composables/useBreadcrumb'
|
import { toBreadcrumbItems, walkNavTree } from '@/composables/useBreadcrumb'
|
||||||
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
|
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
|
||||||
|
import type { NavItem } from '@/config/navigation'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
@@ -149,3 +150,53 @@ describe('toBreadcrumbItems', () => {
|
|||||||
expect(items[0].label).toBe('Dashboard')
|
expect(items[0].label).toBe('Dashboard')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AD-2.5-B1 — walkNavTree (registry-driven breadcrumb)
|
||||||
|
// Local FIXTURE keeps these specs independent of APP_NAVIGATION content.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FIXTURE: NavItem[] = [
|
||||||
|
{ key: 'a', label: 'A', routeName: 'a' },
|
||||||
|
{
|
||||||
|
key: 'b',
|
||||||
|
label: 'B',
|
||||||
|
children: [
|
||||||
|
{ key: 'b.1', label: 'B-1', routeName: 'b-1' },
|
||||||
|
{
|
||||||
|
key: 'b.2',
|
||||||
|
label: 'B-2',
|
||||||
|
children: [
|
||||||
|
{ key: 'b.2.1', label: 'B-2-1', routeName: 'b-2-1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('walkNavTree (AD-2.5-B1)', () => {
|
||||||
|
it('returns single-entry chain for a top-level leaf', () => {
|
||||||
|
expect(walkNavTree(FIXTURE, 'a')).toEqual([
|
||||||
|
{ label: 'A', routeName: 'a' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns full chain for a nested leaf', () => {
|
||||||
|
expect(walkNavTree(FIXTURE, 'b-1')).toEqual([
|
||||||
|
{ label: 'B', routeName: undefined },
|
||||||
|
{ label: 'B-1', routeName: 'b-1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns full chain for a deeply nested leaf', () => {
|
||||||
|
expect(walkNavTree(FIXTURE, 'b-2-1')).toEqual([
|
||||||
|
{ label: 'B', routeName: undefined },
|
||||||
|
{ label: 'B-2', routeName: undefined },
|
||||||
|
{ label: 'B-2-1', routeName: 'b-2-1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for an unmatched route', () => {
|
||||||
|
expect(walkNavTree(FIXTURE, 'nonexistent')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import { APP_NAVIGATION, type NavItem } from '@/config/navigation'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types — legacy (meta-based, consumed by AppTopbar pre-P4)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** A single breadcrumb item. `to` is absent for the current (last) item. */
|
/** A single breadcrumb item. `to` is absent for the current (last) item. */
|
||||||
@@ -81,8 +82,11 @@ export interface UseBreadcrumbReturn {
|
|||||||
* Composable that derives breadcrumb items from the current route's
|
* Composable that derives breadcrumb items from the current route's
|
||||||
* `matched` array. Reactive — updates automatically on navigation.
|
* `matched` array. Reactive — updates automatically on navigation.
|
||||||
*
|
*
|
||||||
* Composables zone may NOT import from @/navigation — do not add
|
* NOTE (AD-2.5-B1): this `route.matched` + `meta` derivation is the
|
||||||
* route-level nav helpers here.
|
* pre-Plan-2.5 mechanism, still consumed by AppTopbar. P4 (RFC §8
|
||||||
|
* step 4) migrates AppTopbar to `useNavBreadcrumb` below; after that,
|
||||||
|
* this legacy function and its types should be retired and
|
||||||
|
* `useNavBreadcrumb` renamed to `useBreadcrumb` as the single SoT.
|
||||||
*/
|
*/
|
||||||
export function useBreadcrumb(): UseBreadcrumbReturn {
|
export function useBreadcrumb(): UseBreadcrumbReturn {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -93,3 +97,66 @@ export function useBreadcrumb(): UseBreadcrumbReturn {
|
|||||||
|
|
||||||
return { items }
|
return { items }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AD-2.5-B1 — registry-driven breadcrumb (Plan 2.5 P1 foundation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadcrumb item derived from APP_NAVIGATION. Distinct from the legacy
|
||||||
|
* `BreadcrumbItem` above which carries a `RouteLocationRaw to` field;
|
||||||
|
* this shape carries a route NAME, mapped at render time to
|
||||||
|
* `{ name: routeName }`. Both shapes reconcile in P4 when AppTopbar
|
||||||
|
* migrates and the legacy API is removed.
|
||||||
|
*/
|
||||||
|
export interface NavBreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
routeName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks a NavItem tree to find the chain from root to the leaf whose
|
||||||
|
* `routeName` matches the given name. Returns an empty array if no match.
|
||||||
|
*
|
||||||
|
* AD-2.5-B1: pure function — unit-testable without router or component mount.
|
||||||
|
*/
|
||||||
|
export function walkNavTree(
|
||||||
|
tree: NavItem[],
|
||||||
|
routeName: string,
|
||||||
|
acc: NavBreadcrumbItem[] = [],
|
||||||
|
): NavBreadcrumbItem[] {
|
||||||
|
for (const node of tree) {
|
||||||
|
const next = [...acc, { label: node.label, routeName: node.routeName }]
|
||||||
|
|
||||||
|
if (node.routeName === routeName)
|
||||||
|
return next
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const childMatch = walkNavTree(node.children, routeName, next)
|
||||||
|
|
||||||
|
if (childMatch.length > 0)
|
||||||
|
return childMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the breadcrumb chain for the current route, derived from
|
||||||
|
* APP_NAVIGATION. Empty array when `route.name` is unset or no match.
|
||||||
|
*
|
||||||
|
* AD-2.5-B1. Transitional name: in P4 (RFC §8 step 4), AppTopbar
|
||||||
|
* migrates from the legacy `useBreadcrumb` above to this composable;
|
||||||
|
* after that, rename this to `useBreadcrumb` and drop the legacy API.
|
||||||
|
*/
|
||||||
|
export function useNavBreadcrumb(): ComputedRef<NavBreadcrumbItem[]> {
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
return computed<NavBreadcrumbItem[]>(() => {
|
||||||
|
if (!route.name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return walkNavTree(APP_NAVIGATION, String(route.name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
44
apps/app/src/config/navigation.ts
Normal file
44
apps/app/src/config/navigation.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// AD-2.5-B1: central navigation registry.
|
||||||
|
// Single source of truth for sidebar rendering AND breadcrumb derivation.
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
|
||||||
|
/** Stable identifier, dot-namespaced (e.g., 'events.volunteers'). */
|
||||||
|
key: string
|
||||||
|
|
||||||
|
/** Display string shown in sidebar and breadcrumb. */
|
||||||
|
label: string
|
||||||
|
|
||||||
|
/** Vue-router route name; leaf nodes only. Branch nodes omit this. */
|
||||||
|
routeName?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon name in the project's `tabler-X` (dash-prefixed) convention,
|
||||||
|
* passed to `<Icon name="tabler-X" />`. Note: this deviates from the
|
||||||
|
* AD-2.5-B1 example which uses Iconify-standard `tabler:X` — the
|
||||||
|
* codebase's Icon.vue / iconify plugin pipeline expects dash-prefixed.
|
||||||
|
*/
|
||||||
|
icon?: string
|
||||||
|
|
||||||
|
/** Submodule nesting. */
|
||||||
|
children?: NavItem[]
|
||||||
|
|
||||||
|
/** Exclude from sidebar but allow breadcrumb walk to traverse. */
|
||||||
|
hidden?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_NAVIGATION: NavItem[] = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
routeName: 'v2-dashboard',
|
||||||
|
icon: 'tabler-home',
|
||||||
|
},
|
||||||
|
]
|
||||||
59
apps/app/tests/component/layouts/AppBreadcrumb.spec.ts
Normal file
59
apps/app/tests/component/layouts/AppBreadcrumb.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// AppBreadcrumb component spec — RFC-WS-PRIMEVUE-PLAN-2-5 AD-2.5-B1.
|
||||||
|
//
|
||||||
|
// Verifies the foundation primitive renders breadcrumb items derived
|
||||||
|
// from APP_NAVIGATION via useNavBreadcrumb. P1 only checks the matched
|
||||||
|
// and unmatched-route paths; integration into AppTopbar is P5 scope.
|
||||||
|
//
|
||||||
|
// Path follows the existing convention (sibling to AppShellV2.spec.ts /
|
||||||
|
// OrganizerLayoutV2.spec.ts under apps/app/tests/component/layouts/).
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import AppBreadcrumb from '@/components-v2/layout/AppBreadcrumb.vue'
|
||||||
|
|
||||||
|
const StubRouteComponent = defineComponent({ template: '<div />' })
|
||||||
|
|
||||||
|
function makeRouter(initialRouteName: string | null) {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'v2-dashboard', component: StubRouteComponent },
|
||||||
|
{ path: '/none', name: 'nonexistent', component: StubRouteComponent },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (initialRouteName)
|
||||||
|
router.push({ name: initialRouteName })
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppBreadcrumb (AD-2.5-B1)', () => {
|
||||||
|
it('renders the Dashboard label for the v2-dashboard route', async () => {
|
||||||
|
const router = makeRouter('v2-dashboard')
|
||||||
|
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(AppBreadcrumb, {
|
||||||
|
global: { plugins: [router, PrimeVue] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no breadcrumb items when the route is unmatched', async () => {
|
||||||
|
const router = makeRouter('nonexistent')
|
||||||
|
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(AppBreadcrumb, {
|
||||||
|
global: { plugins: [router, PrimeVue] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// PrimeVue Breadcrumb renders its items as <li>; an empty model → 0 items.
|
||||||
|
expect(wrapper.findAll('li')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user