chore(layout): remove v2 nav-folding orphans surfaced by P4 refactor

P4 (Plan 2.5, AD-2.5-W1 + AD-2.5-B1) refactored SidebarNav to read
APP_NAVIGATION directly, retiring the OrganizerLayoutV2 → useV2Nav →
AppSidebar :groups → SidebarNav :groups props chain. Five artifacts
were deliberately left in place to keep the P4 diff focused — this
commit removes them.

Deleted:
- src/composables/useV2Nav.ts (+ spec) — v1→v2 nav fold adapter, no
  production consumer post-P4
- src/types/v2/nav.ts — V2NavGroup / V2NavItem types, only consumed
  by the deleted composables above. types/v2/ directory removed (empty)
- src/components-v2/layout/sidebarNavActive.ts (+ spec) — pure helper,
  SidebarNav now uses inlined active check against NavItem.routeName
- navFixture export + V2NavGroup import from stories/v2/_helpers.ts

Also: stale "useV2Nav(orgNavItems)" reference scrubbed from
OrganizerLayoutV2.vue docstring (the function no longer exists; the
comment now describes the retired plumbing generically).

Suite delta: 575 → 557 (−18 specs). The drop is correct — the removed
specs tested deleted dead code (sidebarNavActive: 8 specs, useV2Nav:
10 specs), not contract behaviour.

vue-tsc clean. Scoped ESLint clean (0 errors). Final re-grep on all
deleted symbols (useV2Nav, V2NavGroup, V2NavItem, sidebarNavActive,
navFixture) returns zero hits across apps/app/src/.

Per zero-compromise gap 5 (delete > adapt): orphans don't stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 19:32:46 +02:00
parent 864cc558e2
commit 967f1a93bb
7 changed files with 1 additions and 313 deletions

View File

@@ -1,46 +0,0 @@
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)
})
})

View File

@@ -1,37 +0,0 @@
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}-`)
}

View File

@@ -1,101 +0,0 @@
import { describe, expect, it } from 'vitest'
import { toV2NavGroups, useV2Nav } 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 equals the v1 route name (already kebab-case; no normalisation applied)', () => {
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)
})
})
describe('useV2Nav', () => {
it('returns a computed whose .value equals toV2NavGroups(items)', () => {
const { groups } = useV2Nav(fixture)
expect(groups.value).toEqual(toV2NavGroups(fixture))
})
it('consecutive headings produce an empty-items group then the next group', () => {
const consecutiveHeadings = [
{ heading: 'First' },
{ heading: 'Second' },
{ title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } },
] as const
const groups = toV2NavGroups(consecutiveHeadings)
expect(groups).toHaveLength(2)
expect(groups[0].label).toBe('First')
expect(groups[0].items).toHaveLength(0)
expect(groups[1].label).toBe('Second')
expect(groups[1].items).toHaveLength(1)
})
})

View File

@@ -1,95 +0,0 @@
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, // v1 route names are already kebab-case; no normalisation needed
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<V2NavGroup[]> } {
const groups = computed(() => toV2NavGroups(items))
return { groups }
}

View File

@@ -10,7 +10,7 @@
*
* 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.
* passes nav data; the prior v1→v2 nav-fold plumbing has been retired.
*
* No provide/inject: each shell piece reads its own state from
* useShellUiStore / useAuthStore (RFC AD-G4). This layout wires

View File

@@ -1,7 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import type { Decorator } from '@storybook/vue3-vite'
import type { Organisation, User } from '@/types/auth'
import type { V2NavGroup } from '@/types/v2/nav'
/**
* Fresh Pinia per story + optional seeding. The seed fn runs AFTER
@@ -64,21 +63,3 @@ export const orgC: Organisation = {
/** Convenience single-org list for store seeds. */
export const orgFixture: Organisation[] = [orgA]
export const navFixture: V2NavGroup[] = [
{
label: '',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-smart-home', to: { name: 'dashboard' } },
{ id: 'events', label: 'Evenementen', icon: 'tabler-calendar-event', to: { name: 'events' }, count: 3 },
],
},
{
label: 'Beheer',
items: [
{ id: 'organisation', label: 'Mijn Organisatie', icon: 'tabler-building', to: { name: 'organisation' } },
{ id: 'members', label: 'Leden', icon: 'tabler-users', to: { name: 'members' } },
{ id: 'organisation-settings', label: 'Instellingen', icon: 'tabler-settings', to: { name: 'organisation-settings' } },
],
},
]

View File

@@ -1,14 +0,0 @@
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[]
}