Skip to content

Commit fd73f2b

Browse files
objectstack-fleet[bot]claudehotlong
authored
feat(app-shell): Studio authors relatedList, the related-list block's columns/sort/filter, ordered highlight fields, and slotted-by-default record pages (#27163)
Part of #27054. This PR lands four of the card's five Studio items. The card's `relatedLists` drag-to-order editor stays open on the card: it waits for #27069 to ship the key, as the card and the dispatch both say. Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 现在能直接编辑记录页 v3 读取的几项元数据。查找/主从字段有「关联列表」三态控件(显示 / 不显示 / 主要(进正文))和筛选构建器;关联列表块露出列、排序、筛选、条数、「查看全部」、添加;重点字段变成可拖排序的有序列表(上限 6)带要点条预览;新建记录页默认是空槽的 slotted 页,「转成整页」是显式按钮。 - **为什么改**:这些键 spec 早已声明、记录页也在读,但 Studio 没有控件,作者只能手写 JSON;新建记录页还会把合成页整份拷进去,从第一天起就和默认页分叉。 - **风险与回滚**:只动 Studio 前端(app-shell)与 studio 文案包,不改任何 spec schema、不碰记录页运行时渲染。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx`(`RelatedListFields`);`ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx`(`convertToFull`,写回的是 spec 认可的落盘形状);`ui/packages/app-shell/src/views/metadata-admin/anchors.ts`(新建页 seed)。 - **两分钟验证**:在 Studio 打开 showcase 的 Invoice,点 Account 列的编辑按钮,字段面板下方应看到「Related list on the parent record」三态控件,「Primary (in body)」处于选中;截图见证据分支 `claude/issue-27054-evidence`。 - **需要你做的**:无,席位落地。 ## What changes 1. **Field inspector: related list on the parent record.** On a `lookup` or `master_detail` field, a new section edits the two keys that describe the child collection on the parent's record page. - A three-state control writes `relatedList`. "Don't show" writes `false` and "Primary (in body)" writes `'primary'`. "Show" leaves the key absent, the spec's own "true or absent = shown"; a stored `true` reads as Show. - `relatedListFilter` is edited with the same builder the dataset designer uses over the list toolbar's `FilterBuilder`. That field is now one shared component, `FilterConditionField`, moved out of the dataset inspector rather than copied. A stored filter the flat builder cannot hold (nested, OR) is kept as stored and named. - The section is absent on `tree` fields, which the spec gives no related list. 2. **`record:related_list` block inspector.** The block now curates `columns`, `sort`, `filter`, `limit`, `showViewAll` and `add`, beside `objectName`, `relationshipField` and `title`. Every one is a prop `RecordRelatedListProps` already declares. - `columns`, `sort` and `filter` read the child object named by `objectName`. `filter` uses the list toolbar's builder (the `filter-builder` form widget's own field, now exported), so it writes spec `ViewFilterRule[]`. - `showViewAll` shows the spec default (on) when unset. `add` is a JSON box with a shape sample, the only door that can add that nested key. - A stored value in the other arm of a union is shown as JSON rather than dropped into the row editor: a string `sort`, or `columns` given as saved-view column objects. - The limit hint reads 5, the renderer's own default, instead of 10. 3. **Object settings: `highlightFields` as an ordered list.** Rows can be dragged or moved with up and down buttons (the keyboard path). Fields are appended up to six, and a longer stored list is shown whole. A strip preview below draws the picked order. Removing the last field removes the key. The editor is `HighlightFieldsEditor` in `studio-design`. 4. **New record pages start slotted.** In the metadata page-create form, the kind now follows the page type until the author picks one: `record` gives `slotted`, everything else `full`. This is a new `page-kind` create-derive transform. - A record page is created `kind: 'slotted'` with no slots, which renders as the synthesized default and keeps following the object. - The synthesized regions are copied only when the author explicitly asks for a full page: by choosing `full` at create, or with the designer's new "Convert to full page" action. - That action re-synthesizes the regions in the persisted shape (field names, English default heading), exactly as the create-time seed does. It then switches the kind to `full` and drops `slots`. - An empty slotted page now opens on the slot editor with every slot inherited. Before, it opened a blank regions canvas whose "Add region" wrote regions a slotted page never reads. ## Why The record-page v3 epic makes the record page read these keys, and the spec already declares all of them, but Studio had no control for most of them. Authors had to hand-write JSON, or had no door at all: `relatedList` had no control, and the block's column, sort and filter props were absent. A new record page was seeded with a copy of the synthesized page, so every authored page forked from the default on the day it was created. The maintainer's rulings on the card fix the shape of each control. ## Tests All test runs are on head `23a697c2` (after merging `main`), run from the repository root. - New pins: - `ObjectFieldInspector.relatedList-27054.test.tsx`: 「主要」 on the task → lead FK writes `'primary'`, and the lead page built by the real `deriveRelatedLists` and `buildDefaultPageSchema` chain lists tasks first. Before the click, the control order is notes first. - `PageBlockInspector.relatedListProps-27054.test.tsx`: columns, filter and limit round-trip, and every written block passes `ComponentPropsMap['record:related_list']`. - `ObjectSettingsPanel.highlightOrder-27054.test.tsx`: order is preserved through buttons, drag and add, plus the cap of six. - `PagePreview.slottedDefault-27054.test.tsx` and `anchors.page-seed.test.ts`: a new record page is `kind: 'slotted'` with empty slots and passes `PageSchema`. Convert-to-full and full-create both write blocks that pass their spec props rows. - Re-pinned (patch round 1, head `7731242e37`): `recordDetailSections.agreement-12442.test.ts` — the seed-and-runtime agreement now holds on the explicit-`full` path (`fullSeedFor`), and a new case pins that a record page created with no kind or `slotted` seeds exactly `{ kind: 'slotted' }` with no `record:details` node; the three-call-sites block is unchanged. - Updated pins: - `block-config-i18n.test.ts`: one more add button, one more literal placeholder, and the limit literal is now 5. - `block-config.test.ts`: the new `filter-rules` kind. - `block-config-schema-parity-8216.test.ts`: the census now judges the row editor's element through the single array arm of a `string | array` union, which is more coverage, not less. <details><summary>Test and type-check readings</summary> ```text pnpm --dir ui exec vitest run --maxWorkers=2 (touched families: metadata-admin page seed, PagePreview, PageBlockCanvas, block-config, PageBlockInspector, ObjectFieldInspector, DatasetDefaultInspector, datasetFilterCondition, ResourceEditPage, widgets, ObjectSettingsPanel, packages/i18n) Test Files 198 passed (198) Tests 2268 passed | 13 skipped (2281) pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check os-verify-lock: VERDICT command-exit 0 Before the merge, on 997f09f plus the label rename: pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/ packages/app-shell/src/views/studio-design/ packages/i18n/ Test Files 1 failed | 969 passed (970) -> the one failure was this branch's "Hidden" label colliding with the field's own Hidden checkbox; renamed to Show / Don't show and re-run: ObjectFieldInspector* Test Files 23 passed (23); StudioDesignSurface* + DataPillar* Test Files 127 passed (127) ``` </details> <details><summary>Ablations (one-shot, restored by blob hash)</summary> ```text anchors.ts: "if (draft.kind !== 'full') return { kind: 'slotted' };" neutralised -> anchors.page-seed.test.ts: Tests 2 failed | 6 passed; restored blob == HEAD, git diff HEAD empty PagePreview.tsx convertToFull: toAuthoredDetailSections(...) replaced by identity -> PagePreview.slottedDefault-27054.test.tsx: 1 failed, "record:details sections.0.fields.0: Invalid input: expected string, received object"; restored blob == HEAD ``` </details> ## Acceptance notes - **Live verification found and fixed a defect in this branch.** The first version of "Convert to full page" copied the canvas's render schema, whose detail sections hold runtime field objects. On a real showcase page the converted draft drew a 16-error spec banner. The action now writes the persisted shape, and the props-row pin above reproduces the original error when the fix is ablated. - **Primary's wording follows the v3 ruling.** The label is "Primary (in body)" / 「主要(进正文)」 and the help text describes a full-width table in the record body. That rendering is #27069's half; until it lands, the runtime still promotes a primary list to its own tab. The dispatch asked the help text to cite #27069, but runtime strings carry no tracker number in this repo, so the reference sits in the code comment on `RelatedListFields`. - **"Show" leaves `relatedList` absent rather than writing `true`.** The spec reads both the same, and the inspector's convention is that unset deletes the key. - **The Setup metadata drawer's `relatedListFilter` row is still a JSON box.** The spec's field form (`field.form.ts`) renders there through `SchemaForm`, which has no object-field context in that drawer. Plumbing one would also re-bind every `*Field` row of the field form to the wrong object: `displayField` names a field of the referenced object. The filter builder is in the Studio field inspector, where the three-state control lives, so `field.form.ts` and `page.form.ts` are unchanged. - **The file surface differs from the card.** `block-config.ts` and `PageBlockInspector.tsx` live under `metadata-admin/` (previews and inspectors), and `ObjectSettingsPanel.tsx` under `studio-design/`. The fix also touches `PagePreview.tsx`, `PageBlockCanvas.tsx`, `createDerive.ts`, `registry.ts`, `widgets.tsx` (one export) and `DatasetDefaultInspector.tsx` (the shared filter field moved out). New files are `FilterConditionField.tsx` and `HighlightFieldsEditor.tsx`. - **Observed, not fixed (pre-existing).** In the page designer canvas, an authored `record:details` that names its fields draws a location field as "[Object]" and a select value raw. This is the same output the existing full-create seed already produces on `main`. Another is that `PageBlockInspector`'s JSON fields render a `Label` that is not associated with their textarea. ## Verification - Screenshots are on the never-merged branch [`claude/issue-27054-evidence`](https://github.com/objectstack-ai/objectstack/tree/claude/issue-27054-evidence). They come from a local showcase app (`--fresh` database) running this branch. - The field inspector on `showcase_invoice.account` with 「Primary (in body)」 selected. - The page-create form switching Kind to `slotted` for a record page. - The designer's default-page note and "Convert to full page". - The converted page saved with no spec banner. - The ordered highlight list with its strip preview. - Local gates: the cloud run's gate block is in report comment 6464856235 on the card (head `23a697c2`), the patch round's in 6466884994 (head `7731242e37`; 34 / 34 derived, the `vitest run scripts/` red is the macOS host gap). CI on `7731242e37`: all nine required contexts success, Test UI 16 / 16 shards. - Final head: `7731242e37` (the cloud head `23a697c2` merged with `main` `b8615afaba` plus the re-pin). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint over the changed files reports no new error. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief and quoted rulings excepted). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q4EjjRbbbwV5u1Vx4Ge7TT --- _Generated by [Claude Code](https://claude.ai/code/session_01Q4EjjRbbbwV5u1Vx4Ge7TT)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 91a4036 commit fd73f2b

