diff --git a/apps/app/src/components-v2/layout/SidebarNav.vue b/apps/app/src/components-v2/layout/SidebarNav.vue new file mode 100644 index 00000000..adbb276d --- /dev/null +++ b/apps/app/src/components-v2/layout/SidebarNav.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts new file mode 100644 index 00000000..82ca06d2 --- /dev/null +++ b/apps/app/src/components-v2/layout/__tests__/sidebarNavActive.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive' +import type { V2NavItem } from '@/types/v2/nav' + +function makeItem(name: string): V2NavItem { + return { id: name, label: name, icon: 'tabler-home', to: { name } } +} + +describe('isNavItemActive', () => { + it('returns true when the current route name exactly matches', () => { + expect(isNavItemActive(makeItem('dashboard'), 'dashboard')).toBe(true) + }) + + it('returns false when the current route name differs', () => { + expect(isNavItemActive(makeItem('dashboard'), 'events')).toBe(false) + }) + + it('returns false when currentRouteName is null', () => { + expect(isNavItemActive(makeItem('dashboard'), null)).toBe(false) + }) + + it('returns false when currentRouteName is undefined', () => { + expect(isNavItemActive(makeItem('dashboard'), undefined)).toBe(false) + }) + + it('returns true for nested routes: item name is a prefix of current route (dash-separated)', () => { + // e.g. item "organisation" should be active on route "organisation-settings" + expect(isNavItemActive(makeItem('organisation'), 'organisation-settings')).toBe(true) + }) + + it('does not match partial-word prefixes: "org" should NOT match "organisation-settings"', () => { + expect(isNavItemActive(makeItem('org'), 'organisation-settings')).toBe(false) + }) + + it('handles symbol route names: returns false (not string — no match)', () => { + const sym = Symbol('dashboard') + + expect(isNavItemActive(makeItem('dashboard'), sym)).toBe(false) + }) + + it('item with to as { name: string } matches correctly', () => { + const item: V2NavItem = { id: 'events', label: 'Events', icon: 'tabler-calendar', to: { name: 'events' } } + + expect(isNavItemActive(item, 'events')).toBe(true) + }) +}) diff --git a/apps/app/src/components-v2/layout/sidebarNavActive.ts b/apps/app/src/components-v2/layout/sidebarNavActive.ts new file mode 100644 index 00000000..9305bacc --- /dev/null +++ b/apps/app/src/components-v2/layout/sidebarNavActive.ts @@ -0,0 +1,37 @@ +import type { V2NavItem } from '@/types/v2/nav' + +/** + * Pure helper — determines whether a V2NavItem is "active" given the current + * route name. + * + * Active rules (simplest correct definition): + * 1. Exact match: currentRouteName === item.to.name + * 2. Prefix match for nested routes: currentRouteName starts with + * item.to.name + '-' (e.g. item "organisation" is active on + * "organisation-settings"). The dash boundary prevents "org" from + * spuriously matching "organisation-settings". + * + * Only `to` values that are a plain object with a string `name` property are + * compared — string/array `to` values always return false (router-push + * style not used in v1 nav). + */ +export function isNavItemActive( + item: V2NavItem, + currentRouteName: string | symbol | null | undefined, +): boolean { + if (typeof currentRouteName !== 'string') + return false + + const to = item.to + + if (typeof to !== 'object' || to === null || Array.isArray(to)) + return false + + const itemName = (to as { name?: unknown }).name + + if (typeof itemName !== 'string') + return false + + return currentRouteName === itemName + || currentRouteName.startsWith(`${itemName}-`) +} diff --git a/apps/app/src/composables/__tests__/useV2Nav.spec.ts b/apps/app/src/composables/__tests__/useV2Nav.spec.ts new file mode 100644 index 00000000..dd7a4e9b --- /dev/null +++ b/apps/app/src/composables/__tests__/useV2Nav.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { toV2NavGroups } from '@/composables/useV2Nav' +import type { V2NavItem } from '@/types/v2/nav' + +// Hand-built fixture — does NOT depend on the real orgNavItems contents. +const fixture = [ + { title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } }, + { title: 'Beta', to: { name: 'beta' }, icon: { icon: 'tabler-bell' } }, + { heading: 'Group One' }, + { title: 'Gamma', to: { name: 'gamma' }, icon: { icon: 'tabler-star' }, count: 5 }, + { heading: 'Group Two' }, + { title: 'Delta', to: { name: 'delta' }, icon: { icon: 'tabler-settings' } }, +] as const + +describe('toV2NavGroups', () => { + it('places items before the first heading into a leading group with label ""', () => { + const groups = toV2NavGroups(fixture) + + expect(groups[0].label).toBe('') + expect(groups[0].items).toHaveLength(2) + }) + + it('starts a new group when a heading entry is encountered', () => { + const groups = toV2NavGroups(fixture) + + expect(groups).toHaveLength(3) + expect(groups[1].label).toBe('Group One') + expect(groups[2].label).toBe('Group Two') + }) + + it('maps item fields correctly: id, label, icon, to', () => { + const groups = toV2NavGroups(fixture) + const alpha = groups[0].items[0] as V2NavItem + + expect(alpha.id).toBe('alpha') + expect(alpha.label).toBe('Alpha') + expect(alpha.icon).toBe('tabler-home') + expect(alpha.to).toEqual({ name: 'alpha' }) + }) + + it('passes count through when present', () => { + const groups = toV2NavGroups(fixture) + const gamma = groups[1].items[0] as V2NavItem + + expect(gamma.count).toBe(5) + }) + + it('leaves count undefined when absent', () => { + const groups = toV2NavGroups(fixture) + const alpha = groups[0].items[0] as V2NavItem + + expect(alpha.count).toBeUndefined() + }) + + it('id is derived from the route name (kebab already for dotted names)', () => { + const groups = toV2NavGroups(fixture) + const delta = groups[2].items[0] as V2NavItem + + expect(delta.id).toBe('delta') + }) + + it('returns empty groups array for an empty input', () => { + expect(toV2NavGroups([])).toEqual([]) + }) + + it('items-only input (no headings) returns a single leading group', () => { + const onlyItems = [ + { title: 'A', to: { name: 'a' }, icon: { icon: 'tabler-a' } }, + ] as const + + const groups = toV2NavGroups(onlyItems) + + expect(groups).toHaveLength(1) + expect(groups[0].label).toBe('') + expect(groups[0].items).toHaveLength(1) + }) +}) diff --git a/apps/app/src/composables/useV2Nav.ts b/apps/app/src/composables/useV2Nav.ts new file mode 100644 index 00000000..ec1aa57a --- /dev/null +++ b/apps/app/src/composables/useV2Nav.ts @@ -0,0 +1,95 @@ +import { computed } from 'vue' +import type { ComputedRef } from 'vue' +import type { V2NavGroup, V2NavItem } from '@/types/v2/nav' + +// --------------------------------------------------------------------------- +// Local discriminated union for v1 nav entries (no `any`) +// --------------------------------------------------------------------------- + +interface V1NavHeading { + heading: string +} + +interface V1NavLink { + title: string + to: { name: string } + icon: { icon: string } + count?: number +} + +export type V1NavEntry = V1NavHeading | V1NavLink + +function isHeading(entry: V1NavEntry): entry is V1NavHeading { + return 'heading' in entry +} + +// --------------------------------------------------------------------------- +// Pure adapter — exported so it can be unit-tested without mounting +// --------------------------------------------------------------------------- + +/** + * Folds a flat v1 nav array into V2NavGroup[]. + * + * - A `{ heading }` entry closes the current group and opens a new one. + * - Items before the first heading are placed in a leading group with label ''. + * - A pure function with no side-effects. + */ +export function toV2NavGroups(items: readonly V1NavEntry[]): V2NavGroup[] { + if (items.length === 0) + return [] + + const groups: V2NavGroup[] = [] + let current: V2NavGroup | null = null + + for (const entry of items) { + if (isHeading(entry)) { + // Close current group (if any) and start a new named group. + if (current !== null) + groups.push(current) + + current = { label: entry.heading, items: [] } + } + else { + // Ensure there is a current group (leading ungrouped section). + if (current === null) + current = { label: '', items: [] } + + const navItem: V2NavItem = { + id: entry.to.name, + label: entry.title, + icon: entry.icon.icon, + to: { name: entry.to.name }, + ...(entry.count !== undefined ? { count: entry.count } : {}), + } + + current.items.push(navItem) + } + } + + // Flush the last open group. + if (current !== null) + groups.push(current) + + return groups +} + +// --------------------------------------------------------------------------- +// Composable +// --------------------------------------------------------------------------- + +/** + * Wraps toV2NavGroups in a computed ref. + * + * Accepts the raw v1 nav items as a parameter so the composable (composables + * boundary zone) does not need to import from @/navigation (navigation zone). + * Call-sites in layouts/pages — which ARE allowed to import navigation — + * pass orgNavItems directly: + * + * import { orgNavItems } from '@/navigation/vertical' + * const { groups } = useV2Nav(orgNavItems) + */ +export function useV2Nav(items: readonly V1NavEntry[]): { groups: ComputedRef } { + const groups = computed(() => toV2NavGroups(items)) + + return { groups } +} diff --git a/apps/app/src/types/v2/nav.ts b/apps/app/src/types/v2/nav.ts new file mode 100644 index 00000000..18c9b4a3 --- /dev/null +++ b/apps/app/src/types/v2/nav.ts @@ -0,0 +1,14 @@ +import type { RouteLocationRaw } from 'vue-router' + +export interface V2NavItem { + id: string + label: string + icon: string // e.g. 'tabler-smart-home' + to: RouteLocationRaw + count?: number +} + +export interface V2NavGroup { + label: string // '' for an ungrouped leading section + items: V2NavItem[] +}