diff --git a/.changeset/record-details-sections-object-form.md b/.changeset/record-details-sections-object-form.md new file mode 100644 index 0000000000..8eac674a88 --- /dev/null +++ b/.changeset/record-details-sections-object-form.md @@ -0,0 +1,56 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: `RecordDetailsProps.sections` declares the object form every page actually authors, and `hideFields` is declared (#5611) + +`record:details` declared a `sections` shape that nothing produced and nothing +consumed, and omitted a key a published platform page depends on. Both are now +declared as delivered. + +**BREAKING (authored metadata shape) — `sections` is an object array, not an ID list.** + +```ts +// FROM — declared, but written by zero pages and read by zero renderers +sections: ['overview', 'financials'] + +// TO — what every real page already authors +sections: [ + { label: 'Overview', columns: 2, fields: ['name', 'account', 'owner'] }, + { label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, +] +``` + +One-line fix: replace each section ID with `{ fields: [...] }`, naming the +fields that section should render (add `label` for a heading, `columns` for its +grid width, `name` to make the heading translatable). + +**Why this is safe despite being a type change — it was measured, not assumed.** +The ID-list form had **zero** read paths and **zero** producers: + +- `objectui`'s `RecordDetailsRenderer` maps every `sections` entry as an object + (`s.name` / `s.label` / `s.title` / `s.fields`) and has no string branch — a + string entry would spread into a character map and render nothing; +- `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared + `Array<{ name?, label?, fields, ... }>`, and the Studio block designer can only + author `{ label, columns, fields }`; +- every page in this repo authors the object form — three showcase pages + (`project-detail`, `task-detail`, `settings`) and the `sys_user` platform page; +- `packages/lint` has modelled it as `nestedSections` (`sections[].fields[]`) all + along. + +So the "breakage" applies only to hypothetical stored metadata written against a +declaration nothing ever honoured, and schema validation runs on the publish +path — it does not rewrite data at rest. + +**New: `hideFields`.** `z.array(z.string()).optional()` — field names omitted +from the body, applied to `fields` and to every section's `fields`. The +`sys_user` platform page has authored it since it shipped and the renderer reads +it; it was undeclared, so it survived only because per-component `properties` is +never parsed. Declaring it now means the parse gate (#5068) preserves it instead +of silently stripping a live page's hidden-field list. + +**Section keys**, each declared because it is delivered end to end: +`fields` (required), `label`, `columns` (1-4), and `name` — the i18n anchor that +resolves `objects.._sections..label`, which `packages/lint`'s +`translation-section-name-missing` rule tells authors to add. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d342317cb9..1856be8c7d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -291,8 +291,9 @@ const result = AIChatWindowProps.parse(data); | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) | | **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields, custom uses explicit sections | -| **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") | +| **sections** | `{ name?: string; label?: string; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. | | **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) | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 0f1f82ff9e..f1bcb7b70e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,8 +21,8 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 455 | -| Still-open (strip) sites | 196 | +| Object sites in them | 456 | +| Still-open (strip) sites | 197 | | Files carrying at least one | 30 | Remaining strip sites by class: @@ -33,7 +33,7 @@ Remaining strip sites by class: | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 107 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 29 | +| no gate — carrier live, no parse | 30 | ## Posture, per triaged directory @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 171 | 116 | 5 | 0 | 50 | +| `ui/` | 172 | 116 | 5 | 0 | 51 | | `data/` | 162 | 54 | 1 | 0 | 107 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **455** | **253** | **6** | **0** | **196** | +| **total** | **456** | **253** | **6** | **0** | **197** | ## File-level triage — site counts @@ -65,7 +65,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 18 | | `bulk-action.zod.ts` | 3 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 29 | +| `component.zod.ts` | 30 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 6 | @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `theme.zod.ts` | 6 | | `view.zod.ts` | 53 | | `widget.zod.ts` | 9 | -| **total** | **171** | +| **total** | **172** | ### `data/` — sites @@ -156,18 +156,18 @@ over it is here. ### `ui/` — open -**50 strip of 171**, in 7 file(s). +**51 strip of 172**, in 7 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | | `chart.zod.ts` | 2 | 8 | -| `component.zod.ts` | 29 | 29 | +| `component.zod.ts` | 30 | 30 | | `i18n.zod.ts` | 5 | 6 | | `view.zod.ts` | 3 | 53 | | `widget.zod.ts` | 9 | 9 | -| **total** | **50** | **171** | +| **total** | **51** | **172** | | Bucket | Sites | |---|---| @@ -175,7 +175,7 @@ over it is here. | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 3 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 29 | +| no gate — carrier live, no parse | 30 | ### `data/` — open diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index bd904d16c2..53f19ce8b0 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -84,8 +84,9 @@ describe('validatePageFieldBindings — highlights / KPI cards', () => { }); describe('validatePageFieldBindings — record:details real authored shape', () => { - // Real pages author `sections: [{ label, fields }]`, which RecordDetailsProps - // does not describe (it survives because `properties` is unvalidated). + // Real pages author `sections: [{ label, fields }]` — the shape + // `RecordDetailsProps` now declares too (#5611; it used to declare an ID + // `string[]` that no page and no renderer ever used). it('walks sections[].fields[]', () => { const findings = validatePageFieldBindings({ ...baseStack(), diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index d485073dd2..30d6e9e4fb 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -164,8 +164,11 @@ export interface ComponentFieldSpec { export const COMPONENT_FIELD_SPECS: Readonly> = { 'record:highlights': { props: ['fields'] }, - // `sections`/`hideFields` are not in RecordDetailsProps, but every real page - // authors them (they survive because `properties` is unvalidated). + // `sections` (object form) and `hideFields` are what every real page authors, + // and since #5611 they are what `RecordDetailsProps` declares — this model and + // the spec agree. (Before that, `sections` was declared as an ID `string[]` + // and `hideFields` not at all; both survived only because `properties` is + // unvalidated.) 'record:details': { props: ['fields', 'hideFields'], nestedSections: ['sections'] }, 'record:path': { props: ['statusField'] }, 'element:number': { props: ['field'] }, @@ -208,9 +211,11 @@ export function componentFieldRefs( const sections = Array.isArray(props[key]) ? (props[key] as unknown[]) : []; for (let si = 0; si < sections.length; si++) { const section = sections[si]; - // A `sections` that is a plain `string[]` (the shape `RecordDetailsProps` - // actually declares — section IDs) yields nothing here, which is right: - // those are not field names. + // A non-object entry yields nothing here, which is right: lint runs on + // unvalidated `properties`, so it must survive off-spec input rather than + // throw on it. (Until #5611 the ID `string[]` this skips was the shape + // `RecordDetailsProps` declared; it declares the object form now, so this + // is a defensive guard rather than a divergence from the spec.) if (!isRec(section)) continue; refs.push(...fieldRefsFrom(section.fields, `${basePath}${sep}${key}[${si}].fields`)); } diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 98b3a4bca7..8118d6f372 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7703,6 +7703,7 @@ "ui/RecordDetailsProps:aria", "ui/RecordDetailsProps:columns", "ui/RecordDetailsProps:fields", + "ui/RecordDetailsProps:hideFields", "ui/RecordDetailsProps:layout", "ui/RecordDetailsProps:sections", "ui/RecordHighlightsProps:aria", diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index c172e0e8b8..c739bf8daf 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -149,14 +149,88 @@ describe('RecordDetailsProps', () => { expect(result.sections).toBeUndefined(); }); - it('should accept custom layout with sections', () => { - const details = { columns: '3' as const, layout: 'custom' as const, sections: ['sec-1', 'sec-2'] }; - expect(() => RecordDetailsProps.parse(details)).not.toThrow(); - }); - it('should reject invalid column value', () => { expect(() => RecordDetailsProps.parse({ columns: '5' })).toThrow(); }); + + // #5611: `sections` is the OBJECT form — the only form any page authors and + // the only form any renderer reads. These fixtures are lifted verbatim from + // the real pages so the schema is pinned to authored reality, not to a shape + // invented here. Before this change every one of them was an `invalid_type` + // rejection at `sections[0]` (the old `z.array(z.string())`), and the whole + // `hideFields` key was silently stripped. + it('accepts the showcase section shape verbatim (project-detail.page.ts:49)', () => { + const details = { + layout: 'custom' as const, + sections: [ + { label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, + { label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, + { label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] }, + ], + }; + const result = RecordDetailsProps.parse(details); + expect(result.sections).toHaveLength(3); + expect(result.sections?.[0]).toEqual({ + label: 'Overview', + columns: 2, + fields: ['name', 'account', 'owner', 'status'], + }); + // `columns: 1` is authored too (task-detail.page.ts:76). + expect(() => + RecordDetailsProps.parse({ sections: [{ label: 'Details', columns: 1, fields: ['notes'] }] }), + ).not.toThrow(); + }); + + it('accepts a section with no columns (sys-user.page.ts:118)', () => { + const result = RecordDetailsProps.parse({ + sections: [{ label: 'Identity', fields: ['name', 'image'] }], + }); + expect(result.sections?.[0].columns).toBeUndefined(); + expect(result.sections?.[0].fields).toEqual(['name', 'image']); + }); + + it('accepts an untitled section and a `name`-anchored one', () => { + // No label: the renderer draws it borderless. No name: it is untranslatable + // by construction, which is what `translation-section-name-missing` reports. + expect(() => RecordDetailsProps.parse({ sections: [{ fields: ['notes'] }] })).not.toThrow(); + // `name` is the i18n anchor a lint rule tells authors to add, so the schema + // must accept it — the rule and the schema cannot disagree. + const named = RecordDetailsProps.parse({ + sections: [{ name: 'identity', label: 'Identity', fields: ['name'] }], + }); + expect(named.sections?.[0].name).toBe('identity'); + }); + + it('requires `fields` on every section', () => { + const r = RecordDetailsProps.safeParse({ sections: [{ label: 'Empty' }] }); + expect(r.success).toBe(false); + expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0, 'fields']); + }); + + it('rejects the retired ID-list form rather than silently half-reading it', () => { + const r = RecordDetailsProps.safeParse({ layout: 'custom', sections: ['overview'] }); + 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]); + }); + + it('rejects an out-of-range section column count', () => { + expect(() => + RecordDetailsProps.parse({ sections: [{ label: 'Wide', columns: 5, fields: ['a'] }] }), + ).toThrow(); + }); + + 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 + // nothing ever parsed these props. + const hideFields = ['id', 'banned', 'ban_reason', 'ban_expires', 'email', 'role']; + const result = RecordDetailsProps.parse({ + hideFields, + sections: [{ label: 'Audit', fields: ['created_at', 'updated_at'] }], + }); + expect(result.hideFields).toEqual(hideFields); + }); }); describe('RecordRelatedListProps', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e456af17c1..f2b7c80c54 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -76,10 +76,13 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // gate, not to close schemas nobody calls — filed as #5068, which also // records the two constraints that stop it being a drive-by: `type` is an open // union (unregistered types like `record:line_items` are authored in the wild), -// and real pages already author shapes these schemas do not declare -// (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's -// `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which -// has documented the untyped bag all along). +// and real pages already author shapes these schemas do not declare (the record +// picker's `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, +// which has documented the untyped bag all along). `record:details` +// `sections[]` / `hideFields[]` WAS the largest such divergence and is now +// closed: #5611 re-declared `sections` in the object form every page actually +// authors and declared `hideFields`, so wiring the gate no longer turns three +// showcase pages and the `sys_user` platform page into hard parse errors. // // When #5068 lands, this file becomes `authorable` and the ratchet applies. The // verdict is pinned in `component.test.ts` and in the `ui/` tables of @@ -157,8 +160,56 @@ export const PageCardProps = z.object({ export const RecordDetailsProps = z.object({ columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'), layout: z.enum(['auto', 'custom']).default('auto').describe('Layout mode: auto uses object highlightFields, custom uses explicit sections'), - sections: z.array(z.string()).optional().describe('Section IDs to show (required when layout is "custom")'), + /** + * Field groups rendered as the detail body, IN ORDER. + * + * Declared as the object form because that is the only form anything + * delivers or authors (#5611). Until 17.x this key was `z.array(z.string())` + * — "section IDs" — which no page in this repo, and no read path in + * `objectui`, has ever used: `RecordDetailsRenderer` maps every entry as an + * object (`s.name` / `s.label` / `s.fields`) with no string branch anywhere, + * `@object-ui/types`' `RecordDetailsComponentProps` mirror declares the + * object form, and the Studio block designer can only author + * `{label, columns, fields}`. The ID-list spelling was a declaration with no + * producer and no consumer, so it is gone rather than unioned in: one shape, + * not two de-facto contracts (Prime Directive #12). + */ + sections: z.array(z.object({ + /** + * Stable section identifier, snake_case. This is the i18n anchor: the + * heading resolves through `objects.._sections..label`, so a + * section WITHOUT a name renders its authored `label` in every locale. + * `packages/lint`'s `translation-section-name-missing` rule exists to tell + * authors to add it, which is why it is declared here — a key one rule + * demands must not be a key the schema rejects. + */ + name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case) — resolves `objects.._sections..label`; a nameless section renders its authored label in every locale'), + /** Heading text. Omit for an untitled section, which renders borderless. */ + label: I18nLabelSchema.optional().describe('Section heading (omit for an untitled, borderless section)'), + /** + * Field-grid width for THIS section; falls back to the renderer's own + * derivation when omitted. + * + * An int range rather than `z.union([z.literal(1), …])` — same accepted set + * (1-4), but the docs generator renders numeric literals as QUOTED strings + * (`'1' | '2'`, see `FormSectionSchema.columns` in `references/ui/view.mdx`), + * which would tell an author to write `columns: '2'` where this key requires + * `2`. Shipping a reference that misdocuments the key is the exact harm + * #5611 is fixing, so the shape that documents itself truthfully wins. + */ + 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 }`.'), 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 + * section's `fields`. Authored by the published `sys_user` platform page and + * read by `RecordDetailsRenderer`; it was simply never declared, so the + * (unvalidated) props bag carried it. Declared now so the enforcement to come + * does not silently strip a live platform page's hidden-field list. + */ + hideFields: z.array(z.string()).optional().describe('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)'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), });