28 files changed

Lines changed: 1614 additions & 164 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(app-shell): Studio authors what the record page reads about related lists, highlight fields, and new record pages
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **Field inspector, lookup and master-detail fields.** A "Related list on the parent record" section with a three-state control (Show / Don't show / Primary (in body)) writes `relatedList`: Don't show writes `false`, Primary writes `'primary'`, and Show leaves the key absent, the spec's own "true or absent = shown". Below it, `relatedListFilter` is edited with the same filter builder the dataset designer and the list toolbar use, instead of being absent from the inspector. A stored filter the builder cannot hold (nested, OR) is kept as stored and named.
13+
- **Related-list block inspector.** The `record:related_list` block exposes `columns` (the child object's fields), `sort` (field and order rows), `filter` (the list toolbar's filter builder), `limit`, `showViewAll` (shown on when unset, the spec default) and `add`, beside the object, relationship field and title. A stored value in the other arm of a union, such as a string `sort` or `columns` given as saved-view column objects, is shown as JSON rather than as an empty or garbled list. The limit hint now reads 5, the renderer's own default.
14+
- **Object settings.** `highlightFields` is an ordered list: drag a row or use the move buttons to reorder, add fields up to six, and see the highlight strip preview in the picked order. Removing the last field removes the key.
15+
- **New record pages.** A record page created in the metadata editor now starts `kind: 'slotted'` with no slots, which is the synthesized default page, and it keeps following the object. The create form's kind follows the page type until the author picks one. Choosing `full` at create still copies the synthesized regions. In the page designer, a slotted record page says it is the default page and offers "Convert to full page", which copies the regions it renders, switches the kind to `full` and drops `slots`. An empty slotted page now opens on the slot editor instead of a blank regions canvas.

‎ui/packages/app-shell/src/views/metadata-admin/anchors.page-seed.test.ts‎

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, vi } from 'vitest';
44
// `createSeed` reaches the page synthesizer through a dynamic
55
// `await import('@object-ui/plugin-detail')` (anchors.ts), so the cold transform
66
// of that whole package graph is billed to whichever test first awaits the hook
@@ -17,11 +17,21 @@ import { describe, it, expect } from 'vitest';
1717
import '@object-ui/plugin-detail';
1818
import { registerBuiltinAnchors } from './anchors';
1919
import { resolveResourceConfig } from './registry';
20+
import { applyTransform } from './createDerive';
21+
import { buildCreateModeBody } from './createBody';
22+
import { validateMetadataDraft } from './clientValidation';
23+
import { getMetadataCreateSeed } from '@objectstack/spec/kernel';
24+
import { PageSchema, ComponentPropsMap } from '@objectstack/spec/ui';
2025

