diff --git a/.changeset/b1-page-seam.md b/.changeset/b1-page-seam.md new file mode 100644 index 000000000..359a94001 --- /dev/null +++ b/.changeset/b1-page-seam.md @@ -0,0 +1,7 @@ +--- +"@object-ui/app-shell": minor +--- + +Runtime persistence seam: add `'page'` artifact type (record-page draft/publish). + +`RuntimeArtifactType` now includes `'page'`, so a record `PageSchema` stages and publishes through the same ADR-0034 `/meta` draft model as views/reports/dashboards (#1541). New pure helpers `recordPageName(objectName, existing?)` (prefers an assigned page name, else mints `_record`) and `recordPageEnvelope(objectName, schema, name?)` (sets the `name`/`object`/`pageType:'record'`/`kind:'full'` identity fields the resolver matches on) — foundation for the record-page edit loop. diff --git a/packages/app-shell/src/views/runtime-metadata-persistence.test.ts b/packages/app-shell/src/views/runtime-metadata-persistence.test.ts index bb1bd8a11..1bca35dba 100644 --- a/packages/app-shell/src/views/runtime-metadata-persistence.test.ts +++ b/packages/app-shell/src/views/runtime-metadata-persistence.test.ts @@ -8,6 +8,8 @@ import { readRuntimeDraft, discardRuntimeDraft, unwrapDraftBody, + recordPageName, + recordPageEnvelope, } from './runtime-metadata-persistence'; /** @@ -44,11 +46,12 @@ describe('runtime-metadata-persistence seam (ADR-0034)', () => { }); }); - it('persistRuntimeMetadata works for view / dashboard too', async () => { + it('persistRuntimeMetadata works for view / dashboard / page too', async () => { const metadataClient = makeMetadataClient(); await persistRuntimeMetadata('view', 'my_view', { columns: ['name'] }, { metadataClient }); await persistRuntimeMetadata('dashboard', 'my_dash', { widgets: [] }, { metadataClient }); + await persistRuntimeMetadata('page', 'invoice_record', { regions: [] }, { metadataClient }); expect(metadataClient.save).toHaveBeenCalledWith('view', 'my_view', { columns: ['name'] }, { mode: 'draft', @@ -56,6 +59,45 @@ describe('runtime-metadata-persistence seam (ADR-0034)', () => { expect(metadataClient.save).toHaveBeenCalledWith('dashboard', 'my_dash', { widgets: [] }, { mode: 'draft', }); + expect(metadataClient.save).toHaveBeenCalledWith('page', 'invoice_record', { regions: [] }, { + mode: 'draft', + }); + }); + + describe('record page helpers (#1541)', () => { + it('recordPageName prefers an existing name, else mints _record', () => { + expect(recordPageName('invoice')).toBe('invoice_record'); + expect(recordPageName('invoice', 'custom_invoice_page')).toBe('custom_invoice_page'); + expect(recordPageName('invoice', null)).toBe('invoice_record'); + }); + + it('recordPageEnvelope sets the record-page identity fields the resolver matches on', () => { + const env = recordPageEnvelope('invoice', { type: 'page', title: 'Invoice', regions: [{ name: 'main' }] }); + expect(env).toMatchObject({ + type: 'page', + name: 'invoice_record', + object: 'invoice', + pageType: 'record', + kind: 'full', + title: 'Invoice', + regions: [{ name: 'main' }], + }); + }); + + it('recordPageEnvelope keeps an explicit/existing page name', () => { + expect(recordPageEnvelope('invoice', { name: 'inv_v2' }).name).toBe('inv_v2'); + expect(recordPageEnvelope('invoice', {}, 'inv_v3').name).toBe('inv_v3'); + }); + + it('a page draft round-trips through the seam', async () => { + const metadataClient = makeMetadataClient(); + metadataClient.get.mockResolvedValue({ type: 'page', name: 'invoice_record', item: { regions: [{ name: 'x' }] } }); + const draft = await readRuntimeDraft('page', 'invoice_record', { metadataClient }); + expect(metadataClient.get).toHaveBeenCalledWith('page', 'invoice_record', { state: 'draft' }); + expect(draft).toEqual({ regions: [{ name: 'x' }] }); + await publishRuntimeMetadata('page', 'invoice_record', { metadataClient }); + expect(metadataClient.publish).toHaveBeenCalledWith('page', 'invoice_record'); + }); }); it('createRuntimeMetadata → save draft and returns the name', async () => { diff --git a/packages/app-shell/src/views/runtime-metadata-persistence.ts b/packages/app-shell/src/views/runtime-metadata-persistence.ts index 2c32b2d44..85a5fe646 100644 --- a/packages/app-shell/src/views/runtime-metadata-persistence.ts +++ b/packages/app-shell/src/views/runtime-metadata-persistence.ts @@ -18,8 +18,42 @@ * a legacy write path here. */ -/** The three runtime-editable artifact types ADR-0034 unifies. */ -export type RuntimeArtifactType = 'view' | 'report' | 'dashboard'; +/** The runtime-editable artifact types ADR-0034 unifies. `page` (a record + * `PageSchema`) joins the original three (#1541): a record page is edited in + * the browser and staged/published through the same `/meta` draft model. */ +export type RuntimeArtifactType = 'view' | 'report' | 'dashboard' | 'page'; + +/** + * The metadata `name` (the `:name` in `/meta/page/:name`) for an object's + * record page. Prefer an already-assigned record page's name; otherwise mint + * the convention `_record`. Editing the synthesized default for the + * first time materialises a real named page under this key so it has something + * to draft / publish / version against. + */ +export function recordPageName(objectName: string, existingName?: string | null): string { + return existingName || `${objectName}_record`; +} + +/** + * Wrap an edited `PageSchema` as a persistable **record page** body: ensures the + * `name` / `object` / `pageType: 'record'` / `kind: 'full'` identity fields the + * resolver (`usePageAssignment`) matches on, so a published page overrides the + * synthesized default for that object on the next render. + */ +export function recordPageEnvelope( + objectName: string, + schema: Record, + name?: string, +): Record { + return { + ...schema, + type: 'page', + name: recordPageName(objectName, name ?? (schema?.name as string | undefined)), + object: objectName, + pageType: 'record', + kind: 'full', + }; +} /** Everything the seam needs to persist any of the three artifact types. */ export interface RuntimePersistCtx {