feat(gui-v2): port AppTopbar + useBreadcrumb to TypeScript
- useBreadcrumb composable: pure toBreadcrumbItems() helper + thin useRoute() wrapper; route-driven, no prop coupling - AppTopbar: hamburger→setMobileOpen, theme/density toggles→shell store, PrimeVue Breadcrumb/OverlayBadge/Popover/Avatar/Menu; replaces all manual document.mousedown listeners with PrimeVue built-in dismissal; notifications stubbed (useNotificationStore is a toast queue, not a feed — TODO TECH-WS-GUI-REDESIGN); sign-out→authStore.logout() - Unit tests: 10 breadcrumb + 6 AppTopbar assertions (16 total, all pass) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
442
apps/app/src/components-v2/layout/AppTopbar.vue
Normal file
442
apps/app/src/components-v2/layout/AppTopbar.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AppTopbar — production port of crewli-starter AppTopbar.vue.
|
||||
*
|
||||
* 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
|
||||
* - 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
|
||||
* - Theme toggle: shell.setTheme() with flipped value
|
||||
* - Notifications: stubbed with count=0 — useNotificationStore is a toast/snackbar
|
||||
* queue (visible/message/type) not a notification feed; real feed is not
|
||||
* foundation scope.
|
||||
* TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
||||
* - User menu: PrimeVue Menu with #start slot for user header; Sign out → authStore.logout()
|
||||
* - All click-outside: PrimeVue Popover/Menu built-in dismissal (no document.addEventListener)
|
||||
*
|
||||
* Notifications decision (A7): useNotificationStore exposes only
|
||||
* { visible, message, type, timeout, show, hide } — a toast/snackbar queue,
|
||||
* not a persistent notification feed with unread counts or list items.
|
||||
* Decision: STUB at count=0, empty list, with TODO comment above.
|
||||
*
|
||||
* Icon convention: import Icon from '@/components/Icon.vue', <Icon name="tabler-x" :size="N" />
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu from 'primevue/menu'
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const shell = useShellUiStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb — route-driven via useBreadcrumb()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { items: breadcrumbItems } = useBreadcrumb()
|
||||
|
||||
/**
|
||||
* Map BreadcrumbItem[] → PrimeVue MenuItem[].
|
||||
* Non-last items get a `route` for router-link rendering.
|
||||
* Last item (current) has no route/command — plain label only.
|
||||
*/
|
||||
const breadcrumbModel = computed<MenuItem[]>(() =>
|
||||
breadcrumbItems.value.map(item => ({
|
||||
label: item.label,
|
||||
...(item.to !== undefined ? { route: item.to } : {}),
|
||||
})),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User initials derived from full_name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const userInitials = computed<string>(() => {
|
||||
const name = authStore.user?.full_name?.trim() ?? ''
|
||||
|
||||
if (!name)
|
||||
return '?'
|
||||
|
||||
const words = name.split(/\s+/)
|
||||
|
||||
return words.length >= 2
|
||||
? (words[0][0] + words[words.length - 1][0]).toUpperCase()
|
||||
: name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile workspace button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mobileOrgGradient = computed<[string, string]>(() => {
|
||||
const id = authStore.currentOrganisation?.id ?? ''
|
||||
|
||||
return computeOrgGradient(id)
|
||||
})
|
||||
|
||||
const mobileOrgInitials = computed<string>(() => {
|
||||
const name = authStore.currentOrganisation?.name?.trim() ?? ''
|
||||
|
||||
if (!name)
|
||||
return '?'
|
||||
|
||||
const words = name.split(/\s+/)
|
||||
|
||||
return words.length >= 2
|
||||
? (words[0][0] + words[1][0]).toUpperCase()
|
||||
: name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Density toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toggleDensity(): void {
|
||||
shell.setDensity(shell.density === 'comfortable' ? 'compact' : 'comfortable')
|
||||
}
|
||||
|
||||
const densityAriaLabel = computed<string>(() =>
|
||||
shell.density === 'comfortable' ? 'Switch to compact' : 'Switch to comfortable',
|
||||
)
|
||||
|
||||
const densityIcon = computed<string>(() =>
|
||||
shell.density === 'comfortable'
|
||||
? 'tabler-layout-distribute-vertical'
|
||||
: 'tabler-layout-distribute-horizontal',
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toggleTheme(): void {
|
||||
shell.setTheme(shell.theme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const themeAriaLabel = computed<string>(() =>
|
||||
shell.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
|
||||
)
|
||||
|
||||
const themeIcon = computed<string>(() =>
|
||||
shell.theme === 'dark' ? 'tabler-sun' : 'tabler-moon',
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications — STUBBED (see file header for A7 decision)
|
||||
// TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const notifCount = 0
|
||||
const notifPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
function toggleNotifPopover(event: MouseEvent): void {
|
||||
notifPopoverRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User menu — PrimeVue Menu (replaces crewli-starter manual mousedown listener)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const userMenuRef = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function toggleUserMenu(event: MouseEvent): void {
|
||||
userMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
const userMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'tabler-user',
|
||||
command: () => {
|
||||
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Account settings',
|
||||
icon: 'tabler-settings',
|
||||
command: () => {
|
||||
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Workspace settings',
|
||||
icon: 'tabler-adjustments',
|
||||
command: () => {
|
||||
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Keyboard shortcuts',
|
||||
icon: 'tabler-keyboard',
|
||||
command: () => {
|
||||
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Help & support',
|
||||
icon: 'tabler-help',
|
||||
command: () => {
|
||||
// TODO TECH-WS-GUI-REDESIGN: routes not yet defined
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Sign out',
|
||||
icon: 'tabler-logout',
|
||||
command: () => authStore.logout(),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
.topbar: sticky top-0, h-[var(--topbar-h)], flex items-center, gap-3,
|
||||
bg-[var(--surface)], border-b border-[var(--border)], z-30, px-5
|
||||
-->
|
||||
<header class="sticky top-0 z-30 flex h-[var(--topbar-h,56px)] items-center gap-3 border-b border-[var(--p-content-border-color)] bg-[var(--p-content-background)] px-5">
|
||||
<!-- Left group: hamburger + brand + breadcrumb -->
|
||||
<div class="flex items-center gap-[6px]">
|
||||
<!--
|
||||
Hamburger — mobile only (lg:hidden).
|
||||
.hamburger: display none at >=lg, display inline-flex at <768px
|
||||
.icon-btn: 38x38, rounded, transparent bg, fg-muted color, hover bg-hover
|
||||
-->
|
||||
<button
|
||||
class="lg:hidden inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||
aria-label="Open menu"
|
||||
@click="shell.setMobileOpen(true)"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-menu-2"
|
||||
:size="20"
|
||||
/>
|
||||
</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)
|
||||
-->
|
||||
<nav
|
||||
class="hidden lg:flex items-center"
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<Breadcrumb
|
||||
:model="breadcrumbModel"
|
||||
class="border-0 bg-transparent p-0 text-[13px]"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right group: ws-mobile-btn, search, density, theme, notifications, user -->
|
||||
<div class="ms-auto flex items-center gap-[6px]">
|
||||
<!--
|
||||
Mobile workspace button — visible only <lg.
|
||||
.ws-mobile-btn: display none (>= 1024px). On mobile: display flex.
|
||||
38x38, rounded, color #fff, font-bold 12px, gradient bg via inline :style
|
||||
(dynamic hex pair — RFC §7.4). Box-shadow via scoped CSS.
|
||||
-->
|
||||
<button
|
||||
v-if="authStore.currentOrganisation"
|
||||
class="ws-mobile-btn-shadow lg:hidden inline-flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center rounded-[var(--p-border-radius)] border-0 text-[12px] font-bold text-white"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${mobileOrgGradient[0]}, ${mobileOrgGradient[1]})`,
|
||||
}"
|
||||
:aria-label="`Workspace: ${authStore.currentOrganisation.name}`"
|
||||
>
|
||||
{{ mobileOrgInitials }}
|
||||
</button>
|
||||
|
||||
<!--
|
||||
Search — static chrome, no backend.
|
||||
.search: position relative, w-[320px], max-w-full
|
||||
.search input: h-[38px], px-[12px] ps-[36px], bg-surface-alt, border rounded
|
||||
.search .kbd: absolute right-2, text-[11px], px-[6px] py-[2px], border rounded
|
||||
Hidden on smallest viewports per crewli-starter (<768px: width 0 / display:none)
|
||||
-->
|
||||
<div class="relative hidden sm:block w-[240px] lg:w-[320px] max-w-full">
|
||||
<Icon
|
||||
name="tabler-search"
|
||||
:size="16"
|
||||
class="pointer-events-none absolute left-[11px] top-1/2 -translate-y-1/2 text-[var(--p-text-muted-color)]"
|
||||
/>
|
||||
<InputText
|
||||
class="h-[38px] w-full rounded-[var(--p-border-radius)] border border-transparent bg-[var(--p-content-hover-background)] ps-[36px] pe-[52px] text-[var(--p-text-color)] placeholder:text-[var(--p-text-muted-color)] focus:border-[var(--p-primary-color)] focus:bg-[var(--p-content-background)] focus:shadow-[0_0_0_3px_var(--p-primary-50)] focus:outline-none transition-[border-color,background,box-shadow] duration-150"
|
||||
placeholder="Search artists, crew, events..."
|
||||
:pt="{ root: { 'data-search-input': '' } }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-[var(--p-border-radius-sm,4px)] border border-[var(--p-content-border-color)] bg-[var(--p-content-background)] px-[6px] py-[2px] font-mono text-[11px] text-[var(--p-text-muted-color)]">
|
||||
⌘K
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Density toggle.
|
||||
.icon-btn: 38x38, inline-flex, centered, rounded, transparent, fg-muted, hover bg-hover
|
||||
-->
|
||||
<button
|
||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||
:aria-label="densityAriaLabel"
|
||||
@click="toggleDensity"
|
||||
>
|
||||
<Icon
|
||||
:name="densityIcon"
|
||||
:size="18"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Theme toggle. -->
|
||||
<button
|
||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||
:aria-label="themeAriaLabel"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Icon
|
||||
:name="themeIcon"
|
||||
:size="18"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!--
|
||||
Notifications — stubbed bell with OverlayBadge.
|
||||
PrimeVue Popover replaces crewli-starter's manual document.addEventListener('mousedown', …)
|
||||
TODO TECH-WS-GUI-REDESIGN: real notification feed — not foundation scope
|
||||
-->
|
||||
<OverlayBadge
|
||||
:value="notifCount > 0 ? String(notifCount) : undefined"
|
||||
severity="danger"
|
||||
>
|
||||
<button
|
||||
class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1"
|
||||
aria-label="Notifications"
|
||||
@click="toggleNotifPopover"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-bell"
|
||||
:size="18"
|
||||
/>
|
||||
</button>
|
||||
</OverlayBadge>
|
||||
|
||||
<Popover ref="notifPopoverRef">
|
||||
<div class="min-w-[320px] p-4 text-[13px] text-[var(--p-text-muted-color)]">
|
||||
No notifications
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<!--
|
||||
User menu — PrimeVue Avatar + Menu.
|
||||
Avatar: 32x32, rounded-full, initials, gradient bg.
|
||||
Menu: PrimeVue Menu replaces crewli-starter's manual click-outside listener.
|
||||
#start slot used for user info header.
|
||||
-->
|
||||
<Avatar
|
||||
:label="userInitials"
|
||||
shape="circle"
|
||||
class="cursor-pointer"
|
||||
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff;' } }"
|
||||
aria-label="User menu"
|
||||
@click="toggleUserMenu"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
ref="userMenuRef"
|
||||
:model="userMenuItems"
|
||||
popup
|
||||
>
|
||||
<template #start>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)]">
|
||||
<Avatar
|
||||
:label="userInitials"
|
||||
shape="circle"
|
||||
:pt="{ root: { style: 'background: linear-gradient(135deg, #f472b6, var(--p-primary-500, #0d9488)); color: #fff; width:40px; height:40px; font-size:14px;' } }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
||||
{{ authStore.user?.full_name ?? '' }}
|
||||
</div>
|
||||
<div class="truncate text-[12px] text-[var(--p-text-muted-color)]">
|
||||
{{ authStore.user?.email ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
||||
}
|
||||
</style>
|
||||
224
apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts
Normal file
224
apps/app/src/components-v2/layout/__tests__/AppTopbar.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* AppTopbar.spec.ts
|
||||
*
|
||||
* Strategy: mount with createPinia(); stub all PrimeVue components so we
|
||||
* test only the wiring of store actions to user interactions.
|
||||
*
|
||||
* Assertions:
|
||||
* 1. Hamburger click → shell.setMobileOpen(true)
|
||||
* 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
|
||||
*
|
||||
* useBreadcrumb() calls useRoute() internally. We provide a minimal
|
||||
* vue-router mock via vi.mock so the composable has a route to call.
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock vue-router (useBreadcrumb calls useRoute internally)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
matched: [],
|
||||
}),
|
||||
useRouter: () => ({}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import component AFTER mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line import/first -- intentional: mocks must precede import
|
||||
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stubs for PrimeVue components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const globalStubs = {
|
||||
Breadcrumb: { name: 'Breadcrumb', props: ['model'], template: '<nav class="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: {
|
||||
name: 'Popover',
|
||||
template: '<div class="popover-stub"><slot /></div>',
|
||||
methods: { toggle: vi.fn(), hide: vi.fn() },
|
||||
},
|
||||
Avatar: {
|
||||
name: 'Avatar',
|
||||
props: ['label', 'shape', 'pt'],
|
||||
template: '<div class="avatar-stub" @click="$emit(\'click\', $event)">{{ label }}</div>',
|
||||
emits: ['click'],
|
||||
},
|
||||
Menu: {
|
||||
name: 'Menu',
|
||||
props: ['model', 'popup'],
|
||||
template: '<div class="menu-stub"><slot name="start" /></div>',
|
||||
methods: { toggle: vi.fn(), hide: vi.fn() },
|
||||
},
|
||||
Icon: { name: 'Icon', props: ['name', 'size'], template: '<span class="icon-stub" :data-name="name" />' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mountTopbar() {
|
||||
return mount(AppTopbar, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: globalStubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AppTopbar', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Hamburger → setMobileOpen(true)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('hamburger button click calls shell.setMobileOpen(true)', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const shell = useShellUiStore()
|
||||
const spy = vi.spyOn(shell, 'setMobileOpen')
|
||||
|
||||
// The hamburger is the first button (aria-label="Open menu")
|
||||
const hamburger = wrapper.find('button[aria-label="Open menu"]')
|
||||
|
||||
expect(hamburger.exists()).toBe(true)
|
||||
|
||||
await hamburger.trigger('click')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Theme toggle → setTheme with flipped value
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('theme toggle flips light → dark', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.theme = 'light'
|
||||
|
||||
const spy = vi.spyOn(shell, 'setTheme')
|
||||
|
||||
const themeBtn = wrapper.find('button[aria-label="Switch to dark mode"]')
|
||||
|
||||
expect(themeBtn.exists()).toBe(true)
|
||||
|
||||
await themeBtn.trigger('click')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
|
||||
it('theme toggle flips dark → light', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.theme = 'dark'
|
||||
|
||||
const spy = vi.spyOn(shell, 'setTheme')
|
||||
|
||||
// After setting dark we need nextTick so the aria-label computed updates
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const themeBtn = wrapper.find('button[aria-label="Switch to light mode"]')
|
||||
|
||||
expect(themeBtn.exists()).toBe(true)
|
||||
|
||||
await themeBtn.trigger('click')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('light')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Density toggle → setDensity with flipped value
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('density toggle flips comfortable → compact', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.density = 'comfortable'
|
||||
|
||||
const spy = vi.spyOn(shell, 'setDensity')
|
||||
|
||||
const densityBtn = wrapper.find('button[aria-label="Switch to compact"]')
|
||||
|
||||
expect(densityBtn.exists()).toBe(true)
|
||||
|
||||
await densityBtn.trigger('click')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('compact')
|
||||
})
|
||||
|
||||
it('density toggle flips compact → comfortable', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.density = 'compact'
|
||||
|
||||
const spy = vi.spyOn(shell, 'setDensity')
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const densityBtn = wrapper.find('button[aria-label="Switch to comfortable"]')
|
||||
|
||||
expect(densityBtn.exists()).toBe(true)
|
||||
|
||||
await densityBtn.trigger('click')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('comfortable')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sign out → authStore.logout()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('Sign out menu item command calls authStore.logout()', async () => {
|
||||
const wrapper = mountTopbar()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Spy on logout — stub the async to resolve immediately
|
||||
const logoutSpy = vi.spyOn(authStore, 'logout').mockResolvedValue()
|
||||
|
||||
// Access the computed userMenuItems exposed on the component's vm
|
||||
// The menu items are passed as :model to the stubbed Menu component.
|
||||
// We need to find the Sign out item and call its command directly.
|
||||
const menuStub = wrapper.findComponent({ name: 'Menu' })
|
||||
|
||||
expect(menuStub.exists()).toBe(true)
|
||||
|
||||
// The model prop is the computed userMenuItems array
|
||||
const model = menuStub.props('model') as Array<{ items: Array<{ label: string; command?: () => void }> }>
|
||||
|
||||
// Find Sign out in the nested items groups
|
||||
const signOutItem = model
|
||||
.flatMap(group => group.items ?? [])
|
||||
.find(item => item.label === 'Sign out')
|
||||
|
||||
expect(signOutItem).toBeDefined()
|
||||
expect(typeof signOutItem?.command).toBe('function')
|
||||
|
||||
signOutItem?.command?.()
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
151
apps/app/src/composables/__tests__/useBreadcrumb.spec.ts
Normal file
151
apps/app/src/composables/__tests__/useBreadcrumb.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBreadcrumbItems } from '@/composables/useBreadcrumb'
|
||||
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
91
apps/app/src/composables/useBreadcrumb.ts
Normal file
91
apps/app/src/composables/useBreadcrumb.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single breadcrumb item. `to` is absent for the current (last) item. */
|
||||
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
|
||||
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.
|
||||
*
|
||||
* Composables zone may NOT import from @/navigation — do not add
|
||||
* route-level nav helpers here.
|
||||
*/
|
||||
export function useBreadcrumb(): UseBreadcrumbReturn {
|
||||
const route = useRoute()
|
||||
|
||||
const items = computed<BreadcrumbItem[]>(() =>
|
||||
toBreadcrumbItems(route.matched),
|
||||
)
|
||||
|
||||
return { items }
|
||||
}
|
||||
Reference in New Issue
Block a user