diff --git a/apps/app/auto-imports.d.ts b/apps/app/auto-imports.d.ts index e9a18b71..8881070e 100644 --- a/apps/app/auto-imports.d.ts +++ b/apps/app/auto-imports.d.ts @@ -284,6 +284,7 @@ declare global { const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] const useMousePressed: typeof import('@vueuse/core')['useMousePressed'] const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver'] + const useNavBreadcrumb: typeof import('./src/composables/useBreadcrumb')['useNavBreadcrumb'] const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage'] const useNetwork: typeof import('@vueuse/core')['useNetwork'] const useNow: typeof import('@vueuse/core')['useNow'] @@ -372,6 +373,7 @@ declare global { const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll'] const useWindowSize: typeof import('@vueuse/core')['useWindowSize'] const usecreateUrl: typeof import('./src/@core/composable/usecreateUrl')['usecreateUrl'] + const walkNavTree: typeof import('./src/composables/useBreadcrumb')['walkNavTree'] const watch: typeof import('vue')['watch'] const watchArray: typeof import('@vueuse/core')['watchArray'] const watchAtMost: typeof import('@vueuse/core')['watchAtMost'] @@ -668,6 +670,7 @@ declare module 'vue' { readonly useMouseInElement: UnwrapRef readonly useMousePressed: UnwrapRef readonly useMutationObserver: UnwrapRef + readonly useNavBreadcrumb: UnwrapRef readonly useNavigatorLanguage: UnwrapRef readonly useNetwork: UnwrapRef readonly useNow: UnwrapRef @@ -753,6 +756,7 @@ declare module 'vue' { readonly useWindowFocus: UnwrapRef readonly useWindowScroll: UnwrapRef readonly useWindowSize: UnwrapRef + readonly walkNavTree: UnwrapRef readonly watch: UnwrapRef readonly watchArray: UnwrapRef readonly watchAtMost: UnwrapRef diff --git a/apps/app/src/components-v2/layout/AppBreadcrumb.vue b/apps/app/src/components-v2/layout/AppBreadcrumb.vue new file mode 100644 index 00000000..26af0ab0 --- /dev/null +++ b/apps/app/src/components-v2/layout/AppBreadcrumb.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/app/src/composables/__tests__/useBreadcrumb.spec.ts b/apps/app/src/composables/__tests__/useBreadcrumb.spec.ts index 8a6323a4..f54c7247 100644 --- a/apps/app/src/composables/__tests__/useBreadcrumb.spec.ts +++ b/apps/app/src/composables/__tests__/useBreadcrumb.spec.ts @@ -1,6 +1,7 @@ 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 { NavItem } from '@/config/navigation' // --------------------------------------------------------------------------- // Fixtures @@ -149,3 +150,53 @@ describe('toBreadcrumbItems', () => { 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([]) + }) +}) diff --git a/apps/app/src/composables/useBreadcrumb.ts b/apps/app/src/composables/useBreadcrumb.ts index 94a90b64..fc421758 100644 --- a/apps/app/src/composables/useBreadcrumb.ts +++ b/apps/app/src/composables/useBreadcrumb.ts @@ -2,9 +2,10 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' import type { ComputedRef } from 'vue' 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. */ @@ -81,8 +82,11 @@ export interface UseBreadcrumbReturn { * Composable that derives breadcrumb items from the current route's * `matched` array. Reactive — updates automatically on navigation. * - * Composables zone may NOT import from @/navigation — do not add - * route-level nav helpers here. + * NOTE (AD-2.5-B1): this `route.matched` + `meta` derivation is the + * 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 { const route = useRoute() @@ -93,3 +97,66 @@ export function useBreadcrumb(): UseBreadcrumbReturn { 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 { + const route = useRoute() + + return computed(() => { + if (!route.name) + return [] + + return walkNavTree(APP_NAVIGATION, String(route.name)) + }) +} diff --git a/apps/app/src/config/navigation.ts b/apps/app/src/config/navigation.ts new file mode 100644 index 00000000..d285173b --- /dev/null +++ b/apps/app/src/config/navigation.ts @@ -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 ``. 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', + }, +] diff --git a/apps/app/tests/component/layouts/AppBreadcrumb.spec.ts b/apps/app/tests/component/layouts/AppBreadcrumb.spec.ts new file mode 100644 index 00000000..f6ec1511 --- /dev/null +++ b/apps/app/tests/component/layouts/AppBreadcrumb.spec.ts @@ -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: '
' }) + +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
  • ; an empty model → 0 items. + expect(wrapper.findAll('li')).toHaveLength(0) + }) +})