2126
/**
22-
* #1541 — the `page` resource's `createSeed` hook seeds a record page's regions
23-
* from the bound object's synthesized default detail page, so authoring starts
24-
* from the auto-generated layout instead of a blank canvas.
27+
* The `page` resource's create-time seeding.
28+
*
29+
* A record page is created `kind: 'slotted'` with no slots — the synthesized
30+
* default page, which keeps following the object until the author overrides a
31+
* slot. The synthesized regions are copied (#1541's seed) only when the author
32+
* explicitly creates a `full` record page; the create form's `kind` follows the
33+
* page type (`page-kind` derive), so `full` on a record page is the author's
34+
* own pick.
2535
*/
2636
registerBuiltinAnchors();
2737

@@ -36,6 +46,34 @@ const objectDef = {
3646
},
3747
};
3848

49+
/**
50+
* Every block in `tree` judged by the spec's own props row for its type
51+
* (`ComponentPropsMap`) — the per-block check the save door runs, which the
52+
* page-level parse never reaches (`properties` is an open record there).
53+
* Returns the refusals and how many `record:details` blocks were judged, so a
54+
* walk that judged nothing cannot pass as clean.
55+
*/
56+
function blockPropsVerdict(tree: unknown): { refused: string[]; detailsJudged: number } {
57+
const map = ComponentPropsMap as Record<string, { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>;
58+
const refused: string[] = [];
59+
let detailsJudged = 0;
60+
const walk = (node: unknown) => {
61+
if (Array.isArray(node)) { node.forEach(walk); return; }
62+
if (!node || typeof node !== 'object') return;
63+
const n = node as Record<string, unknown>;
64+
if (typeof n.type === 'string' && map[n.type] && n.properties && typeof n.properties === 'object') {
65+
const res = map[n.type].safeParse(n.properties);
66+
if (n.type === 'record:details') detailsJudged++;
67+
if (!res.success) {
68+
for (const i of res.error!.issues) refused.push(`${n.type} ${i.path.join('.')}: ${i.message}`);
69+
}
70+
}
71+
Object.values(n).forEach(walk);
72+
};
73+
walk(tree);
74+
return { refused, detailsJudged };
75+
}
76+
3977
function clientReturning(def: any) {
4078
return { get: async (_type: string, _name: string) => def };
4179
}
@@ -47,25 +85,69 @@ describe('page createSeed (record-page region seeding)', () => {
4785
expect(typeof cfg.createSeed).toBe('function');
4886
});
4987

50-
it('seeds regions from the bound object default for a record page', async () => {
51-
const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice' }, { client: clientReturning(objectDef) });
88+
it('creates a record page slotted with no slots and no copied regions', async () => {
89+
const get = vi.fn(async () => objectDef);
90+
for (const kind of ['slotted', undefined]) {
91+
const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice', kind }, { client: { get } });
92+
expect(seeded).toEqual({ kind: 'slotted' });
93+
}
94+
// The object is never read: nothing is synthesized to be copied.
95+
expect(get).not.toHaveBeenCalled();
96+
});
97+
98+
it('saves a new record page as kind slotted with empty slots, a body the spec accepts', async () => {
99+
// The save path of `ResourceEditPage` in create mode: the spec create seed
100+
// under the author's draft (whose `kind` the `page-kind` derive set), then
101+
// the async seed on top.
102+
const draft = { label: 'Invoice page', name: 'invoice_page', type: 'record', object: 'showcase_invoice', kind: 'slotted' };
103+
const specSeed = getMetadataCreateSeed('page') as Record<string, unknown>;
104+
expect(specSeed.kind).toBe('full'); // the seed alone would make a full page
105+
const body = {
106+
...buildCreateModeBody(cfg, draft, specSeed),
107+
...(await cfg.createSeed!(draft, { client: clientReturning(objectDef) })),
108+
} as Record<string, unknown>;
109+
expect(body.kind).toBe('slotted');
110+
expect(body.slots).toBeUndefined();
111+
expect(body.regions).toEqual([]);
112+
const parsed = PageSchema.safeParse(body);
113+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
114+
});
115+
116+
it('copies the synthesized regions when the author explicitly creates a full record page', async () => {
117+
const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice', kind: 'full' }, { client: clientReturning(objectDef) });
52118
expect(Array.isArray((seeded as any).regions)).toBe(true);
53119
expect((seeded as any).regions.length).toBeGreaterThan(0);
120+
expect((seeded as any).kind).toBeUndefined();
121+
// The copied regions are authored metadata the console's own page check accepts.
122+
const body = { name: 'invoice_full', label: 'Invoice', type: 'record', object: 'showcase_invoice', kind: 'full', ...(seeded as object) };
123+
const verdict = await validateMetadataDraft('page', body, undefined, { mode: 'edit' });
124+
expect(verdict.issues, JSON.stringify(verdict.issues.slice(0, 3))).toEqual([]);
125+
const blocks = blockPropsVerdict((seeded as any).regions);
126+
expect(blocks.detailsJudged).toBeGreaterThan(0);
127+
expect(blocks.refused).toEqual([]);
128+
});
129+
130+
it('derives the create form kind from the page type (record → slotted, else full)', () => {
131+
expect(cfg.createDerive).toContainEqual({ from: 'type', to: 'kind', transform: 'page-kind', untilUserEdits: true });
132+
expect(applyTransform('page-kind', 'record')).toBe('slotted');
133+
for (const type of ['list', 'app', 'home', 'utility', undefined]) {
134+
expect(applyTransform('page-kind', type)).toBe('full');
135+
}
54136
});
55137

56138
it('returns {} for a non-record page (no object binding to synth from)', async () => {
57139
const seeded = await cfg.createSeed!({ type: 'app', object: 'showcase_invoice' }, { client: clientReturning(objectDef) });
58140
expect(seeded).toEqual({});
59141
});
60142

61-
it('returns {} when no object is chosen', async () => {
62-
const seeded = await cfg.createSeed!({ type: 'record' }, { client: clientReturning(objectDef) });
143+
it('returns {} for an explicit full record page when no object is chosen', async () => {
144+
const seeded = await cfg.createSeed!({ type: 'record', kind: 'full' }, { client: clientReturning(objectDef) });
63145
expect(seeded).toEqual({});
64146
});
65147

66148
it('is best-effort — swallows a client/synth failure and returns {}', async () => {
67149
const failing = { get: async () => { throw new Error('boom'); } };
68-
const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice' }, { client: failing });
150+
const seeded = await cfg.createSeed!({ type: 'record', object: 'showcase_invoice', kind: 'full' }, { client: failing });
69151
expect(seeded).toEqual({});
70152
});
71153
});

‎ui/packages/app-shell/src/views/metadata-admin/anchors.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ export function registerBuiltinAnchors(): void {
152152
createFields: ['label', 'name', 'object', 'type', 'kind'],
153153
createDerive: [
154154
{ from: 'label', to: 'name', transform: 'slugify', untilUserEdits: true },
155+
// The kind follows the page type until the author picks one: a record
156+
// page starts `slotted` with no slot overridden (= the synthesized
157+
// default page), every other type `full` (`pageKindForType`).
158+
{ from: 'type', to: 'kind', transform: 'page-kind', untilUserEdits: true },
155159
],
156160
createSchema: {
157161
type: 'object',
@@ -185,22 +189,34 @@ export function registerBuiltinAnchors(): void {
185189
title: 'Kind',
186190
enum: ['full', 'slotted'],
187191
default: 'full',
188-
description: 'full = the whole page; slotted = override only named slots of the default.',
192+
description: 'A record page starts slotted: no slot is overridden, so it shows the default record page and keeps up with the object. Choose full to own the whole layout, starting from a copy of the default.',
189193
},
190194
},
191195
},
192196
createDefaults: { type: 'list', kind: 'full', regions: [] },
193-
// Seed a record page's regions from the bound object's synthesized default
194-
// detail page, so authoring starts from the auto-generated layout (the same
195-
// one the runtime renders by default) instead of a blank canvas.
197+
// A record page is created `slotted` with no slots — the synthesized
198+
// default page, which keeps following the object (its fields, sections and
199+
// related lists) until the author overrides a slot. Its regions are copied
200+
// only when the author explicitly asks for a `full` page: choosing `full`
201+
// in this form (the `page-kind` derive above makes `slotted` the record
202+
// default, so `full` here is the author's own pick), or the page
203+
// designer's "Convert to full page" action (`PagePreview`). Seeding every
204+
// record page with the synthesized regions forked it from the default on
205+
// the day it was created.
196206
createSeed: async (draft, { client }) => {
197207
// List/interface page: pre-bind the chosen object as the data source so
198208
// the new page renders immediately (the author then curates columns,
199209
// filters, visualizations in the editor).
200210
if (draft?.type === 'list') {
201211
return draft?.object ? { interfaceConfig: { source: String(draft.object) } } : {};
202212
}
203-
if (draft?.type !== 'record' || !draft?.object) return {};
213+
if (draft?.type !== 'record') return {};
214+
// Not an explicit `full`: the empty slotted page. The body's `kind` is
215+
// stated here rather than left to the draft, so a record page reaching
216+
// this hook without a kind (the spec create seed says `full`) still
217+
// starts as the default page.
218+
if (draft.kind !== 'full') return { kind: 'slotted' };
219+
if (!draft?.object) return {};
204220
try {
205221
const objectDef = await client.get('object', String(draft.object));
206222
if (!objectDef || typeof objectDef !== 'object') return {};

‎ui/packages/app-shell/src/views/metadata-admin/createDerive.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ export function firstToken(input: string): string {
7474
return m ? m[0] : '';
7575
}
7676

77+
/**
78+
* The `kind` a new page of this `type` starts as.
79+
*
80+
* A record page starts `slotted` with no slot overridden: what it renders is
81+
* exactly the synthesized default page, so it stays in step with the object
82+
* (new fields, related lists, sections) until the author overrides a slot.
83+
* Seeding a `full` page with a copy of the synthesized regions forked every
84+
* authored record page from the default on the day it was created. Owning
85+
* the whole layout is an explicit act — choosing `full` here, or the
86+
* page designer's "Convert to full page" action. Every other page type is a
87+
* `full` page: `slotted` is a record-page mode only.
88+
*/
89+
export function pageKindForType(type: unknown): 'slotted' | 'full' {
90+
return type === 'record' ? 'slotted' : 'full';
91+
}
92+
7793
export function applyTransform(
7894
transform: CreateDeriveRule['transform'],
7995
value: unknown,
@@ -88,6 +104,8 @@ export function applyTransform(
88104
return titlecase(str);
89105
case 'first-token':
90106
return firstToken(str);
107+
case 'page-kind':
108+
return pageKindForType(value);
91109
default:
92110
return str;
93111
}

0 commit comments

Comments
 (0)