feat(gui-v2): drawer registry + port RightDrawer to TypeScript
Adds composables/drawerRegistry.ts (boundary-safe register-by-call map: register/resolve, zero static component imports — composables zone may not import components, RFC-WS-GUI-REDESIGN AD-G5). Extends useRightDrawer with resolveDrawerComponent (thin facade, prior API/tests preserved). RightDrawer.vue: PrimeVue <Drawer position=right>, v-model:visible via a writable computed ↔ useRightDrawer isOpen/close; title/flush read from the open() props object (A4); dynamic <component :is> via resolveDrawerComponent with a graceful empty state on null; #actions header slot retained. 18 unit/component tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
162
apps/app/src/components-v2/layout/RightDrawer.vue
Normal file
162
apps/app/src/components-v2/layout/RightDrawer.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* RightDrawer — the global right-side drawer shell driven entirely by
|
||||||
|
* `useRightDrawer()` / `useShellUiStore().drawer`. No open/title/flush props
|
||||||
|
* (Decision A4): the caller passes chrome keys (`title`, `flush`) inside the
|
||||||
|
* `props` object when calling `useRightDrawer().open('Name', { title, flush, ...bodyProps })`.
|
||||||
|
*
|
||||||
|
* PrimeVue <Drawer position="right"> provides the scrim, escape-key dismiss,
|
||||||
|
* overlay-click close, and focus-trap. The writable computed `drawerVisible`
|
||||||
|
* wires v-model:visible to useRightDrawer().isOpen / .close() without
|
||||||
|
* mutating the store ref directly.
|
||||||
|
*
|
||||||
|
* Body component lifecycle:
|
||||||
|
* - `component.value` (string | null) is resolved via resolveDrawerComponent().
|
||||||
|
* - Unknown / unregistered names → null → graceful empty state (no crash).
|
||||||
|
* This is the expected state in the foundation scope; real drawer-body
|
||||||
|
* components register themselves via registerDrawerComponent() from their
|
||||||
|
* own feature zone (components-v2, pages-v2, etc.) at mount time.
|
||||||
|
* - Chrome keys `title` and `flush` are stripped from `bodyProps` and NOT
|
||||||
|
* forwarded to the body component — they are consumed at the shell level only.
|
||||||
|
*
|
||||||
|
* #actions slot:
|
||||||
|
* Parent layouts may inject header action buttons (e.g. an edit icon) via the
|
||||||
|
* named `#actions` slot. In the store-driven model this slot is typically unused,
|
||||||
|
* but it is kept for composed layouts that wrap <RightDrawer> and need to add
|
||||||
|
* persistent header controls without duplicating the chrome.
|
||||||
|
*
|
||||||
|
* CSS translation (crewli-starter main.css → Tailwind):
|
||||||
|
* .drawer → handled by PrimeVue Drawer + :pt passthrough
|
||||||
|
* .drawer-head → flex items-center gap-3 px-4 py-3 border-b ...
|
||||||
|
* .drawer-head .title → flex-1 font-medium text-sm truncate
|
||||||
|
* .drawer-head .actions → ms-auto flex items-center gap-1
|
||||||
|
* .drawer-body (flush) → p-0 (edge-to-edge)
|
||||||
|
* .drawer-body (normal)→ p-4 overflow-y-auto
|
||||||
|
* .icon-btn → rounded-md p-1 hover:bg-[var(--p-content-hover-background)]
|
||||||
|
* transition-colors flex items-center justify-center
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Drawer from 'primevue/drawer'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { useRightDrawer } from '@/composables/useRightDrawer'
|
||||||
|
|
||||||
|
const { isOpen, component, props, close, resolveDrawerComponent } = useRightDrawer()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writable computed so PrimeVue Drawer can use v-model:visible without
|
||||||
|
* directly mutating the Pinia store ref.
|
||||||
|
*/
|
||||||
|
const drawerVisible = computed<boolean>({
|
||||||
|
get: () => isOpen.value,
|
||||||
|
set: (v: boolean) => {
|
||||||
|
if (!v)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome keys read from drawerProps WITHOUT using `any`.
|
||||||
|
* `typeof` narrowing instead of a cast keeps TypeScript strict.
|
||||||
|
*/
|
||||||
|
const title = computed(() =>
|
||||||
|
typeof props.value.title === 'string' ? props.value.title : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const flush = computed(() => props.value.flush === true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body props: `props.value` minus the chrome keys `title` and `flush`.
|
||||||
|
* These are NOT forwarded to the body component — they are consumed by the
|
||||||
|
* drawer shell. All other keys are passed through as-is.
|
||||||
|
*
|
||||||
|
* Key-filter approach avoids unused-variable linting errors from destructuring
|
||||||
|
* (the project's varsIgnorePattern only allows purely-underscore names).
|
||||||
|
*/
|
||||||
|
const CHROME_KEYS = new Set(['title', 'flush'])
|
||||||
|
|
||||||
|
const bodyProps = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(props.value).filter(([k]) => !CHROME_KEYS.has(k)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved body component from the registry. Null when the component name is
|
||||||
|
* unregistered (expected in foundation scope) — the template renders a graceful
|
||||||
|
* empty state in that case.
|
||||||
|
*/
|
||||||
|
const Body = computed(() => resolveDrawerComponent(component.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer
|
||||||
|
v-model:visible="drawerVisible"
|
||||||
|
position="right"
|
||||||
|
:pt="{
|
||||||
|
/*
|
||||||
|
* Strip PrimeVue's default header so we render our own with the
|
||||||
|
* close button, title, and #actions slot in a single controlled row.
|
||||||
|
* content: remove default padding so body region controls its own spacing.
|
||||||
|
*/
|
||||||
|
header: { class: 'hidden' },
|
||||||
|
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Drawer header: close button + title + optional #actions slot -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)] flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
class="rounded-md p-1 hover:bg-[var(--p-content-hover-background)] transition-colors flex items-center justify-center"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="tabler-x"
|
||||||
|
:size="18"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1 font-medium text-sm truncate">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
#actions slot — parent layouts may inject header action buttons here.
|
||||||
|
In the pure store-driven model this slot is typically empty.
|
||||||
|
-->
|
||||||
|
<div class="ms-auto flex items-center gap-1">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer body: dynamic component or graceful empty state -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-y-auto"
|
||||||
|
:class="flush ? 'p-0' : 'p-4'"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Body is rendered only when a registered component is resolved.
|
||||||
|
When Body.value is null (unknown / not-yet-registered name), we render
|
||||||
|
a minimal empty state — this must NOT crash on null (resolveDrawerComponent
|
||||||
|
guarantees null for unknown names).
|
||||||
|
-->
|
||||||
|
<component
|
||||||
|
:is="Body"
|
||||||
|
v-if="Body !== null"
|
||||||
|
v-bind="bodyProps"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Graceful empty state — shown when the component name is unregistered.
|
||||||
|
Expected in foundation scope; body components self-register at mount.
|
||||||
|
-->
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-sm text-[var(--p-text-muted-color)]"
|
||||||
|
>
|
||||||
|
Geen inhoud
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
229
apps/app/src/components-v2/layout/__tests__/RightDrawer.spec.ts
Normal file
229
apps/app/src/components-v2/layout/__tests__/RightDrawer.spec.ts
Normal file
@@ -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 <Drawer>
|
||||||
|
* 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: `
|
||||||
|
<div class="drawer-stub" :data-visible="visible">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IconStub — prevents Iconify SVG lookups in jsdom.
|
||||||
|
*/
|
||||||
|
const IconStub = defineComponent({
|
||||||
|
name: 'Icon',
|
||||||
|
props: ['name', 'size'],
|
||||||
|
template: '<span class="icon-stub" :data-icon="name" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: '<div class="body-stub" :data-id="someId" />',
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
49
apps/app/src/composables/__tests__/drawerRegistry.spec.ts
Normal file
49
apps/app/src/composables/__tests__/drawerRegistry.spec.ts
Normal file
@@ -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: '<div />' })
|
||||||
|
const StubB = defineComponent({ template: '<div />' })
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { registerDrawerComponent } from '@/composables/drawerRegistry'
|
||||||
import { useRightDrawer } from '@/composables/useRightDrawer'
|
import { useRightDrawer } from '@/composables/useRightDrawer'
|
||||||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
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', () => {
|
describe('useRightDrawer', () => {
|
||||||
beforeEach(() => setActivePinia(createPinia()))
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
|
|
||||||
@@ -24,4 +29,16 @@ describe('useRightDrawer', () => {
|
|||||||
expect(isOpen.value).toBe(false)
|
expect(isOpen.value).toBe(false)
|
||||||
expect(useShellUiStore().drawer.isOpen).toBe(false)
|
expect(useShellUiStore().drawer.isOpen).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolveDrawerComponent delegates to the registry', () => {
|
||||||
|
const StubComponent = defineComponent({ template: '<div />' })
|
||||||
|
const name = `${PREFIX}StubComponent`
|
||||||
|
|
||||||
|
registerDrawerComponent(name, StubComponent)
|
||||||
|
|
||||||
|
const { resolveDrawerComponent } = useRightDrawer()
|
||||||
|
|
||||||
|
expect(resolveDrawerComponent(name)).toBe(StubComponent)
|
||||||
|
expect(resolveDrawerComponent(`${PREFIX}unknown`)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
32
apps/app/src/composables/drawerRegistry.ts
Normal file
32
apps/app/src/composables/drawerRegistry.ts
Normal file
@@ -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<string, Component>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { Component, ComputedRef } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { resolveDrawerComponent } from '@/composables/drawerRegistry'
|
||||||
import { useShellUiStore } from '@/stores/useShellUiStore'
|
import { useShellUiStore } from '@/stores/useShellUiStore'
|
||||||
|
|
||||||
// Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4,
|
// Thin facade over useShellUiStore.drawer (RFC-WS-GUI-REDESIGN AD-G4,
|
||||||
@@ -14,6 +15,7 @@ export interface UseRightDrawer {
|
|||||||
props: ComputedRef<Record<string, unknown>>
|
props: ComputedRef<Record<string, unknown>>
|
||||||
open: (component: string, props?: Record<string, unknown>) => void
|
open: (component: string, props?: Record<string, unknown>) => void
|
||||||
close: () => void
|
close: () => void
|
||||||
|
resolveDrawerComponent: (name: string | null | undefined) => Component | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRightDrawer(): UseRightDrawer {
|
export function useRightDrawer(): UseRightDrawer {
|
||||||
@@ -26,5 +28,6 @@ export function useRightDrawer(): UseRightDrawer {
|
|||||||
props: computed(() => drawer.value.props),
|
props: computed(() => drawer.value.props),
|
||||||
open: (component, props = {}) => store.openDrawer(component, props),
|
open: (component, props = {}) => store.openDrawer(component, props),
|
||||||
close: () => store.closeDrawer(),
|
close: () => store.closeDrawer(),
|
||||||
|
resolveDrawerComponent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user