feat(layout): Plan 2.5 P5 — shell parity fixes 1–5 + useBreadcrumb retire
Per RFC-WS-PRIMEVUE-PLAN-2-5 §5.1–§5.5 plus the AD-2.5-W1 option-A supersession (no sub on dropdown items either, accepted divergence). Atomic changes: - AppTopbar: brand block (gradient "C" mark + Crewli wordmark) removed per Fix 1; the #start slot now renders <AppBreadcrumb /> per Fix 2. Legacy meta-based useBreadcrumb consumption (breadcrumbModel computed, vue-router useRouter import, command-based PrimeVue Breadcrumb model) is gone; AppBreadcrumb owns the registry-driven path. Dead topbar-mark-shadow scoped CSS rule deleted. - AppBreadcrumb: import updated to the renamed useBreadcrumb. - AppSidebar: docstring updated to make the Fix 3 vertical order (Header → Nav → Switcher, switcher bottom-anchored) explicit. No template change needed: SidebarNav's root <nav class="flex-1"> already fills available column space, naturally pushing WorkspaceSwitcher to the bottom (two flex-1 siblings would split the column 50/50 and compress the nav — a separate spacer element is structurally wrong). - WorkspaceSwitcher: dropdown panel restructured per crewli-starter reference. Semantic class markers (.popover-head/.title/.link/.list/ .opt/.is-current/.ws-logo/.name/.check-mark/.foot) added alongside Tailwind utilities so specs assert structure with stable selectors. Footer buttons wired to placeholder createWorkspace / inviteUser handlers (console.warn + TODO) until the flows ship. Manage link stays a non-navigating label (no v2-workspaces-manage route yet). No sub line on any dropdown row (AD-2.5-W1 option A). Atomic legacy useBreadcrumb retirement (planned since P1): - Legacy route-meta-driven useBreadcrumb + toBreadcrumbItems + BreadcrumbRouteRecord types deleted entirely (only AppTopbar consumed it, and that consumption is gone after Fix 2). - useNavBreadcrumb → useBreadcrumb (single SoT for breadcrumb chain). - NavBreadcrumbItem → BreadcrumbItem. - AppBreadcrumb.vue import updated to the new name. - SidebarNav.vue docstring reference scrubbed to the new name. - useBreadcrumb.spec.ts: 10 legacy toBreadcrumbItems specs removed; 4 walkNavTree specs retained. AppTopbar.spec.ts: - vue-router mock simplified (route.matched no longer relevant). - AppBreadcrumb stubbed in #start; legacy command-vs-route assertion removed; new spec verifies AppBreadcrumb is rendered. WorkspaceSwitcher.spec.ts: 5 new dropdown specs (header / row count / current-row checkmark / footer buttons / no-sub on rows). Suite delta: 557 → 552 (−5 net: −10 legacy toBreadcrumbItems specs, +5 Fix 5 dropdown specs, −1 obsolete AppTopbar breadcrumb-model spec, +1 new AppTopbar AppBreadcrumb-presence spec). vue-tsc clean. Scoped ESLint clean (0 errors). All 3 re-grep checks returned 0 hits (useNavBreadcrumb/NavBreadcrumbItem, topbar brand selectors, standalone "sub" identifier in WorkspaceSwitcher — only documentation comments referencing the no-sub state remain, which describe absence by design). Manual smoke skipped (Auto Mode); coverage from the post-edit specs includes AppBreadcrumb-in-#start, dropdown structure, and trigger no-sub. Recommend Bert run `pnpm --filter crewli-app dev` and verify the 6 checks listed in the prompt before merging. Known divergence from crewli-starter (accepted): - Dropdown rows are ~16px shorter than crewli-starter (no sub line). Tracked as WORKSPACE-DROPDOWN-SUB-CONTENT for a future RFC with the required backend scope (organisations.type enum + metrics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
apps/app/auto-imports.d.ts
vendored
4
apps/app/auto-imports.d.ts
vendored
@@ -550,12 +550,10 @@ declare module 'vue' {
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
|
||||
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
|
||||
readonly toBreadcrumbItems: UnwrapRef<typeof import('./src/composables/useBreadcrumb')['toBreadcrumbItems']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toV2NavGroups: UnwrapRef<typeof import('./src/composables/useV2Nav')['toV2NavGroups']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
|
||||
@@ -670,7 +668,6 @@ declare module 'vue' {
|
||||
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
|
||||
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
|
||||
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 useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
||||
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
||||
@@ -743,7 +740,6 @@ declare module 'vue' {
|
||||
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
|
||||
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
|
||||
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
|
||||
readonly useV2Nav: UnwrapRef<typeof import('./src/composables/useV2Nav')['useV2Nav']>
|
||||
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
|
||||
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
|
||||
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
/**
|
||||
* AppBreadcrumb — layout primitive per RFC-WS-PRIMEVUE-PLAN-2-5 AD-2.5-B1.
|
||||
*
|
||||
* Consumes `useNavBreadcrumb` (registry-driven; data derives from
|
||||
* Consumes `useBreadcrumb` (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`.
|
||||
* (RFC §5.2 Fix 2).
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import { useNavBreadcrumb } from '@/composables/useBreadcrumb'
|
||||
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
||||
|
||||
const crumbs = useNavBreadcrumb()
|
||||
const crumbs = useBreadcrumb()
|
||||
|
||||
const items = computed(() =>
|
||||
crumbs.value.map(c => ({
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
* source), but @/config/navigation is the unclassified central registry
|
||||
* and is consumed in-place by SidebarNav.
|
||||
*
|
||||
* Vertical order (Plan 2.5 P5 Fix 3): SidebarHeader → SidebarNav →
|
||||
* WorkspaceSwitcher (bottom-anchored). The bottom anchoring is achieved
|
||||
* by SidebarNav's root <nav class="flex-1 ..."> filling all available
|
||||
* space inside this aside's flex-col column — no separate spacer element
|
||||
* is needed (two flex-1 siblings would split the column 50/50 and
|
||||
* compress the nav).
|
||||
*
|
||||
* Deliberate simplification: crewli-starter's bespoke Teleport tooltip (shown in
|
||||
* collapsed mode for nav items) is NOT ported here. SidebarNav already provides
|
||||
* native `:title` tooltips in collapsed mode, which is functionally equivalent
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*
|
||||
* Wiring:
|
||||
* - Hamburger → shell.setMobileOpen(true) (mobile only, lg:hidden)
|
||||
* - Brand: static mark + wordmark (Tailwind from .topbar-brand/.mark/.wordmark)
|
||||
* - Breadcrumb: PrimeVue <Breadcrumb> fed from useBreadcrumb(), route-driven
|
||||
* - Breadcrumb: <AppBreadcrumb /> primitive (registry-driven via APP_NAVIGATION).
|
||||
* Brand block was removed in Plan 2.5 P5 (RFC §5.1 Fix 1) — sidebar
|
||||
* owns Crewli identity.
|
||||
* - Mobile workspace button: org gradient via computeOrgGradient() + initials
|
||||
* - Search: static InputText with ⌘K hint (no backend, chrome only)
|
||||
* - Density toggle: shell.setDensity() with flipped value
|
||||
@@ -30,64 +31,29 @@
|
||||
*
|
||||
* Styling: crewli-starter CSS translated to Tailwind inline.
|
||||
* <style scoped> used only for:
|
||||
* 1. topbar-mark-shadow — inset box-shadow (no Tailwind utility at this granularity, RFC §7.4)
|
||||
* 2. ws-mobile-btn-shadow — same inset shadow justification
|
||||
* 1. ws-mobile-btn-shadow — inset box-shadow (no Tailwind utility at this
|
||||
* granularity, RFC §7.4)
|
||||
*/
|
||||
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menubar from 'primevue/menubar'
|
||||
import Menu from 'primevue/menu'
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||||
import { computeOrgGradient } from '@/utils/v2/gradient'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stores / router
|
||||
// Stores
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const shell = useShellUiStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb — route-driven via useBreadcrumb()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { items: breadcrumbItems } = useBreadcrumb()
|
||||
|
||||
/**
|
||||
* Map BreadcrumbItem[] → PrimeVue MenuItem[].
|
||||
*
|
||||
* The installed PrimeVue Breadcrumb (BreadcrumbItem.vue) renders
|
||||
* `<a :href="item.url || '#'">` and calls `item.command` on click.
|
||||
* It does NOT honour a `route` key — router-link is never invoked.
|
||||
*
|
||||
* Fix: non-last items navigate via `command: () => router.push(item.to)` (client-side,
|
||||
* no full reload, no href="#"). Last/current item has no `to` from useBreadcrumb()
|
||||
* → no command → non-interactive.
|
||||
*/
|
||||
const breadcrumbModel = computed<MenuItem[]>(() =>
|
||||
breadcrumbItems.value.map(item => {
|
||||
const base: MenuItem = { label: item.label }
|
||||
|
||||
if (item.to !== undefined) {
|
||||
base.command = () => {
|
||||
router.push(item.to!)
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User initials derived from full_name
|
||||
@@ -278,42 +244,16 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
||||
</button>
|
||||
|
||||
<!--
|
||||
Brand — hidden on mobile, shown >=lg.
|
||||
.topbar-brand: display none default, display inline-flex >=lg
|
||||
.mark: 28x28, rounded-lg, gradient bg, white text, font-bold 13px
|
||||
.wordmark: font-bold 16px, tracking-tight
|
||||
-->
|
||||
<div class="hidden lg:inline-flex items-center gap-2">
|
||||
<!--
|
||||
Topbar mark — dynamic gradient cannot be a static Tailwind class.
|
||||
Inline style justified (RFC §7.4). Box-shadow via scoped CSS
|
||||
(inset-shadow has no Tailwind utility at this granularity).
|
||||
-->
|
||||
<div
|
||||
class="topbar-mark-shadow inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-[13px] font-bold text-white"
|
||||
style="background: linear-gradient(135deg, var(--p-primary-500, #0d9488), var(--p-primary-700, #0f766e))"
|
||||
aria-hidden="true"
|
||||
>
|
||||
C
|
||||
</div>
|
||||
<span class="text-[16px] font-bold tracking-[-0.01em] text-[var(--p-text-color)]">
|
||||
Crewli
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Breadcrumb — PrimeVue Breadcrumb, route-driven.
|
||||
.breadcrumb: flex, items-center, gap-[6px], text-[13px], color fg-muted, flex-wrap nowrap, overflow-hidden
|
||||
Hidden on mobile per crewli-starter (<768px: display:none!important)
|
||||
Breadcrumb — registry-driven via <AppBreadcrumb /> (consumes
|
||||
APP_NAVIGATION). The Crewli brand block was removed in Plan 2.5
|
||||
P5 (RFC §5.1 Fix 1); the sidebar owns the Crewli identity now.
|
||||
Hidden on mobile per crewli-starter (<768px: display:none!important).
|
||||
-->
|
||||
<nav
|
||||
class="hidden lg:flex items-center"
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<Breadcrumb
|
||||
:model="breadcrumbModel"
|
||||
class="border-0 bg-transparent p-0 text-[13px]"
|
||||
/>
|
||||
<AppBreadcrumb />
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -472,16 +412,10 @@ const userMenuItems = computed<MenuItem[]>(() => [
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* FIX 1: topbar-mark-shadow — inset directional box-shadow.
|
||||
* Tailwind has no inset-shadow utility at this depth/direction granularity → RFC §7.4.
|
||||
*/
|
||||
.topbar-mark-shadow {
|
||||
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 2: ws-mobile-btn-shadow — same inset shadow justification as above.
|
||||
* Background gradient is set via inline :style (dynamic hex pair from computeOrgGradient).
|
||||
* ws-mobile-btn-shadow — inset directional box-shadow on the mobile workspace
|
||||
* button. Tailwind has no inset-shadow utility at this depth/direction
|
||||
* granularity → RFC §7.4 last-resort. Background gradient is set via inline
|
||||
* :style (dynamic hex pair from computeOrgGradient).
|
||||
*/
|
||||
.ws-mobile-btn-shadow {
|
||||
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 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
|
||||
* (the breadcrumb side is served by `useBreadcrumb` 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.
|
||||
|
||||
@@ -8,10 +8,24 @@
|
||||
* Popover: PrimeVue <Popover> replaces the manual document.mousedown
|
||||
* click-outside listener from crewli-starter. Toggle via popoverRef.toggle($event).
|
||||
*
|
||||
* Plan 2.5 P5 Fix 5 (RFC §5.5): dropdown panel restructured per
|
||||
* crewli-starter reference, with AD-2.5-W1 option A applied — dropdown
|
||||
* rows render WITHOUT a sub line (organisation type / metrics deferred
|
||||
* to a future RFC with backend scope — tracked as
|
||||
* WORKSPACE-DROPDOWN-SUB-CONTENT). Known visual divergence: rows are
|
||||
* ~16px shorter than crewli-starter; accepted.
|
||||
*
|
||||
* Semantic class names (.popover-head, .title, .link, .list, .opt,
|
||||
* .is-current, .ws-logo, .name, .check-mark, .foot) are kept alongside
|
||||
* the Tailwind utility classes as structural markers — they carry no
|
||||
* scoped CSS rules (only the .ws-logo / .ws-logo-square-lg box-shadow
|
||||
* exceptions below) and exist so specs can assert structure with
|
||||
* stable selectors.
|
||||
*
|
||||
* Icons: Crewli Icon.vue convention — name="tabler-x" :size="N".
|
||||
*
|
||||
* Styling: crewli-starter CSS selectors translated to Tailwind utilities inline.
|
||||
* One <style scoped> block covers the two exceptions documented below.
|
||||
* One <style scoped> block covers the two box-shadow exceptions documented below.
|
||||
*/
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
@@ -103,12 +117,25 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
authStore.setActiveOrganisation(ws.id)
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer button placeholders — neither flow is wired in Plan 2.5 scope.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TODO: wire to /v2/workspaces/new route or create-workspace dialog when ready
|
||||
function createWorkspace(): void {
|
||||
console.warn('WorkspaceSwitcher: createWorkspace not yet implemented')
|
||||
}
|
||||
|
||||
// TODO: wire to invite-user dialog when ready
|
||||
function inviteUser(): void {
|
||||
console.warn('WorkspaceSwitcher: inviteUser not yet implemented')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
|
||||
<!-- Trigger button -->
|
||||
<!-- .ws-switcher .trigger: flex, items-center, gap, w-full, px/py, rounded, border, bg-transparent, color, transition -->
|
||||
<button
|
||||
class="flex w-full items-center gap-[10px] rounded-[var(--p-border-radius)] border border-transparent bg-transparent px-[10px] py-[8px] text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
|
||||
:class="[
|
||||
@@ -120,25 +147,22 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
<!-- Logo square (gradient background is bespoke: dynamic hex pair cannot be a static Tailwind class — RFC §7.4 justified inline-style) -->
|
||||
<span
|
||||
v-if="current"
|
||||
class="ws-logo-square w-8 h-8 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[12px]"
|
||||
class="ws-logo ws-logo-square w-8 h-8 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[12px]"
|
||||
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
|
||||
>
|
||||
{{ current.initials }}
|
||||
</span>
|
||||
|
||||
<!-- Meta: name (hidden in collapsed mode) -->
|
||||
<!-- .ws-switcher .meta: flex-1, min-w-0, flex-col, line-height, text-left -->
|
||||
<!-- Meta: name (hidden in collapsed mode). AD-2.5-W1 / P4: no sub. -->
|
||||
<span
|
||||
v-if="!collapsed && current"
|
||||
class="flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
|
||||
>
|
||||
<!-- .ws-switcher .meta .name: text-[13.5px], font-semibold, truncate -->
|
||||
<span class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
||||
<span class="name truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
||||
{{ current.name }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Chevron (.ws-switcher .chev: color fg-subtle, flex-shrink-0) -->
|
||||
<Icon
|
||||
v-if="!collapsed"
|
||||
name="tabler-chevron-down"
|
||||
@@ -149,47 +173,53 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
|
||||
<!-- PrimeVue Popover — replaces crewli-starter's manual document.mousedown click-outside -->
|
||||
<Popover ref="popoverRef">
|
||||
<!-- popover-head: px-[16px] py-[14px], border-bottom, flex, items-center, justify-between -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
|
||||
<!-- .popover-head .title -->
|
||||
<span class="text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
|
||||
<!-- .popover-head .link — TODO TECH-WS-GUI-REDESIGN: no manage-workspaces route yet -->
|
||||
<span class="cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
|
||||
<!-- Header -->
|
||||
<div class="popover-head flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
|
||||
<span class="title text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
|
||||
<!--
|
||||
TODO TECH-WS-GUI-REDESIGN: no v2-workspaces-manage route yet —
|
||||
rendered as a non-navigating label per the routeName-undefined
|
||||
convention (registry-style). Becomes a RouterLink when the
|
||||
manage-workspaces page lands.
|
||||
-->
|
||||
<span class="link cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
|
||||
</div>
|
||||
|
||||
<!-- .pop-ws .list: p-[6px] -->
|
||||
<div class="min-w-[280px] p-[6px]">
|
||||
<!-- .pop-ws .opt: grid 3-col, gap, p, rounded, cursor-pointer -->
|
||||
<!-- List of organisations -->
|
||||
<div class="list min-w-[280px] p-[6px]">
|
||||
<!-- TODO TECH-WS-GUI-REDESIGN: full listbox/menu ARIA (role, roving tabindex, arrow-key nav) — tracked -->
|
||||
<button
|
||||
v-for="ws in allOrgs"
|
||||
:key="ws.id"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[36px_1fr_auto] cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] border-0 bg-transparent p-[10px] text-start hover:bg-[var(--p-content-hover-background)]"
|
||||
class="opt grid w-full grid-cols-[36px_1fr_auto] cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] border-0 bg-transparent p-[10px] text-start hover:bg-[var(--p-content-hover-background)]"
|
||||
:class="[
|
||||
ws.id === current?.id ? 'bg-[var(--p-primary-50)]' : '',
|
||||
ws.id === current?.id ? 'is-current bg-[var(--p-primary-50)]' : '',
|
||||
]"
|
||||
:aria-current="current?.id === ws.id ? 'true' : undefined"
|
||||
@click="selectOrg(ws)"
|
||||
>
|
||||
<!-- Org logo — larger variant (36px) with dynamic gradient (same inline-style justification as trigger) -->
|
||||
<!-- Org logo (large variant) — dynamic gradient via inline :style -->
|
||||
<span
|
||||
class="ws-logo-square-lg w-9 h-9 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
|
||||
class="ws-logo lg ws-logo-square-lg w-9 h-9 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
|
||||
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
|
||||
>{{ ws.initials }}</span>
|
||||
|
||||
<!-- Name -->
|
||||
<!--
|
||||
Name only. AD-2.5-W1 option A: no .sub line on dropdown items
|
||||
until WORKSPACE-DROPDOWN-SUB-CONTENT RFC lands with backend
|
||||
(organisations.type enum + metrics endpoint).
|
||||
-->
|
||||
<span>
|
||||
<!-- .pop-ws .opt .name -->
|
||||
<div class="text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
||||
<div class="name text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
||||
</span>
|
||||
|
||||
<!-- Check mark for active org (.pop-ws .opt .check-mark) -->
|
||||
<!-- Check mark for active org -->
|
||||
<Icon
|
||||
v-if="ws.id === current?.id"
|
||||
name="tabler-check"
|
||||
:size="18"
|
||||
class="text-[var(--p-primary-color)]"
|
||||
class="check-mark text-[var(--p-primary-color)]"
|
||||
/>
|
||||
<!-- Spacer when not current (keeps grid alignment) -->
|
||||
<span
|
||||
@@ -199,18 +229,24 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer (.pop-ws .foot: p-[8px], border-top, flex, gap-[4px]) -->
|
||||
<div class="flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
|
||||
<!-- TODO TECH-WS-GUI-REDESIGN: create-workspace route not yet defined -->
|
||||
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
|
||||
<!-- Footer -->
|
||||
<div class="foot flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]"
|
||||
@click="createWorkspace"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-plus"
|
||||
:size="14"
|
||||
/>
|
||||
New workspace
|
||||
</button>
|
||||
<!-- TODO TECH-WS-GUI-REDESIGN: invite route not yet defined -->
|
||||
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]"
|
||||
@click="inviteUser"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-user-plus"
|
||||
:size="14"
|
||||
@@ -224,7 +260,7 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
|
||||
<style scoped>
|
||||
/**
|
||||
* FIX 2: ws-logo-square — width/height moved to Tailwind (w-8 h-8 on the element).
|
||||
* ws-logo-square — width/height moved to Tailwind (w-8 h-8 on the element).
|
||||
* Tailwind has no inset directional box-shadow utility at this granularity →
|
||||
* scoped CSS last resort per RFC §7.4.
|
||||
*/
|
||||
@@ -233,7 +269,7 @@ function selectOrg(ws: WorkspaceDisplay): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 2: ws-logo-square-lg — width/height moved to Tailwind (w-9 h-9 on the element).
|
||||
* ws-logo-square-lg — width/height moved to Tailwind (w-9 h-9 on the element).
|
||||
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
|
||||
* Background gradient set via inline :style on the element (dynamic hex pair).
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
* 2. Theme toggle click → shell.setTheme with flipped value (light→dark, dark→light)
|
||||
* 3. Density toggle click → shell.setDensity with flipped value
|
||||
* 4. User-menu Sign out command → authStore.logout called
|
||||
* 5. Breadcrumb model mapping:
|
||||
* - non-last items carry a `command` that calls router.push with the item's `to`
|
||||
* - last item has NO `command` (non-interactive) and NO `route` key (FIX A regression)
|
||||
* 5. AppBreadcrumb is rendered in the #start slot (Plan 2.5 P5 Fix 2).
|
||||
*
|
||||
* useBreadcrumb() calls useRoute() internally. We provide a minimal
|
||||
* vue-router mock via vi.mock so the composable has a route to call.
|
||||
* useRoute is exposed as a vi.fn() so individual tests can override the
|
||||
* matched records without re-importing the module.
|
||||
* AppBreadcrumb (and its useBreadcrumb composable) is stubbed so this spec
|
||||
* never reaches the real route-driven walkNavTree path — breadcrumb-derivation
|
||||
* coverage lives in useBreadcrumb.spec.ts.
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -27,23 +24,15 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock vue-router (useBreadcrumb calls useRoute internally).
|
||||
// vi.hoisted() initialises these vi.fn() instances before the vi.mock()
|
||||
// factory runs. Vitest hoists vi.hoisted() + vi.mock() above all imports,
|
||||
// so the mock is registered before AppTopbar's transitive vue-router
|
||||
// import resolves — physical import order here is irrelevant to that.
|
||||
// Mock vue-router — AppTopbar no longer uses useRoute/useRouter directly, but
|
||||
// PrimeVue's stubbed components and AppBreadcrumb (stubbed below) may import
|
||||
// from vue-router transitively. A minimal mock keeps the test environment lean.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockRouterPush, mockUseRoute } = vi.hoisted(() => ({
|
||||
mockRouterPush: vi.fn(),
|
||||
mockUseRoute: vi.fn(() => ({ matched: [] as unknown[] })),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: mockUseRoute,
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
useRoute: () => ({ name: 'v2-dashboard' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,7 +52,7 @@ const MenubarStub = {
|
||||
|
||||
const globalStubs = {
|
||||
Menubar: MenubarStub,
|
||||
Breadcrumb: { name: 'Breadcrumb', props: ['model'], template: '<nav class="breadcrumb-stub" />' },
|
||||
AppBreadcrumb: { name: 'AppBreadcrumb', template: '<nav class="app-breadcrumb-stub" data-testid="app-breadcrumb-stub" />' },
|
||||
InputText: { name: 'InputText', template: '<input class="input-text-stub" />' },
|
||||
OverlayBadge: { name: 'OverlayBadge', props: ['value', 'severity'], template: '<div class="overlay-badge-stub"><slot /></div>' },
|
||||
Popover: {
|
||||
@@ -106,11 +95,6 @@ function mountTopbar() {
|
||||
describe('AppTopbar', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockRouterPush.mockReset()
|
||||
mockUseRoute.mockReset()
|
||||
|
||||
// Default: empty matched array (no breadcrumb items)
|
||||
mockUseRoute.mockReturnValue({ matched: [] })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -248,55 +232,16 @@ describe('AppTopbar', () => {
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FIX A regression: breadcrumb model mapping uses command+router.push,
|
||||
// NOT the `route` key that this PrimeVue Breadcrumb version ignores.
|
||||
//
|
||||
// BreadcrumbItem.vue renders <a :href="item.url || '#'"> and calls
|
||||
// item.command on click — it never reads `route`. This test would have
|
||||
// caught the broken mapping that set `route` instead of `command`.
|
||||
// Plan 2.5 P5 Fix 2: AppTopbar #start renders <AppBreadcrumb /> (not the
|
||||
// legacy PrimeVue Breadcrumb fed by a route-meta mapping). Breadcrumb
|
||||
// derivation coverage lives in useBreadcrumb.spec.ts.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('breadcrumb model: non-last items have command that calls router.push; last item has no command and no route key', async () => {
|
||||
// Provide two matched records so we get a non-last item (with `to`) and
|
||||
// a last item (current, no `to`).
|
||||
mockUseRoute.mockReturnValue({
|
||||
matched: [
|
||||
{ meta: { breadcrumb: 'Dashboard' }, name: 'dashboard', path: '/dashboard' },
|
||||
{ meta: { breadcrumb: 'Events' }, name: 'events', path: '/events' },
|
||||
],
|
||||
})
|
||||
|
||||
it('renders <AppBreadcrumb /> inside the Menubar #start slot', () => {
|
||||
const wrapper = mountTopbar()
|
||||
const bar = wrapper.findComponent(MenubarStub)
|
||||
|
||||
const breadcrumbStub = wrapper.findComponent({ name: 'Breadcrumb' })
|
||||
|
||||
expect(breadcrumbStub.exists()).toBe(true)
|
||||
|
||||
const model = breadcrumbStub.props('model') as Array<{
|
||||
label: string
|
||||
command?: () => void
|
||||
route?: unknown
|
||||
}>
|
||||
|
||||
// Should have two items produced by toBreadcrumbItems
|
||||
expect(model).toHaveLength(2)
|
||||
|
||||
const [firstItem, lastItem] = model as [typeof model[0], typeof model[0]]
|
||||
|
||||
// Non-last item: must carry `command`, must NOT carry `route`
|
||||
expect(firstItem.label).toBe('Dashboard')
|
||||
expect(typeof firstItem.command).toBe('function')
|
||||
expect('route' in firstItem).toBe(false)
|
||||
|
||||
// Invoking command must call router.push with the item's resolved `to` path
|
||||
firstItem.command!()
|
||||
expect(mockRouterPush).toHaveBeenCalledOnce()
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/dashboard')
|
||||
|
||||
// Last/current item: must have NO command (non-interactive), NO `route` key
|
||||
expect(lastItem.label).toBe('Events')
|
||||
expect(lastItem.command).toBeUndefined()
|
||||
expect('route' in lastItem).toBe(false)
|
||||
expect(bar.find('[data-testid="app-breadcrumb-stub"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* WorkspaceSwitcher.spec.ts — regression coverage for AD-2.5-W1.
|
||||
* WorkspaceSwitcher.spec.ts
|
||||
*
|
||||
* 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.
|
||||
* Locks two slices of contract:
|
||||
* 1. AD-2.5-W1 + AD-2.5-W1 option A: NO sub line on trigger OR on any
|
||||
* dropdown row.
|
||||
* 2. Plan 2.5 P5 Fix 5: dropdown panel structure per crewli-starter —
|
||||
* header (title + manage link), list (one .opt per org, .is-current
|
||||
* + .check-mark on active), footer (two buttons).
|
||||
*
|
||||
* Popover is stubbed as a simple slot passthrough so its content renders
|
||||
* inline and `wrapper.find('.popover-head')` etc. resolve without
|
||||
* teleporting to <body>.
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -28,12 +33,9 @@ const userFixture: User = {
|
||||
avatar: null,
|
||||
}
|
||||
|
||||
const orgFixture: Organisation = {
|
||||
id: 'org_a',
|
||||
name: 'Festival Crew NL',
|
||||
slug: 'festival-crew-nl',
|
||||
role: 'org_admin',
|
||||
}
|
||||
const orgA: Organisation = { id: 'org_a', name: 'Festival Crew NL', slug: 'festival-crew-nl', role: 'org_admin' }
|
||||
const orgB: Organisation = { id: 'org_b', name: 'Stadspark Events', slug: 'stadspark-events', role: 'org_member' }
|
||||
const orgC: Organisation = { id: 'org_c', name: 'Volunteer Collective', slug: 'volunteer-collective', role: 'event_manager' }
|
||||
|
||||
const globalStubs = {
|
||||
Icon: { template: '<span class="icon-stub" />' },
|
||||
@@ -47,13 +49,13 @@ const globalStubs = {
|
||||
},
|
||||
}
|
||||
|
||||
function mountSwitcher() {
|
||||
function mountSwitcher(opts: { orgs?: Organisation[] } = {}) {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
auth.user = userFixture
|
||||
auth.organisations = [orgFixture]
|
||||
auth.organisations = opts.orgs ?? [orgA, orgB, orgC]
|
||||
|
||||
return mount(WorkspaceSwitcher, {
|
||||
props: { collapsed: false },
|
||||
@@ -68,19 +70,87 @@ describe('WorkspaceSwitcher', () => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('does not render a sub line (AD-2.5-W1)', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
// -------------------------------------------------------------------------
|
||||
// AD-2.5-W1 — no sub on trigger
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Role string used to back the sub line — must not leak into rendered output.
|
||||
expect(wrapper.text()).not.toContain(orgFixture.role)
|
||||
it('does not render a sub line on the trigger (AD-2.5-W1)', () => {
|
||||
const wrapper = mountSwitcher({ orgs: [orgA] })
|
||||
|
||||
// No selector / class tied to the removed sub element.
|
||||
expect(wrapper.text()).not.toContain(orgA.role)
|
||||
expect(wrapper.html()).not.toMatch(/workspace-sub|ws-sub|meta-sub/)
|
||||
})
|
||||
|
||||
it('renders the workspace name', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
it('renders the workspace name on the trigger', () => {
|
||||
const wrapper = mountSwitcher({ orgs: [orgA] })
|
||||
|
||||
expect(wrapper.text()).toContain(orgFixture.name)
|
||||
expect(wrapper.text()).toContain(orgA.name)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fix 5 — dropdown panel structure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('dropdown panel (Fix 5)', () => {
|
||||
it('renders header with Workspaces title and Manage link', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
const head = wrapper.find('.popover-head')
|
||||
|
||||
expect(head.exists()).toBe(true)
|
||||
expect(head.find('.title').text()).toBe('Workspaces')
|
||||
expect(head.find('.link').exists()).toBe(true)
|
||||
expect(head.find('.link').text()).toBe('Manage')
|
||||
})
|
||||
|
||||
it('renders one .opt row per organisation', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
expect(wrapper.findAll('.opt')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders check-mark and is-current only on the current organisation', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
// currentOrganisation defaults to organisations[0] (= orgA) when no
|
||||
// active id is set; the switcher's allOrgs sort puts the current org
|
||||
// first, so .opt[0] is the current row.
|
||||
const opts = wrapper.findAll('.opt')
|
||||
|
||||
expect(opts[0].classes()).toContain('is-current')
|
||||
expect(opts[0].find('.check-mark').exists()).toBe(true)
|
||||
|
||||
expect(opts[1].classes()).not.toContain('is-current')
|
||||
expect(opts[1].find('.check-mark').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders footer with New workspace and Invite buttons', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
const foot = wrapper.find('.foot')
|
||||
|
||||
expect(foot.exists()).toBe(true)
|
||||
|
||||
const buttons = foot.findAll('button')
|
||||
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].text()).toContain('New workspace')
|
||||
expect(buttons[1].text()).toContain('Invite')
|
||||
})
|
||||
|
||||
it('does not render sub lines on any dropdown row (AD-2.5-W1 option A)', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
// No .opt row carries a .sub descendant; no role string leaks into
|
||||
// the dropdown text (org_admin, org_member, event_manager are the
|
||||
// backing roles for the 3-org fixture).
|
||||
expect(wrapper.find('.opt .sub').exists()).toBe(false)
|
||||
|
||||
const listText = wrapper.find('.list').text()
|
||||
|
||||
expect(listText).not.toContain('org_admin')
|
||||
expect(listText).not.toContain('org_member')
|
||||
expect(listText).not.toContain('event_manager')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,158 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBreadcrumbItems, walkNavTree } from '@/composables/useBreadcrumb'
|
||||
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
|
||||
import { walkNavTree } from '@/composables/useBreadcrumb'
|
||||
import type { NavItem } from '@/config/navigation'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const withTitle = (title: string, path: string): BreadcrumbRouteRecord => ({
|
||||
meta: { title },
|
||||
path,
|
||||
})
|
||||
|
||||
const withBreadcrumb = (breadcrumb: string, path: string): BreadcrumbRouteRecord => ({
|
||||
meta: { breadcrumb },
|
||||
path,
|
||||
})
|
||||
|
||||
const withBoth = (title: string, breadcrumb: string, path: string): BreadcrumbRouteRecord => ({
|
||||
meta: { title, breadcrumb },
|
||||
path,
|
||||
})
|
||||
|
||||
const withNeither = (path: string): BreadcrumbRouteRecord => ({
|
||||
meta: {},
|
||||
path,
|
||||
})
|
||||
|
||||
const withNoMeta = (path: string): BreadcrumbRouteRecord => ({
|
||||
path,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('toBreadcrumbItems', () => {
|
||||
it('returns empty array for empty matched', () => {
|
||||
expect(toBreadcrumbItems([])).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out records that have neither meta.title nor meta.breadcrumb', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withNeither('/'),
|
||||
withNoMeta('/events'),
|
||||
withTitle('Events', '/events'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].label).toBe('Events')
|
||||
})
|
||||
|
||||
it('uses meta.breadcrumb as label when set', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withBreadcrumb('Home base', '/'),
|
||||
withBreadcrumb('Events overview', '/events'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items[0].label).toBe('Home base')
|
||||
expect(items[1].label).toBe('Events overview')
|
||||
})
|
||||
|
||||
it('uses meta.title as label when meta.breadcrumb is absent', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withTitle('Dashboard', '/dashboard'),
|
||||
withTitle('Settings', '/settings'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items[0].label).toBe('Dashboard')
|
||||
expect(items[1].label).toBe('Settings')
|
||||
})
|
||||
|
||||
it('prefers meta.breadcrumb over meta.title when both are present', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withBoth('Long Title', 'Short', '/events'),
|
||||
withTitle('Current', '/events/detail'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items[0].label).toBe('Short')
|
||||
})
|
||||
|
||||
it('last item has no `to` (current page)', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withTitle('Events', '/events'),
|
||||
withTitle('Detail', '/events/detail'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[1].to).toBeUndefined()
|
||||
})
|
||||
|
||||
it('non-last items carry a `to` link', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withTitle('Home', '/'),
|
||||
withTitle('Events', '/events'),
|
||||
withTitle('Detail', '/events/detail'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items[0].to).toBe('/')
|
||||
expect(items[1].to).toBe('/events')
|
||||
expect(items[2].to).toBeUndefined()
|
||||
})
|
||||
|
||||
it('single eligible record: label set, no `to`', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
withTitle('Dashboard', '/dashboard'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].label).toBe('Dashboard')
|
||||
expect(items[0].to).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to name-based RouteLocationRaw when path is absent', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
{ meta: { title: 'Home' }, name: 'home' },
|
||||
{ meta: { title: 'Current' }, name: 'current' },
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items[0].to).toEqual({ name: 'home' })
|
||||
expect(items[1].to).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records with no meta at all are filtered out', () => {
|
||||
const matched: BreadcrumbRouteRecord[] = [
|
||||
{ path: '/layout' },
|
||||
withTitle('Dashboard', '/dashboard'),
|
||||
]
|
||||
|
||||
const items = toBreadcrumbItems(matched)
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].label).toBe('Dashboard')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AD-2.5-B1 — walkNavTree (registry-driven breadcrumb)
|
||||
// walkNavTree — registry-driven breadcrumb chain extraction.
|
||||
// Local FIXTURE keeps these specs independent of APP_NAVIGATION content.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -174,7 +25,7 @@ const FIXTURE: NavItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
describe('walkNavTree (AD-2.5-B1)', () => {
|
||||
describe('walkNavTree', () => {
|
||||
it('returns single-entry chain for a top-level leaf', () => {
|
||||
expect(walkNavTree(FIXTURE, 'a')).toEqual([
|
||||
{ label: 'A', routeName: 'a' },
|
||||
|
||||
@@ -1,115 +1,13 @@
|
||||
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 — legacy (meta-based, consumed by AppTopbar pre-P4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single breadcrumb item. `to` is absent for the current (last) item. */
|
||||
/**
|
||||
* Breadcrumb item derived from APP_NAVIGATION. The `routeName` is mapped
|
||||
* at render time to `{ name: routeName }` for RouterLink (see AppBreadcrumb).
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
to?: RouteLocationRaw
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape we need from a matched route record.
|
||||
* Using a structural type keeps the pure helper testable without
|
||||
* requiring full RouteLocationMatched fixtures in unit tests.
|
||||
* RouteLocationMatched is structurally assignable to this interface so
|
||||
* route.matched (RouteLocationMatched[]) passes the type-checker.
|
||||
*/
|
||||
export interface BreadcrumbRouteRecord {
|
||||
meta?: Record<string, unknown> & {
|
||||
breadcrumb?: string
|
||||
title?: string
|
||||
}
|
||||
name?: string | symbol | null
|
||||
path?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helper — testable without a live router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an array of matched route records into breadcrumb items.
|
||||
*
|
||||
* Rules:
|
||||
* - Records without `meta.breadcrumb` OR `meta.title` are filtered out.
|
||||
* - Label = `meta.breadcrumb` if set, else `meta.title`.
|
||||
* - The last item is current — no `to` link.
|
||||
* - All preceding items carry a `to` pointing at their `path` (or `name`
|
||||
* when path is absent).
|
||||
*/
|
||||
export function toBreadcrumbItems(matched: readonly BreadcrumbRouteRecord[]): BreadcrumbItem[] {
|
||||
const eligible = matched.filter(
|
||||
r => r.meta?.breadcrumb !== undefined || r.meta?.title !== undefined,
|
||||
)
|
||||
|
||||
return eligible.map((record, index): BreadcrumbItem => {
|
||||
const label = (record.meta?.breadcrumb ?? record.meta?.title) as string
|
||||
const isLast = index === eligible.length - 1
|
||||
|
||||
if (isLast)
|
||||
return { label }
|
||||
|
||||
// Prefer path; fall back to name when path is absent/empty.
|
||||
// NOTE: a non-last record whose `path` is a param template (e.g. `/events/:id`)
|
||||
// would yield an unresolved-template `to` — acceptable in foundation scope because
|
||||
// param routes are normally the last (current, no-`to`) segment.
|
||||
// TODO TECH-WS-GUI-REDESIGN: resolve param paths if a non-leaf param route ever needs a crumb link
|
||||
const to: RouteLocationRaw
|
||||
= record.path
|
||||
? record.path
|
||||
: { name: record.name as string }
|
||||
|
||||
return { label, to }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable — thin wrapper around useRoute()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseBreadcrumbReturn {
|
||||
items: ComputedRef<BreadcrumbItem[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that derives breadcrumb items from the current route's
|
||||
* `matched` array. Reactive — updates automatically on navigation.
|
||||
*
|
||||
* 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()
|
||||
|
||||
const items = computed<BreadcrumbItem[]>(() =>
|
||||
toBreadcrumbItems(route.matched),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -118,13 +16,13 @@ export interface NavBreadcrumbItem {
|
||||
* 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.
|
||||
* Pure function — unit-testable without router or component mount.
|
||||
*/
|
||||
export function walkNavTree(
|
||||
tree: NavItem[],
|
||||
routeName: string,
|
||||
acc: NavBreadcrumbItem[] = [],
|
||||
): NavBreadcrumbItem[] {
|
||||
acc: BreadcrumbItem[] = [],
|
||||
): BreadcrumbItem[] {
|
||||
for (const node of tree) {
|
||||
const next = [...acc, { label: node.label, routeName: node.routeName }]
|
||||
|
||||
@@ -146,14 +44,14 @@ export function walkNavTree(
|
||||
* 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.
|
||||
* The breadcrumb's single source of truth is APP_NAVIGATION (shared with
|
||||
* SidebarNav); the prior route-meta-driven mechanism was retired in
|
||||
* Plan 2.5 P5.
|
||||
*/
|
||||
export function useNavBreadcrumb(): ComputedRef<NavBreadcrumbItem[]> {
|
||||
export function useBreadcrumb(): ComputedRef<BreadcrumbItem[]> {
|
||||
const route = useRoute()
|
||||
|
||||
return computed<NavBreadcrumbItem[]>(() => {
|
||||
return computed<BreadcrumbItem[]>(() => {
|
||||
if (!route.name)
|
||||
return []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user