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>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
* 5. AppBreadcrumb is rendered in the #start slot (Plan 2.5 P5 Fix 2).
|
|
*
|
|
* 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'
|
|
import { mount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useShellUiStore } from '@/stores/useShellUiStore'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import AppTopbar from '@/components-v2/layout/AppTopbar.vue'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('vue-router', () => ({
|
|
useRoute: () => ({ name: 'v2-dashboard' }),
|
|
useRouter: () => ({ push: vi.fn() }),
|
|
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stubs for PrimeVue components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* MenubarStub — mirrors the PrimeVue Menubar slot contract used by AppTopbar
|
|
* (RFC AD-3). Renders #start and #end named slots so [data-tb="breadcrumb"]
|
|
* and [data-tb="user"] are inspectable inside the stub.
|
|
*/
|
|
const MenubarStub = {
|
|
name: 'Menubar',
|
|
props: ['model', 'pt'],
|
|
template: '<div class="menubar-stub"><slot name="start" /><slot name="end" /></div>',
|
|
}
|
|
|
|
const globalStubs = {
|
|
Menubar: MenubarStub,
|
|
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: {
|
|
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()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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('renders <AppBreadcrumb /> inside the Menubar #start slot', () => {
|
|
const wrapper = mountTopbar()
|
|
const bar = wrapper.findComponent(MenubarStub)
|
|
|
|
expect(bar.find('[data-testid="app-breadcrumb-stub"]').exists()).toBe(true)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// RFC AD-3: AppTopbar wraps PrimeVue Menubar as chrome root
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders the top bar inside a PrimeVue Menubar (RFC AD-3)', () => {
|
|
const w = mountTopbar()
|
|
|
|
expect(w.findComponent(MenubarStub).exists()).toBe(true)
|
|
})
|
|
|
|
it('breadcrumb lives in Menubar #start, user cluster in #end; search wrapper exists and mobile-ws-btn is NOT its descendant', async () => {
|
|
// Mount first so the component's pinia plugin is active, then get the
|
|
// store instance from that same pinia context.
|
|
const w = mountTopbar()
|
|
const bar = w.findComponent(MenubarStub)
|
|
|
|
expect(bar.find('[data-tb="breadcrumb"]').exists()).toBe(true)
|
|
expect(bar.find('[data-tb="user"]').exists()).toBe(true)
|
|
|
|
// AD-3 regression lock: data-tb="search" must exist as a direct #end sibling
|
|
expect(bar.find('[data-tb="search"]').exists()).toBe(true)
|
|
|
|
// Seed authStore with an organisation so authStore.currentOrganisation resolves
|
|
// (it is a computed derived from organisations[0]). setUser() is the public API
|
|
// that populates the organisations array.
|
|
// Must call useAuthStore() AFTER mountTopbar() so we use the same pinia instance.
|
|
const authStore = useAuthStore()
|
|
|
|
authStore.setUser({
|
|
id: 'u1',
|
|
first_name: 'Test',
|
|
last_name: 'User',
|
|
full_name: 'Test User',
|
|
date_of_birth: null,
|
|
email: 'test@example.com',
|
|
phone: null,
|
|
timezone: 'UTC',
|
|
locale: 'en',
|
|
avatar: null,
|
|
organisations: [{ id: 'org-1', name: 'Test Org', slug: 'test-org', role: 'org_admin' }],
|
|
app_roles: [],
|
|
permissions: [],
|
|
})
|
|
|
|
await w.vm.$nextTick()
|
|
|
|
const wsBtnSelector = 'button[aria-label^="Workspace:"]'
|
|
|
|
// The mobile workspace button must exist in the bar (currentOrganisation is set).
|
|
expect(bar.find(wsBtnSelector).exists()).toBe(true)
|
|
|
|
// The mobile workspace button must NOT be a descendant of data-tb="search".
|
|
// In the corrected structure it is a free sibling in the #end flex row.
|
|
expect(bar.find('[data-tb="search"]').find(wsBtnSelector).exists()).toBe(false)
|
|
})
|
|
})
|