+
+
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,
}
}