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
24 changes: 24 additions & 0 deletions .changeset/record-details-section-presentation-keys.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>; 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<string, string>; 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). |
Expand Down
64 changes: 64 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>;
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
Expand Down
27 changes: 26 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading