diff --git a/apps/app/src/components-v2/layout/RightDrawer.vue b/apps/app/src/components-v2/layout/RightDrawer.vue new file mode 100644 index 00000000..8f3feee9 --- /dev/null +++ b/apps/app/src/components-v2/layout/RightDrawer.vue @@ -0,0 +1,162 @@ + + + diff --git a/apps/app/src/components-v2/layout/__tests__/RightDrawer.spec.ts b/apps/app/src/components-v2/layout/__tests__/RightDrawer.spec.ts new file mode 100644 index 00000000..045bc758 --- /dev/null +++ b/apps/app/src/components-v2/layout/__tests__/RightDrawer.spec.ts @@ -0,0 +1,229 @@ +/** + * RightDrawer.spec.ts — unit tests for the global right-side drawer shell. + * + * Strategy: mount RightDrawer with @vue/test-utils, stub PrimeVue + * so we avoid the full overlay/teleport machinery in jsdom. The stub exposes + * the same `visible` prop and emits `update:visible` so we can test the + * writable-computed v-model:visible wiring against the real Pinia store. + * + * What is tested (real store/composable, not stub theater): + * 1. Drawer visible when store drawer is open, title renders. + * 2. Emitting update:visible=false closes the store drawer. + * 3. flush:true in drawerProps → body has p-0 class (no padding). + * 4. flush:false/absent → body has p-4 class (padded). + * 5. Unknown component name (nothing registered) → graceful empty state, no throw. + * 6. Registered stub component renders in body; non-chrome props forwarded. + * + * PrimeVue Drawer stub: mirrors only the contract surface used by RightDrawer — + * `visible` prop + `update:visible` emit + default slot passthrough. + * + * Icon is stubbed to a simple span so jsdom doesn't choke on Iconify SVG fetch. + */ + +import { createPinia, setActivePinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import { registerDrawerComponent } from '@/composables/drawerRegistry' +import { useShellUiStore } from '@/stores/useShellUiStore' +import RightDrawer from '@/components-v2/layout/RightDrawer.vue' + +// --------------------------------------------------------------------------- +// Unique prefix per run — drawerRegistry is a module singleton; avoid leakage. +// --------------------------------------------------------------------------- + +const PREFIX = `rd-spec-${Date.now()}-` + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +/** + * DrawerStub mirrors the PrimeVue Drawer contract used by RightDrawer: + * - `visible` prop (Boolean) + * - emits `update:visible` on close (v-model:visible contract) + * - renders its default slot so header/body children are mounted + */ +const DrawerStub = defineComponent({ + name: 'Drawer', + props: { + visible: { type: Boolean, default: false }, + position: { type: String, default: 'right' }, + pt: { type: Object, default: () => ({}) }, + }, + emits: ['update:visible'], + template: ` +
+ +
+ `, +}) + +/** + * IconStub — prevents Iconify SVG lookups in jsdom. + */ +const IconStub = defineComponent({ + name: 'Icon', + props: ['name', 'size'], + template: '', +}) + +// --------------------------------------------------------------------------- +// Mount helper +// --------------------------------------------------------------------------- + +function mountDrawer() { + return mount(RightDrawer, { + global: { + plugins: [createPinia()], + stubs: { + Drawer: DrawerStub, + Icon: IconStub, + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RightDrawer', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + // ------------------------------------------------------------------------- + // 1. Store open → Drawer visible, title renders + // ------------------------------------------------------------------------- + + it('renders Drawer as visible when store drawer is open', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer('SomePanel', { title: 'Hello' }) + await wrapper.vm.$nextTick() + + const drawer = wrapper.find('.drawer-stub') + + expect(drawer.attributes('data-visible')).toBe('true') + }) + + it('renders the title from drawerProps', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer('SomePanel', { title: 'Hello' }) + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Hello') + }) + + it('Drawer is not visible when store drawer is closed', async () => { + const wrapper = mountDrawer() + + // store starts closed by default + + await wrapper.vm.$nextTick() + + const drawer = wrapper.find('.drawer-stub') + + expect(drawer.attributes('data-visible')).toBe('false') + }) + + // ------------------------------------------------------------------------- + // 2. Emitting update:visible=false → store drawer closed + // ------------------------------------------------------------------------- + + it('emitting update:visible=false closes the store drawer', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer('SomePanel', { title: 'Test' }) + await wrapper.vm.$nextTick() + + // Simulate PrimeVue Drawer's v-model:visible setter called with false + // (e.g. user clicks overlay or presses Escape) + await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false) + await wrapper.vm.$nextTick() + + expect(shell.drawer.isOpen).toBe(false) + }) + + // ------------------------------------------------------------------------- + // 3 & 4. flush prop → padding class + // ------------------------------------------------------------------------- + + it('body has p-0 class when flush is true', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer('SomePanel', { title: 'Flush', flush: true }) + await wrapper.vm.$nextTick() + + // The body div is the scrollable container (class includes flex-1 overflow-y-auto) + const bodyDiv = wrapper.find('.flex-1.overflow-y-auto') + + expect(bodyDiv.classes()).toContain('p-0') + expect(bodyDiv.classes()).not.toContain('p-4') + }) + + it('body has p-4 class when flush is false or absent', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer('SomePanel', { title: 'Padded' }) + await wrapper.vm.$nextTick() + + const bodyDiv = wrapper.find('.flex-1.overflow-y-auto') + + expect(bodyDiv.classes()).toContain('p-4') + expect(bodyDiv.classes()).not.toContain('p-0') + }) + + // ------------------------------------------------------------------------- + // 5. Unknown component name → graceful empty state, no throw + // ------------------------------------------------------------------------- + + it('renders graceful empty state for an unregistered component name', async () => { + const wrapper = mountDrawer() + const shell = useShellUiStore() + + shell.openDrawer(`${PREFIX}NotRegistered`, { title: 'Missing' }) + await wrapper.vm.$nextTick() + + // Should show the empty state text, not throw + expect(wrapper.text()).toContain('Geen inhoud') + }) + + // ------------------------------------------------------------------------- + // 6. Registered component renders; non-chrome props forwarded + // ------------------------------------------------------------------------- + + it('renders a registered body component and forwards non-chrome props', async () => { + const BodyStub = defineComponent({ + props: { someId: { type: String, default: '' } }, + template: '
', + }) + + const name = `${PREFIX}BodyStub` + + registerDrawerComponent(name, BodyStub) + + const wrapper = mountDrawer() + const shell = useShellUiStore() + + // `title` and `flush` are chrome keys — they must NOT reach BodyStub. + // `someId` is a body prop — it MUST be forwarded. + shell.openDrawer(name, { title: 'With Body', flush: false, someId: 'abc-123' }) + await wrapper.vm.$nextTick() + + const body = wrapper.find('.body-stub') + + expect(body.exists()).toBe(true) + expect(body.attributes('data-id')).toBe('abc-123') + + // Graceful-empty-state text must NOT appear when a component is rendered + expect(wrapper.text()).not.toContain('Geen inhoud') + }) +}) diff --git a/apps/app/src/composables/__tests__/drawerRegistry.spec.ts b/apps/app/src/composables/__tests__/drawerRegistry.spec.ts new file mode 100644 index 00000000..d1bd2b18 --- /dev/null +++ b/apps/app/src/composables/__tests__/drawerRegistry.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import { registerDrawerComponent, resolveDrawerComponent } from '@/composables/drawerRegistry' + +// Minimal component stubs — no template needed for registry identity checks. +const StubA = defineComponent({ template: '
' }) +const StubB = defineComponent({ template: '
' }) + +// The registry is a module singleton. Each test registers its own names so +// tests remain independent without needing a reset hook. +describe('drawerRegistry', () => { + const PREFIX = `test-${Date.now()}-` // unique per run to avoid cross-test leakage + + it('resolves null for an unregistered name', () => { + expect(resolveDrawerComponent(`${PREFIX}unknown`)).toBeNull() + }) + + it('resolves null for null input', () => { + expect(resolveDrawerComponent(null)).toBeNull() + }) + + it('resolves null for undefined input', () => { + expect(resolveDrawerComponent(undefined)).toBeNull() + }) + + it('resolves null for empty string', () => { + expect(resolveDrawerComponent('')).toBeNull() + }) + + it('returns the registered component', () => { + registerDrawerComponent(`${PREFIX}StubA`, StubA) + expect(resolveDrawerComponent(`${PREFIX}StubA`)).toBe(StubA) + }) + + it('re-registering overwrites the previous component', () => { + const name = `${PREFIX}overwrite` + + registerDrawerComponent(name, StubA) + registerDrawerComponent(name, StubB) + expect(resolveDrawerComponent(name)).toBe(StubB) + }) + + it('independent names do not collide', () => { + registerDrawerComponent(`${PREFIX}A`, StubA) + registerDrawerComponent(`${PREFIX}B`, StubB) + expect(resolveDrawerComponent(`${PREFIX}A`)).toBe(StubA) + expect(resolveDrawerComponent(`${PREFIX}B`)).toBe(StubB) + }) +}) diff --git a/apps/app/src/composables/__tests__/useRightDrawer.spec.ts b/apps/app/src/composables/__tests__/useRightDrawer.spec.ts index a0cbafd2..8c5dcb5e 100644 --- a/apps/app/src/composables/__tests__/useRightDrawer.spec.ts +++ b/apps/app/src/composables/__tests__/useRightDrawer.spec.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' import { createPinia, setActivePinia } from 'pinia' +import { registerDrawerComponent } from '@/composables/drawerRegistry' import { useRightDrawer } from '@/composables/useRightDrawer' import { useShellUiStore } from '@/stores/useShellUiStore' +// Unique prefix per run to avoid cross-test leakage from the module singleton registry. +const PREFIX = `rdr-spec-${Date.now()}-` + describe('useRightDrawer', () => { beforeEach(() => setActivePinia(createPinia())) @@ -24,4 +29,16 @@ describe('useRightDrawer', () => { expect(isOpen.value).toBe(false) expect(useShellUiStore().drawer.isOpen).toBe(false) }) + + it('resolveDrawerComponent delegates to the registry', () => { + const StubComponent = defineComponent({ template: '
' }) + const name = `${PREFIX}StubComponent` + + registerDrawerComponent(name, StubComponent) + + const { resolveDrawerComponent } = useRightDrawer() + + expect(resolveDrawerComponent(name)).toBe(StubComponent) + expect(resolveDrawerComponent(`${PREFIX}unknown`)).toBeNull() + }) }) diff --git a/apps/app/src/composables/drawerRegistry.ts b/apps/app/src/composables/drawerRegistry.ts new file mode 100644 index 00000000..f7f61f6d --- /dev/null +++ b/apps/app/src/composables/drawerRegistry.ts @@ -0,0 +1,32 @@ +import type { Component } from 'vue' + +// Module-private registry map. No static component imports — callers +// register their own components via registerDrawerComponent() from a zone +// that is allowed to import components (components-v2, layouts, pages-v2, +// etc.). This keeps drawerRegistry inside the `composables` boundary zone +// which may NOT import any component zone (RFC-WS-GUI-REDESIGN AD-G5, +// ESLint boundaries matrix). The registry ships empty; real drawer-body +// components register themselves after mount from their own feature zone. +const registry = new Map() + +/** + * Register a drawer-body component under a string name. + * Calling again with the same name overwrites the previous registration. + * Call this from the component's owning feature zone (components-v2, layouts, + * pages-v2, …) — never from inside composables/drawerRegistry.ts itself. + */ +export function registerDrawerComponent(name: string, component: Component): void { + registry.set(name, component) +} + +/** + * Resolve a previously registered component by name. + * Returns null for unknown names, empty strings, null, or undefined — + * callers must guard the null return (e.g. render a graceful empty state). + */ +export function resolveDrawerComponent(name: string | null | undefined): Component | null { + if (!name) + return null + + return registry.get(name) ?? null +} diff --git a/apps/app/src/composables/useRightDrawer.ts b/apps/app/src/composables/useRightDrawer.ts index b15889be..bbe0ddd6 100644 --- a/apps/app/src/composables/useRightDrawer.ts +++ b/apps/app/src/composables/useRightDrawer.ts @@ -1,6 +1,7 @@ import { storeToRefs } from 'pinia' -import type { ComputedRef } from 'vue' +import type { Component, ComputedRef } from 'vue' import { computed } from 'vue' +import { resolveDrawerComponent } from '@/composables/drawerRegistry' import { useShellUiStore } from '@/stores/useShellUiStore' // Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4, @@ -14,6 +15,7 @@ export interface UseRightDrawer { props: ComputedRef> open: (component: string, props?: Record) => void close: () => void + resolveDrawerComponent: (name: string | null | undefined) => Component | null } export function useRightDrawer(): UseRightDrawer { @@ -26,5 +28,6 @@ export function useRightDrawer(): UseRightDrawer { props: computed(() => drawer.value.props), open: (component, props = {}) => store.openDrawer(component, props), close: () => store.closeDrawer(), + resolveDrawerComponent, } }