diff --git a/.changeset/b1-seed-record-page.md b/.changeset/b1-seed-record-page.md new file mode 100644 index 000000000..ce2d715d9 --- /dev/null +++ b/.changeset/b1-seed-record-page.md @@ -0,0 +1,7 @@ +--- +"@object-ui/app-shell": minor +--- + +Studio: new record pages seed their layout from the object's default detail page. + +Creating a `pageType: 'record'` page bound to an object previously started from a blank canvas. The `page` resource now has a `createSeed` hook that, on create, fetches the bound object and seeds the page's `regions` from `buildDefaultPageSchema(objectDef)` — the same auto-generated detail layout the runtime renders by default. Authors start by tweaking the default page, not rebuilding it. A generic async `createSeed` hook was added to `MetadataResourceConfig` (merged into the create body after `createBuildBody`/`createDefaults`; best-effort). Completes #1541's Studio authoring path. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 489371463..c337ef9ad 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -796,7 +796,7 @@ function MetadataResourceEditPageImpl({ // or `{ list: { data: { object } } }` for view) is present so // the saved body satisfies its JSONSchema. User-supplied values // always win over the defaults. - const builtBody = createMode + let builtBody = createMode ? (config.createBuildBody ? config.createBuildBody(draft) : { ...(config.createDefaults ?? {}), ...draft }) @@ -804,6 +804,19 @@ function MetadataResourceEditPageImpl({ // (inverse of `toDraft` — e.g. `view` folds the `{ list | form }` // family key back into the ViewItem `config` wrapper). : (config.fromDraft ? config.fromDraft(draft) : draft); + // Async create-time augmentation (e.g. seed a record page's regions from + // the bound object's synthesized default). Best-effort — a failure leaves + // the un-augmented body. User/builder-supplied keys win over the seed. + if (createMode && config.createSeed) { + try { + const seeded = await config.createSeed(draft, { client }); + if (seeded && typeof seeded === 'object') { + // Seed wins over the empty defaults (`builtBody` already folded the + // user's draft in, which only carries default-empty `regions`). + builtBody = { ...(builtBody as Record), ...seeded }; + } + } catch { /* seed is best-effort; proceed with the un-augmented body */ } + } const savedName = String( (builtBody as Record)[identityField] ?? draft[identityField] ?? name, ); diff --git a/packages/app-shell/src/views/metadata-admin/anchors.page-seed.test.ts b/packages/app-shell/src/views/metadata-admin/anchors.page-seed.test.ts new file mode 100644 index 000000000..8e3642d99 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/anchors.page-seed.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { registerBuiltinAnchors } from './anchors'; +import { resolveResourceConfig } from './registry'; + +/** + * #1541 — the `page` resource's `createSeed` hook seeds a record page's regions + * from the bound object's synthesized default detail page, so authoring starts + * from the auto-generated layout instead of a blank canvas. + */ +registerBuiltinAnchors(); + +const objectDef = { + name: 'showcase_invoice', + label: 'Invoice', + fields: { + name: { type: 'text', label: 'Invoice Number' }, + status: { type: 'select', label: 'Status', options: [{ value: 'draft', label: 'Draft' }, { value: 'paid', label: 'Paid' }] }, + account: { type: 'lookup', label: 'Account', reference: 'showcase_account' }, + total: { type: 'currency', label: 'Total' }, + }, +}; + +function clientReturning(def: any) { + return { get: async (_type: string, _name: string) => def }; +} + +describe('page createSeed (record-page region seeding)', () => { + const cfg = resolveResourceConfig('page'); + + it('is registered on the page resource', () => { + expect(typeof cfg.createSeed).toBe('function'); + }); + + it('seeds regions from the bound object default for a record page', async () => { + const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice' }, { client: clientReturning(objectDef) }); + expect(Array.isArray((seeded as any).regions)).toBe(true); + expect((seeded as any).regions.length).toBeGreaterThan(0); + }); + + it('returns {} for a non-record page (no object binding to synth from)', async () => { + const seeded = await cfg.createSeed!({ type: 'app', object: 'showcase_invoice' }, { client: clientReturning(objectDef) }); + expect(seeded).toEqual({}); + }); + + it('returns {} when no object is chosen', async () => { + const seeded = await cfg.createSeed!({ type: 'record' }, { client: clientReturning(objectDef) }); + expect(seeded).toEqual({}); + }); + + it('is best-effort — swallows a client/synth failure and returns {}', async () => { + const failing = { get: async () => { throw new Error('boom'); } }; + const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice' }, { client: failing }); + expect(seeded).toEqual({}); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/anchors.ts b/packages/app-shell/src/views/metadata-admin/anchors.ts index 02b06ed52..7323f032f 100644 --- a/packages/app-shell/src/views/metadata-admin/anchors.ts +++ b/packages/app-shell/src/views/metadata-admin/anchors.ts @@ -156,6 +156,24 @@ export function registerBuiltinAnchors(): void { }, }, createDefaults: { type: 'record', kind: 'full', regions: [] }, + // Seed a record page's regions from the bound object's synthesized default + // detail page, so authoring starts from the auto-generated layout (the same + // one the runtime renders by default) instead of a blank canvas. + createSeed: async (draft, { client }) => { + if (draft?.type !== 'record' || !draft?.object) return {}; + try { + const objectDef = await client.get('object', String(draft.object)); + if (!objectDef || typeof objectDef !== 'object') return {}; + const { buildDefaultPageSchema } = await import('@object-ui/plugin-detail'); + const synth = buildDefaultPageSchema(objectDef as any) as Record; + const seed: Record = {}; + if (Array.isArray(synth?.regions) && synth.regions.length) seed.regions = synth.regions; + if (synth?.template) seed.template = synth.template; + return seed; + } catch { + return {}; + } + }, }); // A view is the canonical first-class ViewItem ({ viewKind, config }), diff --git a/packages/app-shell/src/views/metadata-admin/registry.ts b/packages/app-shell/src/views/metadata-admin/registry.ts index 66362912b..4b37d242f 100644 --- a/packages/app-shell/src/views/metadata-admin/registry.ts +++ b/packages/app-shell/src/views/metadata-admin/registry.ts @@ -190,6 +190,20 @@ export interface MetadataResourceConfig { */ createBuildBody?: (draft: Record) => Record; + /** + * Async create-time body augmentation that needs runtime context the pure + * {@link createBuildBody} can't reach — e.g. fetching another metadata item. + * Its returned fields are merged into the create body AFTER `createBuildBody` + * / `createDefaults`. Best-effort: errors are swallowed (the create still + * proceeds with the un-augmented body). Used by `page` to seed a record + * page's `regions` from the bound object's synthesized default detail page, + * so authoring starts from the auto-generated layout instead of blank. + */ + createSeed?: ( + draft: Record, + ctx: { client: any }, + ) => Record | Promise>; + /** * Optional load-time normaliser: the wire item returned by the server * (`layered.effective` / a pending draft) → the draft shape the editor