diff --git a/.changeset/record-details-section-presentation-keys.md b/.changeset/record-details-section-presentation-keys.md new file mode 100644 index 0000000000..e3acae611c --- /dev/null +++ b/.changeset/record-details-section-presentation-keys.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +--- + +Declare `hideEmpty` / `collapsible` / `showBorder` on `record:details` +sections — the three keys objectui's renderer has honoured all along + +`RecordDetailsRenderer` spreads every authored section through to +`DetailSection`, which reads all three — but the strict section schema +declared only `name` / `label` / `columns` / `fields`, so +`objectstack validate` warned that an authored key "did nothing". For +`hideEmpty` the warning hid the one key that decides whether a section +exists at all: the renderer forces `hideEmpty ?? true`, and a section whose +fields are all empty then renders nothing — no heading, no skeleton — with +no declarable spelling to ask the skeleton back (a freshly created record +losing two of its three authored sections is how this surfaced). + +Accept-set widening only; the renderer is unchanged (maintainer ruling +2026-08-23, direction 1). All three keys are optional with **no schema +default** — the fallbacks are the renderer's, and the describe() texts +state them as measured at the `.objectui-sha` pin: `hideEmpty` on; +`collapsible` off; `showBorder` derived (on for a titled section, off for +an untitled one). `hideEmpty: false` now keeps a section's label skeleton +on an all-empty record, and schema and runtime finally say the same thing. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 092b0e508b..38e4cc1b83 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -539,7 +539,7 @@ const result = AIChatWindowProps.parse(data); | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | | **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. | +| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder? }`. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | | **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 36d994d989..7a71c46489 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -581,6 +581,70 @@ describe('RecordDetailsProps', () => { ).toThrow(); }); + // #11289 — the three section keys the renderer honoured and this shape + // rejected (maintainer ruling 2026-08-23, direction 1: declare; renderer + // unchanged). `hideEmpty: false` is the load-bearing one: it is the only + // spelling that keeps a section's label skeleton on an all-empty record, + // and before this declaration `objectstack validate` warned it "did + // nothing". + it('preserves the section presentation keys verbatim (#11289)', () => { + const result = RecordDetailsProps.parse({ + sections: [{ + label: 'Description', + fields: ['description', 'next_step'], + hideEmpty: false, + collapsible: true, + showBorder: false, + }], + }); + expect(result.sections?.[0].hideEmpty).toBe(false); + expect(result.sections?.[0].collapsible).toBe(true); + expect(result.sections?.[0].showBorder).toBe(false); + }); + + it('does not materialize the section presentation keys on a clean parse', () => { + // Optional with NO schema default (the `maxVisible` principle): `true` / + // off / title-derived are the RENDERER'S fallbacks, and a schema default + // would turn "the author said nothing" into "the author asked for the + // default" — a different fact. + const section = RecordDetailsProps.parse({ + sections: [{ label: 'Overview', fields: ['name'] }], + }).sections?.[0] as Record; + expect('hideEmpty' in section).toBe(false); + expect('collapsible' in section).toBe(false); + expect('showBorder' in section).toBe(false); + }); + + it('rejects non-boolean values for the section presentation keys', () => { + for (const key of ['hideEmpty', 'collapsible', 'showBorder'] as const) { + const r = RecordDetailsProps.safeParse({ + sections: [{ label: 'A', fields: ['a'], [key]: 'yes' }], + }); + expect(r.success).toBe(false); + expect(r.success === false && r.error.issues[0].code).toBe('invalid_type'); + expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0, key]); + } + }); + + it('still rejects unknown section keys, and the new keys are suggestion candidates', () => { + // Strictness survives the widening, and the declared keys entered the + // "did you mean" candidate list — the proof the declaration reached the + // same error map the strict shape reads. + const r = RecordDetailsProps.safeParse({ + sections: [{ label: 'A', fields: ['a'], showBorders: true }], + }); + expect(r.success).toBe(false); + const message = r.success === false + ? r.error.issues.map((i) => i.message).join('\n') + : ''; + expect(message).toContain('`showBorders`'); + // The arrow form specifically — a bare `toContain('showBorder')` is + // satisfied by the echoed offending key (`showBorders` contains it), which + // is exactly what reverse verification against the pre-declaration schema + // measured: that spelling stayed green with no declaration at all. + expect(message).toContain('`showBorders` → `showBorder`'); + }); + it('preserves hideFields verbatim (sys-user.page.ts:106)', () => { // Undeclared until #5611, so a non-strict `z.object` dropped it on the // floor: the platform page's hidden-field list survived only because diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 0dd1cd55ef..5df09fe0b3 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -866,7 +866,32 @@ export const RecordDetailsProps = strictObject({ columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'), /** Field names shown in this section, in order. */ fields: z.array(z.string()).describe('Field names rendered in this section, in order'), - })).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`.'), + /** + * The three presentation keys the renderer has honoured all along, + * declared at last (#11289, maintainer ruling 2026-08-23 — direction 1: + * declare, defaults matching current renderer behavior; the renderer is + * unchanged). `RecordDetailsRenderer` spreads every authored section + * through to `DetailSection`, which reads all three — while this shape + * rejected them, so `objectstack validate` warned that an authored key + * "did nothing". For `hideEmpty` that warning hid the one key that decides + * whether a section EXISTS: the renderer forces `hideEmpty ?? true`, and + * an all-empty section then returns `null` outright — no heading, no + * skeleton — with no declarable way to ask for the skeleton back. + * + * All three are optional with NO schema default, for the `maxVisible` + * reason (see `inlineEdit` below): the fallbacks are the RENDERER'S, and + * a schema default would turn "the author said nothing" into "the author + * asked for the default". Defaults in the describe() texts are MEASURED + * at the `.objectui-sha` pin (objectui `plugin-detail/src/renderers/ + * record-details.tsx` + `DetailSection.tsx`), not transcribed from a TS + * interface. + */ + hideEmpty: z.boolean().optional().describe('Hide this section\'s empty fields (renderer default: on — and a section whose fields are ALL empty then renders nothing at all: no heading, no skeleton). Set `false` to render empty rows, keeping the section\'s label skeleton on an all-empty record (e.g. a brand-new one).'), + /** Collapsible card. Initial state is expanded; the toggle is the heading. */ + collapsible: z.boolean().optional().describe('Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off).'), + /** Card chrome; the renderer derives it from the presence of a title. */ + showBorder: z.boolean().optional().describe('Draw this section\'s card chrome (renderer default: derived — on for a titled section, off for an untitled one). Set `false` for a borderless titled section, or `true` for a bordered untitled one.'), + })).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder? }`.'), fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'), /** * Field names to omit from the body, applied to both `fields` and every