feat(gui-v2): decompose AppSidebar into SidebarHeader + AppSidebar
Ports crewli-starter's monolithic AppSidebar.vue into two typed production components: SidebarHeader (the .brand block) and AppSidebar (composing SidebarHeader + SidebarNav + WorkspaceSwitcher). AppSidebar renders a permanent <aside> on desktop (lg+) and a PrimeVue Drawer on mobile, both wired to useShellUiStore for collapse/mobile state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
167
apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
Normal file
167
apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* AppSidebar.spec.ts — unit tests for AppSidebar composition and mobile wiring.
|
||||
*
|
||||
* Strategy: mount with @vue/test-utils stubs for all heavy children (SidebarHeader,
|
||||
* SidebarNav, WorkspaceSwitcher, Drawer) so we test only:
|
||||
* 1. Renders the 3 child components (SidebarHeader, SidebarNav, WorkspaceSwitcher).
|
||||
* 2. Passes `groups` prop to SidebarNav.
|
||||
* 3. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
|
||||
* 4. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
|
||||
*
|
||||
* Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
|
||||
* whether its `visible` prop is correctly bound to the store.
|
||||
*/
|
||||
|
||||
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 AppSidebar from '@/components-v2/layout/AppSidebar.vue'
|
||||
import type { V2NavGroup } from '@/types/v2/nav'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A minimal nav group fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testGroups: V2NavGroup[] = [
|
||||
{
|
||||
label: 'Main',
|
||||
items: [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-home', to: { name: 'dashboard' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stubs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DrawerStub exposes the same `visible` prop as PrimeVue Drawer and renders
|
||||
* its default slot so child components are mounted. It also emits
|
||||
* `update:visible` when the close method would fire, which mirrors the
|
||||
* v-model:visible contract.
|
||||
*/
|
||||
const DrawerStub = {
|
||||
name: 'Drawer',
|
||||
props: ['visible', 'position', 'pt'],
|
||||
emits: ['update:visible'],
|
||||
template: '<div class="drawer-stub" :data-visible="visible"><slot /></div>',
|
||||
}
|
||||
|
||||
const globalStubs = {
|
||||
Drawer: DrawerStub,
|
||||
SidebarHeader: { name: 'SidebarHeader', template: '<div class="sidebar-header-stub" />' },
|
||||
SidebarNav: {
|
||||
name: 'SidebarNav',
|
||||
props: ['groups', 'collapsed'],
|
||||
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" :data-groups-count="groups.length" />',
|
||||
},
|
||||
WorkspaceSwitcher: {
|
||||
name: 'WorkspaceSwitcher',
|
||||
props: ['collapsed'],
|
||||
template: '<div class="workspace-switcher-stub" />',
|
||||
},
|
||||
}
|
||||
|
||||
function mountSidebar(groups: V2NavGroup[] = testGroups) {
|
||||
return mount(AppSidebar, {
|
||||
props: { groups },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
stubs: globalStubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Child composition
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders SidebarHeader', () => {
|
||||
const wrapper = mountSidebar()
|
||||
|
||||
expect(wrapper.find('.sidebar-header-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders SidebarNav', () => {
|
||||
const wrapper = mountSidebar()
|
||||
|
||||
expect(wrapper.find('.sidebar-nav-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders WorkspaceSwitcher', () => {
|
||||
const wrapper = mountSidebar()
|
||||
|
||||
expect(wrapper.find('.workspace-switcher-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('passes groups prop to SidebarNav', () => {
|
||||
const wrapper = mountSidebar()
|
||||
const nav = wrapper.find('.sidebar-nav-stub')
|
||||
|
||||
// Our stub renders data-groups-count from the groups prop
|
||||
expect(nav.attributes('data-groups-count')).toBe(String(testGroups.length))
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mobile Drawer wiring
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('Drawer visible is true when shell.mobileOpen is true', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.mobileOpen = true
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// The Drawer stub renders data-visible from its visible prop
|
||||
const drawer = wrapper.find('.drawer-stub')
|
||||
|
||||
expect(drawer.attributes('data-visible')).toBe('true')
|
||||
})
|
||||
|
||||
it('Drawer visible is false when shell.mobileOpen is false', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.mobileOpen = false
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const drawer = wrapper.find('.drawer-stub')
|
||||
|
||||
expect(drawer.attributes('data-visible')).toBe('false')
|
||||
})
|
||||
|
||||
it('emitting update:visible false from Drawer calls shell.setMobileOpen(false)', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.mobileOpen = true
|
||||
|
||||
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
|
||||
|
||||
// Simulate the Drawer closing (v-model:visible setter)
|
||||
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false)
|
||||
|
||||
expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('emitting update:visible true from Drawer calls shell.setMobileOpen(true)', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.mobileOpen = false
|
||||
|
||||
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
|
||||
|
||||
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', true)
|
||||
|
||||
expect(setMobileOpenSpy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user