Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/b1-seed-record-page.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -796,14 +796,27 @@ 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 })
// Edit mode: serialise the editor draft back to the wire shape
// (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<string, unknown>), ...seeded };
}
} catch { /* seed is best-effort; proceed with the un-augmented body */ }
}
const savedName = String(
(builtBody as Record<string, unknown>)[identityField] ?? draft[identityField] ?? name,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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({});
});
});
18 changes: 18 additions & 0 deletions packages/app-shell/src/views/metadata-admin/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
const seed: Record<string, unknown> = {};
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 }),
Expand Down
14 changes: 14 additions & 0 deletions packages/app-shell/src/views/metadata-admin/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ export interface MetadataResourceConfig {
*/
createBuildBody?: (draft: Record<string, unknown>) => Record<string, unknown>;

/**
* 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<string, unknown>,
ctx: { client: any },
) => Record<string, unknown> | Promise<Record<string, unknown>>;

/**
* Optional load-time normaliser: the wire item returned by the server
* (`layered.effective` / a pending draft) → the draft shape the editor
Expand Down
Loading