From 0f6e1b66762bf4ca06b5eda38561e93f1f669d32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:14:15 +0000 Subject: [PATCH 01/13] feat(spec)!: object-form customFields and both forms' sections take the ruled shapes (#21464, S-forms) [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...props-form-custom-fields-sections-typed.md | 45 ++ .../18.ui-object-form-custom-fields-typed.ts | 38 ++ .../18.ui-object-form-sections-typed.ts | 44 ++ packages/spec/src/migrations/registry.ts | 103 ++++ ...m-custom-fields-sections-typed.pin.test.ts | 353 ++++++++++++++ ...omponent-props-unknown-members.pin.test.ts | 55 +-- packages/spec/src/ui/component.zod.ts | 460 ++++++++++++++++-- 7 files changed, 1037 insertions(+), 61 deletions(-) create mode 100644 .changeset/21464-component-props-form-custom-fields-sections-typed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts create mode 100644 packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md new file mode 100644 index 00000000000..3cdc4265fe3 --- /dev/null +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -0,0 +1,45 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-form` page block's `customFields` takes a closed runtime form field, and the `sections` of `object-form` and `object-master-detail-form` take a page-block section shape, instead of any value (#21464) + +Clause-②: yes (narrowing) + + + +**BREAKING** — two accept-set narrowings on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. + +**`@objectstack/spec`** + +- **`object-form` `customFields` is a list of closed runtime form fields.** It was `z.unknown()`. Each member is the field the form merges over the fields it generates from the object's metadata and draws as written, and the spec now declares it: `name` (its identity), `label`, `description`, `type`, `inputType`, `widget`, `required`, `disabled`, `readonly`, `hidden`, `placeholder`, `options`, `validation`, `dependsOn`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan`, `span`, `group`, and the metadata a field widget reads off it — `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `returnType`, `summaryOperations`, `columns`. Members this package already declares take that declaration by reference (the object field's own, the form view's option, the evaluated predicates); `label`, `description` and `placeholder` are plain strings. +- **The `sections` of `object-form` and `object-master-detail-form` are one page-block section shape.** They were `z.array(z.unknown())`. A section takes the form view's section keys — `name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`, `columns`, `pane`, `group`, `fields` — and the form view's group-reference rule; each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline runtime form field (the `customFields` member). The stored form view's `FormSectionSchema` is unchanged. +- **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so a form view's parse-time folds do not run there: a section `visibleOn` and a string `columns` reached the form raw and were dropped. Both are refused with the canonical spelling, and so is a `{ field }` entry's or an inline field's `visibleOn`. +- **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, and a locale map where the form draws a plain string. +- **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`. No new export: the shapes are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `customFields: [{ name: 'b', visibleOn: "record.a != ''" }]` | `customFields: [{ name: 'b', visibleWhen: "record.a != ''" }]` | +| `customFields: [{ name: 'b', condition: { field: 'a', equals: 'x' } }]` | `customFields: [{ name: 'b', visibleWhen: "record.a == 'x'" }]` (`notEquals` is `!=`, `in: [ … ]` is `record.a in [ … ]`) | +| `customFields: [{ name: 'memo', defaultValue: 'X' }]` | `customFields: [{ name: 'memo' }], initialValues: { memo: 'X' }` | +| `customFields: [{ name: 'a', validation: { required: true } }]` | `customFields: [{ name: 'a', required: true }]` (a string `validation.required` is the message) | +| `customFields: [{ name: 'a', validation: { pattern: { value, message } } }]` | `customFields: [{ name: 'a', pattern: '^[A-Z]+$' }]` | +| `customFields: [{ name: 'items', type: 'grid', min_rows: 1 }]` | `customFields: [{ name: 'items', type: 'grid', columns: [ … ] }]` — the widget's defaults until it reads a camelCase key | +| `sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }]` | `sections: [{ fields: ['a'], visibleWhen: 'record.b == 1' }]` | +| `sections: [{ fields: ['a'], columns: '2' }]` | `sections: [{ fields: ['a'], columns: 2 }]` | +| `sections: [{ fields: [{ field: 'a', visibleOn: '…' }] }]` | `sections: [{ fields: [{ field: 'a', visibleWhen: '…' }] }]` | +| `sections: [{ label: { en: 'Basics' }, fields: ['a'] }]` | `sections: [{ name: 'basics', label: 'Basics', fields: ['a'] }]` — the heading translates through `objects.._sections.basics.label` | + +The one-line fix: write each inline field in camelCase with the members the form draws and each section in its canonical spelling. No conversion is registered: nothing on the load path refuses either shape, and the census below found no working value to respell — the D3 entries `ui-object-form-custom-fields-typed` and `ui-object-form-sections-typed` carry that judgment. + +## Who is affected, measured + +A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, or the argument of a local helper that mounts one; values resolve through same-file constants and spreads, and every static value was parsed through this branch's rows. Each value with a non-static part was read by hand. + +- **objectstack** at `ced3e1ae47`: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), names only — all parse. No `customFields` writer. +- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (the same writers): every `customFields` writer parses — 31 static values, the designer's object manager among them — but two: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing. Every `sections` writer on either block parses, the plugin-form README's inline-field wizard and the field designer's inline fields included; the refused section values are objectui's probes that a retired `className` / `gridClassName` reaches nothing, and form-view or `record:details` sections, which these rows do not judge. +- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member. +- **Deployed metadata** was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts new file mode 100644 index 00000000000..df1ef382844 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `object-form` page block's `customFields` was `z.unknown()`: +// each member is objectui's runtime form field, which the spec did not declare, +// and objectui's own declaration is open and spells eight of its members in +// snake_case. The maintainer ruled on #21704 (fork 2, letter B) that the spec +// declares a closed runtime form field of the members the form draws, in +// camelCase, keyed by `name`. D3 only: page-component `properties` is not +// parsed on the metadata save or load path, so a stored page is never refused; +// and the authored census found no working member to respell — the refused +// values are a type-level test's `visibleOn` and a fixture pinning that an +// inline `defaultValue` seeds nothing. +export const entry: SemanticMigration = { + id: 'ui-object-form-custom-fields-typed', + surface: 'page `object-form` components — `properties.customFields` (which used to accept any value)', + replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, … }` — the members the ' + + 'form draws, in camelCase. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a ' + + 'member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave the `grid` widget\'s ' + + 'snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.', + reason: 'The form merges `customFields` over the fields it generates from the object\'s metadata — a member ' + + 'naming a declared field replaces that field\'s whole definition, any other is added — and draws each ' + + 'member as it was written, handing it to the field widget as its metadata. The page-component row ' + + 'declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the ' + + 'component-props gate, and the form drew the field without it. The row now takes a closed runtime form ' + + 'field of the members the form draws, keyed by `name`, each typed to its read — by reference where this ' + + 'package already declares the member (the object field\'s metadata members, the form view\'s option, the ' + + 'evaluated predicates). It is read where every page component\'s props are: the component-props gate ' + + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' + + 'found no working member to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. ' + + 'Each form draws every inline field with the label, type and rules its member names.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts new file mode 100644 index 00000000000..33b26e5d7c4 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `sections` of the `object-form` and `object-master-detail-form` +// page blocks were `z.array(z.unknown())`: a section's `fields` draws an inline +// runtime form field beside a name and the form view's `{ field }` entry, which +// the stored form view's section refuses. The maintainer ruled on #21704 (fork +// 3, letter B) a page-block section shape of its own — the form view's section +// keys plus the three entry arms the form reads, canonical spellings only — and +// the stored form view is unchanged. D3 only: page-component `properties` is +// not parsed on the metadata save or load path, so a stored page is never +// refused; and the authored census found no working section to respell — the +// refused values are objectui's probes that a section's retired style keys +// reach nothing. +export const entry: SemanticMigration = { + id: 'ui-object-form-sections-typed', + surface: 'page `object-form` and `object-master-detail-form` components — `properties.sections` (whose ' + + 'entries used to accept any value)', + replacement: 'closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, ' + + 'columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form ' + + 'view\'s `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field ' + + '`visibleOn` as `visibleWhen`, a string `columns: \'2\'` as the number `2`, and a section `label` (or a ' + + 'field entry\'s `label` / `placeholder` / `helpText`) as a plain string.', + reason: 'The form reads a section\'s heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and ' + + '`fields` — the key set of the form view\'s section — and draws three kinds of field entry: a name, the ' + + 'form view\'s `{ field }` entry overriding that object field, and an inline runtime form field drawn as ' + + 'it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the ' + + 'component-props gate and the form drew the section without it; a form view\'s deprecated `visibleOn` ' + + 'and string `columns`, which a form view folds at parse, reached the form raw — a page block\'s ' + + '`properties` is never parsed on the way — and were dropped. Both rows now take one section shape of ' + + 'their own, the stored form view unchanged: the form view\'s section keys plus the three entry arms, ' + + 'canonical spellings only, a label a plain string because the form draws it as it stands, and the form ' + + 'view\'s group-reference rule. It is read where every page component\'s props are: the component-props ' + + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' + + 'found no working section to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' + + 'validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under ' + + '`properties.sections`. Each form draws every section with the heading, visibility and columns it ' + + 'names, and every entry in it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 491e4e4943d..5cc87a6580d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6213,6 +6213,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`columns` untouched) on `object-form` page components, on every form payload a view ' + 'carries, and on the assembled-manifest `viewItems` channel.', }, + { + id: 'ui-object-form-custom-fields-typed', + order: 78, + text: + 'It also types the `object-form` page block\'s `customFields`, one of the two contracts the ' + + '`ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each ' + + 'member is the runtime form field the form draws, which the spec did not declare, so a member with no ' + + '`name` or a misspelled member passed every door and the form drew the field without it. The spec now ' + + 'declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — ' + + 'the `grid` widget\'s snake_case keys stay out until the widget reads a camelCase spelling — and the ' + + 'row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and ' + + 'loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-form-custom-fields-typed`.', + }, { id: 'ui-object-form-fields-names-typed', order: 74, @@ -6243,6 +6257,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + '`ui-object-form-members-typed`.', }, + { + id: 'ui-object-form-sections-typed', + order: 79, + text: + 'It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the ' + + 'other ruled fork: a section\'s `fields` draws an inline runtime form field beside a name and the form ' + + 'view\'s `{ field }` entry, which the stored form view\'s section refuses, so the sections stayed ' + + '`z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape ' + + 'of their own — the form view\'s section keys plus those three entry arms, the inline arm the runtime ' + + 'form field — in canonical spellings only: a page block\'s `properties` is never parsed on the way to ' + + 'the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at ' + + 'parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. ' + + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`.', + }, { id: 'ui-object-gantt-markers-typed', order: 75, @@ -19847,6 +19876,40 @@ const step18: MigrationStep = { + 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — ' + 'check that this is the grouping you meant.', }, + // #21464 — the `object-form` page block's `customFields` was `z.unknown()`: + // each member is objectui's runtime form field, which the spec did not declare, + // and objectui's own declaration is open and spells eight of its members in + // snake_case. The maintainer ruled on #21704 (fork 2, letter B) that the spec + // declares a closed runtime form field of the members the form draws, in + // camelCase, keyed by `name`. D3 only: page-component `properties` is not + // parsed on the metadata save or load path, so a stored page is never refused; + // and the authored census found no working member to respell — the refused + // values are a type-level test's `visibleOn` and a fixture pinning that an + // inline `defaultValue` seeds nothing. + { + id: 'ui-object-form-custom-fields-typed', + surface: 'page `object-form` components — `properties.customFields` (which used to accept any value)', + replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, … }` — the members the ' + + 'form draws, in camelCase. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a ' + + 'member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave the `grid` widget\'s ' + + 'snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.', + reason: 'The form merges `customFields` over the fields it generates from the object\'s metadata — a member ' + + 'naming a declared field replaces that field\'s whole definition, any other is added — and draws each ' + + 'member as it was written, handing it to the field widget as its metadata. The page-component row ' + + 'declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the ' + + 'component-props gate, and the form drew the field without it. The row now takes a closed runtime form ' + + 'field of the members the form draws, keyed by `name`, each typed to its read — by reference where this ' + + 'package already declares the member (the object field\'s metadata members, the form view\'s option, the ' + + 'evaluated predicates). It is read where every page component\'s props are: the component-props gate ' + + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' + + 'found no working member to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. ' + + 'Each form draws every inline field with the label, type and rules its member names.', + }, // #21464 — the top-level `fields` of the `object-form` and // `object-master-detail-form` page blocks was `z.array(z.unknown())`, held // while the form drew a `{ name }` field entry its own page-builder guide @@ -19936,6 +19999,46 @@ const step18: MigrationStep = { + 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed ' + 'sections, the navigation after a save, and the phone presentation.', }, + // #21464 — the `sections` of the `object-form` and `object-master-detail-form` + // page blocks were `z.array(z.unknown())`: a section's `fields` draws an inline + // runtime form field beside a name and the form view's `{ field }` entry, which + // the stored form view's section refuses. The maintainer ruled on #21704 (fork + // 3, letter B) a page-block section shape of its own — the form view's section + // keys plus the three entry arms the form reads, canonical spellings only — and + // the stored form view is unchanged. D3 only: page-component `properties` is + // not parsed on the metadata save or load path, so a stored page is never + // refused; and the authored census found no working section to respell — the + // refused values are objectui's probes that a section's retired style keys + // reach nothing. + { + id: 'ui-object-form-sections-typed', + surface: 'page `object-form` and `object-master-detail-form` components — `properties.sections` (whose ' + + 'entries used to accept any value)', + replacement: 'closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, ' + + 'columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form ' + + 'view\'s `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field ' + + '`visibleOn` as `visibleWhen`, a string `columns: \'2\'` as the number `2`, and a section `label` (or a ' + + 'field entry\'s `label` / `placeholder` / `helpText`) as a plain string.', + reason: 'The form reads a section\'s heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and ' + + '`fields` — the key set of the form view\'s section — and draws three kinds of field entry: a name, the ' + + 'form view\'s `{ field }` entry overriding that object field, and an inline runtime form field drawn as ' + + 'it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the ' + + 'component-props gate and the form drew the section without it; a form view\'s deprecated `visibleOn` ' + + 'and string `columns`, which a form view folds at parse, reached the form raw — a page block\'s ' + + '`properties` is never parsed on the way — and were dropped. Both rows now take one section shape of ' + + 'their own, the stored form view unchanged: the form view\'s section keys plus the three entry arms, ' + + 'canonical spellings only, a label a plain string because the form draws it as it stands, and the form ' + + 'view\'s group-reference rule. It is read where every page component\'s props are: the component-props ' + + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' + + 'found no working section to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' + + 'validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under ' + + '`properties.sections`. Each form draws every section with the heading, visibility and columns it ' + + 'names, and every entry in it.', + }, // #21464 — the `object-gantt` page block's `markers` was `z.array(z.unknown())`: // its element contract lived only in objectui, so a marker with no `date`, a // numeric `date` or a misspelled member passed the component-props gate, and diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts new file mode 100644 index 00000000000..b358479b699 --- /dev/null +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -0,0 +1,353 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21464, the S-forms stage] `object-form` `customFields` and the `sections` of + * `object-form` and `object-master-detail-form`, typed in the shapes the + * maintainer ruled on the decision card #21704 — fork 2, letter B (a closed + * runtime form field of the members the form draws, in camelCase, keyed by + * `name`) and fork 3, letter B (a page-block section shape of its own: the form + * view's section keys plus the three entry arms the form reads, canonical + * spellings only, the stored form view unchanged). + * + * ## The defect this file closes + * + * Both members were `z.unknown()` (a section was `z.array(z.unknown())`), so + * a member with no `name`, a misspelled member or section key, a section + * `visibleOn` and a string `columns` all passed the component-props gate — and + * the form drew the field or the section without the key, since a page + * block's `properties` is never parsed on the way to the form and the form + * reads only `visibleWhen` and a NUMBER `columns` off a section. + * + * ## What is pinned, and why each half + * + * - §1 THE MEASURED WRITERS PARSE: every shape a census writer authors (the + * read points and the census are in the schemas' docblocks and the PR), + * plus lit controls for the members no writer uses yet. A refusal pin with no + * lit control passes just as well when the door refuses everything. + * - §2 THE REFUSALS: by `code` AND `path`, so a refusal for the wrong reason + * reds — the deprecated spellings and the measured-but-refused keys with + * their prescriptions, and a closed shape at every level. + * - §3 ONE DECLARATION: the runtime field declares exactly the draw set; the + * section declares exactly the form view's section keys minus `visibleOn`; + * the `{ field }` arm's members are the form view's entry's own, def by def; + * both rows share one section instance, and the section's inline arm is the + * one runtime field `customFields` takes. + * - §4 THE REGISTRATION: the ADR-0087 D3 entries step 18 carries. + * + * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the + * other half: the `customFields` and both `sections[]` fork lines left its + * ledger, so a member reverted to `z.unknown()` reds there. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { + ComponentPropsMap, + ObjectFormPropsSchema, + ObjectMasterDetailFormPropsSchema, +} from './component.zod'; +import { FormFieldSchema, FormSectionSchema } from './view.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +type Row = 'object-form' | 'object-master-detail-form'; +const ROWS: readonly Row[] = ['object-form', 'object-master-detail-form']; +const BASE: Record> = { + 'object-form': { objectName: 'account' }, + 'object-master-detail-form': { objectName: 'invoice' }, +}; +const parse = (row: Row, props: Record) => + ComponentPropsMap[row].safeParse({ ...BASE[row], ...props }); + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} +const firstMessage = (result: z.ZodSafeParseResult): string => + (result.success ? '' : result.error.issues[0]!.message); + +/** Every message in the issue tree, union arms included. */ +function messages(result: z.ZodSafeParseResult): string { + const walk = (list: readonly z.core.$ZodIssue[]): string[] => + list.flatMap((i) => [i.message, ...((i as { errors?: z.core.$ZodIssue[][] }).errors ?? []).flatMap(walk)]); + return result.success ? '' : walk(result.error.issues).join('\n'); +} + +/** The object behind a member: through `.optional()` and an array's element. */ +function objectOf(member: unknown): { shape: Record } { + let s = member as { unwrap?: () => unknown; element?: unknown; shape?: Record }; + for (;;) { + if (typeof s.unwrap === 'function') s = s.unwrap() as typeof s; + else if (s.element) s = s.element as typeof s; + else break; + } + return s as { shape: Record }; +} + +const runtimeField = () => objectOf(ObjectFormPropsSchema.shape.customFields); +const section = () => objectOf(ObjectFormPropsSchema.shape.sections); +/** The section `fields` entry union's three arms: name, `{ field }` entry, inline field. */ +const entryArms = () => { + const union = objectOf(section().shape.fields) as unknown as { options: unknown[] }; + return union.options; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the measured writers parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 each member accepts every shape a measured writer authors', () => { + // Byte-identical: no default, and no predicate in these values. + const IDENTICAL: ReadonlyArray]> = [ + // objectui `plugin-form/src/__tests__/drawerModalCustomFieldsMerge-10073.test.tsx:112`. + ['an inline field overriding a declared one', 'object-form', { customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text' }] }], + // `drawerModalCustomFieldsMerge-10073.test.tsx:160` — placed in its field group. + ['an inline field with its field group', 'object-form', { customFields: [{ name: 'channel', label: 'INLINE CHANNEL', type: 'text', group: 'tracking' }] }], + // `sectionsCustomFields-10254.test.tsx:148`. + ['a required textarea', 'object-form', { customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'textarea', required: true }] }], + // `ObjectForm.mobileFullscreen.test.tsx:173`. + ['a widget field with rows and a placeholder', 'object-form', { + customFields: [{ name: 'body', label: 'Body', type: 'field:textarea', rows: 9, placeholder: 'Say more' }], + }], + // `types/src/__tests__/p1-spec-alignment.test.ts:348`, its first member. + ['a field with a widget override', 'object-form', { + customFields: [{ name: 'industry', label: 'Industry', type: 'select', widget: 'industry-picker' }], + }], + // objectui `content/docs/guide/public-forms.md:33`. + ['the public-form guide\'s three fields', 'object-form', { + customFields: [ + { name: 'name', label: 'Full name', type: 'text', required: true }, + { name: 'email', label: 'Email', type: 'email', required: true }, + { name: 'message', label: 'Message', type: 'textarea', rows: 4 }, + ], + }], + // `objectFormCustomFieldsMembers-8071.test.tsx:174`. + ['no inline fields', 'object-form', { customFields: [] }], + // Lit controls: drawn members no measured writer uses yet. + ['a select with options and a cascade', 'object-form', { + customFields: [{ + name: 'state', label: 'State', type: 'select', dependsOn: 'country', + options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], + }], + }], + ['validation rules and native bounds', 'object-form', { + customFields: [{ + name: 'code', type: 'input', inputType: 'text', minLength: 2, maxLength: 8, pattern: '^[A-Z]+$', min: 1, max: 9, + validation: { required: 'Code is required', minLength: { value: 2, message: 'Too short' } }, + required: true, disabled: false, readonly: false, hidden: false, colSpan: 2, span: 'full', description: 'Two to eight letters', + }], + }], + ['the widget metadata members', 'object-form', { + customFields: [ + { name: 'files', type: 'file', multiple: true, accept: ['image/*', '.pdf'] }, + { name: 'owner', type: 'lookup', reference: 'sys_user' }, + { name: 'embedding', type: 'vector', dimensions: 768 }, + { name: 'margin', type: 'formula', returnType: 'number' }, + { name: 'lines', type: 'summary', summaryOperations: { object: 'line', field: 'amount', function: 'sum' } }, + { name: 'items', type: 'grid', columns: [{ name: 'product', type: 'text' }, { name: 'qty', type: 'number' }] }, + ], + }], + // objectstack `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts:60`. + ['the showcase wizard\'s sections', 'object-form', { + sections: [ + { label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] }, + { label: 'Health', description: 'Set the status and the budget.', fields: ['status', 'budget'] }, + ], + }], + // objectui `plugin-form/README.md:776` — the data-source-free wizard's inline fields ("shape 3"). + ['inline fields in a section', 'object-form', { + sections: [{ + name: 'contact', label: 'Contact', + fields: [ + { name: 'firstName', type: 'input', label: 'First Name', required: true }, + { name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true }, + { name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }, + ], + }], + }], + // objectui `cli/src/__tests__/spec-vocabulary-hint.test.ts:54` — the form view's `{ field }` entry. + ['the form view\'s `{ field }` entry', 'object-form', { sections: [{ label: 'Owner', fields: ['name', { field: 'owner', required: true }] }] }], + // `__tests__/formSectionGroupReference-7051.test.tsx` — the group-reference form, with its layout keys. + ['a group section', 'object-form', { sections: [{ group: 'contact_info', columns: 2 }, { label: 'Other', fields: ['note'] }] }], + // `__tests__/collapseResolution-9849.test.tsx`, `sectionColumns.test.tsx`, the split form's `pane`. + ['the collapse pair, columns and pane', 'object-form', { + sections: [ + { name: 'money', label: 'Money', collapsible: true, collapsed: true, columns: 2, pane: 'primary', fields: ['amount'] }, + { name: 'rest', label: 'Rest', columns: 1, pane: 'secondary', fields: ['note'] }, + ], + }], + ['no sections', 'object-form', { sections: [] }], + // objectui `plugin-form/src/masterDetailFormTypeVocabulary.test.tsx` — the parent half's sections. + ['the parent half\'s sections', 'object-master-detail-form', { sections: [{ name: 'header', label: 'Header', fields: ['customer', 'date'] }] }], + ]; + for (const [label, row, props] of IDENTICAL) { + it(`${row}: parses ${label} byte-identical`, () => { + const r = parse(row, props); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE[row], ...props }); + }); + } + + it('a bare CEL predicate parses to its envelope, as on every evaluated slot — and the envelope is kept', () => { + const r = parse('object-form', { + sections: [{ fields: ['a', { field: 'b', visibleWhen: 'record.x == 1' }], visibleWhen: 'record.y == 2' }], + customFields: [{ name: 'c', visibleWhen: { dialect: 'cel', source: 'record.z == 3' }, requiredWhen: 'record.y == 2' }], + }); + expect(issues(r)).toEqual([]); + const data = r.success ? (r.data as Record) : {}; + expect(data.sections[0].visibleWhen).toEqual({ dialect: 'cel', source: 'record.y == 2' }); + expect(data.sections[0].fields[1].visibleWhen).toEqual({ dialect: 'cel', source: 'record.x == 1' }); + expect(data.customFields[0].visibleWhen).toEqual({ dialect: 'cel', source: 'record.z == 3' }); + expect(data.customFields[0].requiredWhen).toEqual({ dialect: 'cel', source: 'record.y == 2' }); + }); + + it('absent members stay absent', () => { + for (const row of ROWS) { + const r = parse(row, {}); + expect(issues(r), row).toEqual([]); + expect(r.success && r.data, row).not.toHaveProperty('sections'); + expect(r.success && r.data, row).not.toHaveProperty('customFields'); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the refusals +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 off-shape values are refused with the code and the path', () => { + const REFUSED: ReadonlyArray, found: ReadonlyArray<{ code: string; path: string }>]> = [ + ['a number for `customFields`', 'object-form', { customFields: 42 }, [{ code: 'invalid_type', path: 'customFields' }]], + ['an inline field with no name', 'object-form', { customFields: [{ label: 'X' }] }, [{ code: 'invalid_type', path: 'customFields.0.name' }]], + ['an empty name', 'object-form', { customFields: [{ name: '' }] }, [{ code: 'too_small', path: 'customFields.0.name' }]], + ['a misspelled member', 'object-form', { customFields: [{ name: 'a', lable: 'A' }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + // objectui `types/src/__tests__/p1-spec-alignment.test.ts:348` — a type-level test, never drawn. + ['an inline `visibleOn`', 'object-form', { customFields: [{ name: 'a', visibleOn: "record.b != ''" }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + ['the legacy `condition`', 'object-form', { customFields: [{ name: 'a', condition: { field: 'b', equals: 'x' } }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + // objectui `__tests__/initialRecordMerge-9760.test.tsx:236` — row 7 pins that it seeds nothing. + ['an inline `defaultValue`', 'object-form', { customFields: [{ name: 'memo', defaultValue: 'X' }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + ['an `id`', 'object-form', { customFields: [{ name: 'a', id: 'a1' }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + ['a divider\'s member claim', 'object-form', { customFields: [{ name: 'a', fields: ['b'] }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + ['a grid widget snake_case key', 'object-form', { customFields: [{ name: 'items', type: 'grid', min_rows: 1 }] }, [{ code: 'unrecognized_keys', path: 'customFields.0' }]], + ['a locale-map label on an inline field', 'object-form', { customFields: [{ name: 'a', label: { en: 'A' } }] }, [{ code: 'invalid_type', path: 'customFields.0.label' }]], + ['a `validation.required` switch', 'object-form', { customFields: [{ name: 'a', validation: { required: true } }] }, [{ code: 'invalid_type', path: 'customFields.0.validation.required' }]], + ['a `validation.pattern` rule', 'object-form', { customFields: [{ name: 'a', validation: { pattern: { value: '^a', message: 'x' } } }] }, [{ code: 'unrecognized_keys', path: 'customFields.0.validation' }]], + ['a bare `validation.minLength`', 'object-form', { customFields: [{ name: 'a', validation: { minLength: 2 } }] }, [{ code: 'invalid_type', path: 'customFields.0.validation.minLength' }]], + ['a column span past the grid', 'object-form', { customFields: [{ name: 'a', colSpan: 5 }] }, [{ code: 'too_big', path: 'customFields.0.colSpan' }]], + ['a malformed field group key', 'object-form', { customFields: [{ name: 'a', group: 'Contact Info' }] }, [{ code: 'invalid_format', path: 'customFields.0.group' }]], + ['a number for `sections`', 'object-form', { sections: 42 }, [{ code: 'invalid_type', path: 'sections' }]], + ['a section `visibleOn`', 'object-form', { sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }] }, [{ code: 'unrecognized_keys', path: 'sections.0' }]], + ['a string `columns`', 'object-form', { sections: [{ fields: ['a'], columns: '2' }] }, [{ code: 'invalid_type', path: 'sections.0.columns' }]], + ['five columns', 'object-form', { sections: [{ fields: ['a'], columns: 5 }] }, [{ code: 'too_big', path: 'sections.0.columns' }]], + ['a locale-map section label', 'object-form', { sections: [{ fields: ['a'], label: { en: 'A' } }] }, [{ code: 'invalid_type', path: 'sections.0.label' }]], + // objectui `__tests__/sectionStyleKeysRetired-13626.test.tsx` — the retired style keys reach nothing. + ['a section style key', 'object-form', { sections: [{ fields: ['a'], className: 'p-4' }] }, [{ code: 'unrecognized_keys', path: 'sections.0' }]], + ['a section with neither `fields` nor `group`', 'object-form', { sections: [{ label: 'Empty' }] }, [{ code: 'custom', path: 'sections.0.fields' }]], + ['`group` beside `fields`', 'object-form', { sections: [{ group: 'contact_info', fields: ['a'] }] }, [{ code: 'custom', path: 'sections.0.group' }]], + ['a group-owned key beside `group`', 'object-form', { sections: [{ group: 'contact_info', label: 'Contact' }] }, [{ code: 'custom', path: 'sections.0.label' }]], + ['an unknown pane', 'object-form', { sections: [{ fields: ['a'], pane: 'left' }] }, [{ code: 'invalid_value', path: 'sections.0.pane' }]], + ['a numeric entry', 'object-form', { sections: [{ fields: [5] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], + ['a `{ field }` entry\'s `visibleOn`', 'object-form', { sections: [{ fields: [{ field: 'a', visibleOn: 'record.b == 1' }] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], + ['an inline entry\'s unknown key', 'object-form', { sections: [{ fields: [{ name: 'a', bogus: 1 }] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], + ['a master-detail section `visibleOn`', 'object-master-detail-form', { sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }] }, [{ code: 'unrecognized_keys', path: 'sections.0' }]], + ]; + for (const [label, row, props, found] of REFUSED) { + it(`${row}: refuses ${label}`, () => { + const r = parse(row, props); + expect(r.success).toBe(false); + expect(issues(r)).toEqual(found); + }); + } + + it('each refused inline key carries its prescription', () => { + const say = (member: Record) => firstMessage(parse('object-form', { customFields: [{ name: 'a', ...member }] })); + expect(say({ visibleOn: 'record.b == 1' })).toMatch(/write the predicate as `visibleWhen`/i); + expect(say({ condition: { field: 'b', equals: 'x' } })).toMatch(/`visibleWhen: "record\.status == 'open'"`/); + expect(say({ defaultValue: 'X' })).toMatch(/block's\s+`initialValues`/); + expect(say({ id: 'a1' })).toMatch(/identified by its `name`/); + expect(say({ fields: ['b'] })).toMatch(/Group fields with the block's `sections`/); + expect(say({ min_rows: 1 })).toMatch(/snake_case field-level keys/); + expect(say({ helpText: 'x' })).toMatch(/`helpText` → `description`/); + expect(say({ validation: { required: true } })).toMatch(/the MESSAGE a required field shows/); + expect(say({ validation: { pattern: { value: '^a', message: 'x' } } })).toMatch(/field's own `pattern` string/); + }); + + it('each refused section spelling carries the canonical one', () => { + expect(firstMessage(parse('object-form', { sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }] }))) + .toMatch(/gated nothing\. Write it as `visibleWhen`/); + expect(firstMessage(parse('object-form', { sections: [{ fields: ['a'], columns: '2' }] }))) + .toMatch(/write `2`, not `'2'`/); + expect(messages(parse('object-form', { sections: [{ fields: [{ field: 'a', visibleOn: 'record.b == 1' }] }] }))) + .toMatch(/takes the\s+canonical spelling only: write `visibleWhen`/); + expect(messages(parse('object-form', { sections: [{ fields: [{ field: 'a', name: 'a' }] }] }))) + .toMatch(/keyed by `field`, or an inline form field keyed by `name` — not both/); + }); + + it('a non-numeric string `columns` carries no prescription — only the four spellings a form view converts', () => { + expect(firstMessage(parse('object-form', { sections: [{ fields: ['a'], columns: 'wide' }] }))).not.toMatch(/write `/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one declaration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the declared members, and one shape for both rows', () => { + it('the runtime form field declares exactly the members the form draws', () => { + expect(Object.keys(runtimeField().shape).sort()).toEqual([ + 'accept', 'colSpan', 'columns', 'dependsOn', 'description', 'dimensions', 'disabled', 'group', 'hidden', + 'inputType', 'label', 'max', 'maxLength', 'min', 'minLength', 'multiple', 'name', 'options', 'pattern', + 'placeholder', 'readonly', 'readonlyWhen', 'reference', 'required', 'requiredWhen', 'returnType', 'rows', + 'span', 'summaryOperations', 'type', 'validation', 'visibleWhen', 'widget', + ]); + }); + + it('no member of it is spelled snake_case', () => { + expect(Object.keys(runtimeField().shape).filter((k) => /_/.test(k))).toEqual([]); + }); + + it('a section declares exactly the form view\'s section keys, without the deprecated `visibleOn`', () => { + // `FormSectionSchema` is its object piped into the `visibleOn` fold; read the object half. + const viewKeys = Object.keys((FormSectionSchema as unknown as { in: { shape: Record } }).in.shape); + expect(viewKeys).toContain('visibleOn'); + expect(Object.keys(section().shape).sort()).toEqual(viewKeys.filter((k) => k !== 'visibleOn').sort()); + }); + + it('the `{ field }` arm\'s members are the form view\'s entry\'s own, def by def, but for the four the page block reads differently', () => { + const view = (FormFieldSchema as unknown as { in: { shape: Record } }).in.shape; + const arm = (entryArms()[1] as { shape: Record }).shape; + const own = ['label', 'placeholder', 'helpText', 'span', 'fields']; + expect(Object.keys(arm).sort()).toEqual(Object.keys(view).filter((k) => k !== 'visibleOn').sort()); + for (const key of Object.keys(arm)) { + if (own.includes(key)) continue; + expect(arm[key]!._zod.def, key).toBe(view[key]!._zod.def); + } + // `span` keeps the view's enum, without the default the form view fills. + const span = arm.span as unknown as { unwrap: () => { _zod: { def: unknown } } }; + const viewSpan = view.span as unknown as { unwrap: () => { _zod: { def: unknown } } }; + expect(span.unwrap()._zod.def).toBe(viewSpan.unwrap()._zod.def); + }); + + it('a section\'s inline arm is the one runtime field `customFields` takes', () => { + expect(entryArms()[2]).toBe(runtimeField()); + }); + + it('both rows take the one section instance', () => { + expect(objectOf(ObjectMasterDetailFormPropsSchema.shape.sections)).toBe(section()); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 each narrowing is registered as the ADR-0087 D3 entry step 18 carries', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); + it.each([ + 'ui-object-form-custom-fields-typed', + 'ui-object-form-sections-typed', + ])('%s', (id) => { + expect(ids).toContain(id); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 1a874189f1d..94361ab908b 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -48,9 +48,12 @@ * `object-timeline` `mapping`, and the field-name `fields` of `object-form` * and `object-master-detail-form`) in * `component-objectui-held-typed-members.pin.test.ts`; the rest of them are - * held below as forks — the drill-down's `report`, the form's `customFields` - * and both forms' `sections`, the timeline's `items` and the action - * containers' members. The one member this ledger held for a ruling, + * held below as forks — the drill-down's `report`, the timeline's `items` and + * the action containers' members. Two of that stage's forks were ruled and + * typed in the S-forms stage — the form's `customFields` and both forms' + * `sections` — and are pinned in + * `component-form-custom-fields-sections-typed.pin.test.ts`; the predicate ASTs + * and the roll-up filter inside them are lines here. The one member this ledger held for a ruling, * `object-kanban` `conditionalFormatting`, exited its hold once objectui's * kanban declared the list view's rule as its only dialect, and is pinned * in §5 below, where its hold was recorded. @@ -147,7 +150,7 @@ function unknownMembers(schema: unknown): UnknownMember[] { * would refuse a measured writer is reported, not shipped. */ const STAGES = { - 'fork': 'element contracts whose declaration is still objectui\'s (the timeline items, `UIActionSchema` — an objectui interface that borrows some members from the spec `Action` — and the runtime form field `FormField`, identity key `name`, which a form section\'s inline entry is too) and the metric drill-down\'s `report`: the S-objectui-held stage, the last of #21464, measured each and found two or more viable spec shapes that no ruling decides, so under the stop valve each is held and its fork reported on the card with its census; the member is typed once a ruling picks a shape', + 'fork': 'element contracts whose declaration is still objectui\'s (the timeline items, and `UIActionSchema` — an objectui interface that borrows some members from the spec `Action`) and the metric drill-down\'s `report`: the S-objectui-held stage, the last of #21464, measured each and found two or more viable spec shapes that no ruling decides, so under the stop valve each is held and its fork reported on the card with its census; the member is typed once a ruling picks a shape (the runtime form field and the form sections were ruled, and typed in the S-forms stage)', } as const; type Stage = keyof typeof STAGES; @@ -191,6 +194,11 @@ const BULK_OPTION_ENTRY: Reason = { owner: 'ui/bulk-action.zod.ts `BulkActionDefSchema` `params[].options[]`', why: 'a deliberately open option entry (`.passthrough()`): the widget reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared `{ label, value }` pair', }; +const FILTER_CONDITION: Reason = { + kind: 'shared', + owner: 'data/filter.zod.ts `FilterConditionSchema`', + why: 'a summary field\'s roll-up `filter` is a query `where` condition: each key names a field of the CHILD object and each value is its comparand, which the filter schema judges with its own refinement (`checkFilterConditionComparands`) and no page-component row can know', +}; const RECORDS: Reason = { kind: 'records' }; const SLOT: Reason = { kind: 'slot' }; const RUNNER: Reason = { kind: 'runner' }; @@ -222,6 +230,18 @@ on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-grid'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); +// The S-forms stage: an inline form field (`customFields[]`, and a section's +// inline entry) and the form view's `{ field }` entry carry the three `*When` +// predicates, an option's `visibleWhen` and a grid column's two rules; a +// section carries its own `visibleWhen`. +const FORM_FIELD_AST_PATHS = [ + 'visibleWhen.ast', 'readonlyWhen.ast', 'requiredWhen.ast', 'options[].visibleWhen.ast', + 'columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast', +]; +on(['object-form'], FORM_FIELD_AST_PATHS.map((p) => `customFields[].${p}`), EXPRESSION_AST); +on(['object-form', 'object-master-detail-form'], [ + 'sections[].visibleWhen.ast', ...FORM_FIELD_AST_PATHS.map((p) => `sections[].fields[].${p}`), +], EXPRESSION_AST); // The action blocks' runner-forwarded members. on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER); @@ -241,6 +261,8 @@ on(['object-grid'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].defa // is the row's own values (`plugin-kanban/src/index.tsx:155`, kept verbatim). on(['object-kanban'], ['columns[].cards[].*'], RECORDS); on(['object-grid'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); +on(['object-form'], ['customFields[].summaryOperations.filter{}'], FILTER_CONDITION); +on(['object-form', 'object-master-detail-form'], ['sections[].fields[].summaryOperations.filter{}'], FILTER_CONDITION); // The rest, one line each. on(['element:definition-list'], ['items[].description'], { @@ -269,31 +291,6 @@ on(['object-metric'], ['drillDown.report'], fork( 'a drill-report shape of its own, the two arms the drawer draws', ], )); -// The form's inline members are objectui's runtime form field (`FormField`, -// identity key `name`), merged over the generated set and drawn whole; the spec -// declares no such field — its own form field is keyed by `field`, and the -// merge never matches it. objectui's field is open (an index signature) and -// eight of its forty-five members are the grid widget's snake_case keys. -on(['object-form'], ['customFields'], fork( - 'plugin-form/src/customFieldsMerge.ts:78-108 (`FormField`, by `name`), from ObjectForm.tsx:755, :1180', - [ - 'objectui\'s `FormField` as it stands, open, with its snake_case grid keys', - 'a closed spec runtime field of the members a form draws, the grid keys camelCased or left out', - 'the spec\'s own `FormFieldSchema` re-keyed by `name`', - ], -)); -// A section's `fields` draws, beside a name and the form view's `{ field }` -// entry, an inline runtime form field as it stands ("shape 3") — kept by -// objectui#11550's ruling and declared by objectui -// (`ObjectFormSection.fields: (string | FormField)[]`). So this member takes a -// shape once the spec declares the runtime form field: `customFields`'s fork. -on(['object-form', 'object-master-detail-form'], ['sections[]'], fork( - 'plugin-form/src/sectionFields.ts:369-370 (shape 3), reached from ObjectForm.tsx:364, :1518 and every sectioned arm; the master-detail form hands it on at MasterDetailForm.tsx:1692', - [ - 'the form view\'s `FormSectionSchema`, its field entry widened by the runtime form field `customFields` declares', - 'a page-block section shape of its own, the form view\'s section keys plus the three entry arms the form reads', - ], -)); // The authored timeline entry is objectui's (`TimelineFeedItem` / // `TimelineGanttItem`): a feed entry's `content` is child schema nodes, and the // arm an entry must match is chosen by the parent's `variant`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 07b494b0f34..a132b878260 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -40,6 +40,13 @@ import { // union): both form renderers switch on exactly that union, so one // declaration judges the form view and the block. FormViewSchema, + // [#21464] A form section's `{ field }` entry is the form view's own field + // entry, member by member, and an inline field's `options` the form view's own + // option — see `objectFormSectionFieldEntry()` and `objectFormRuntimeField()`. + FormFieldSchema, + FormSelectOptionSchema, + type FormField, + type FormFieldInput, } from './view.zod'; // [#21445] `object-grid.bulkActionDefs` is the list view's bulk-action def, // by identity — the element `ListViewSchema.bulkActionDefs` declares. @@ -85,8 +92,10 @@ import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../share // [#20928] `object-master-detail-form`'s `details[].columns` is the SAME inline // grid column a relationship field's `inlineColumns` and a form view's // `subforms[].columns` take, referenced rather than copied: all three carriers -// feed one objectui grid. -import { InlineGridColumnSchema } from '../data/field.zod'; +// feed one objectui grid. [#21464] An inline `object-form` field's metadata +// members a field widget reads off it (`rows`, `accept`, `reference`, …) take the +// object field's own member schemas, by reference — see `objectFormRuntimeField()`. +import { InlineGridColumnSchema, FieldSchema } from '../data/field.zod'; // [#21589] The child field names the renderer derives a detail's line-position // field from — the one list the retired detail-entry `sortField`'s // prescriptions print (reached by relative import only, never the barrel). @@ -5935,6 +5944,402 @@ const formFieldNameList = () => z.array(z.string({ error: (issue) => formFieldNameRefusal(issue.input), })); +// --------------------------------------------------------------------------- +// [#21464] `object-form` `customFields` and both forms' `sections` — the two +// contracts the S-objectui-held stage held as forks, in the shapes the +// maintainer ruled on the decision card #21704 (record 5978663135): fork 2, +// letter B (a closed runtime form field) and fork 3, letter B (a page-block +// section shape of its own). Read points are at the `.objectui-sha` pin +// `2e818d0b51ec`; each cited reader file is byte-identical at objectui `main` +// `b92329c894`. +// --------------------------------------------------------------------------- + +/** + * The `grid` widget's eight field-level keys, spelled snake_case. + * + * The widget reads them off a `type: 'grid'` field (`GridFieldMetadata`, + * `fields/src/widgets/GridField.tsx:588`), so the form does draw them; they are + * left out of {@link objectFormRuntimeField} by the ruling itself, because this + * package spells configuration keys in camelCase. They come in once the widget + * reads a camelCase spelling (objectstack-ai/objectui#11610 carries the rename). + */ +const OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS = [ + 'min_rows', 'max_rows', 'allow_add', 'allow_delete', 'allow_reorder', 'total_field', 'add_label', 'sort_field', +] as const; + +/** What an inline form field's undeclared keys used to cost. */ +const OBJECT_FORM_RUNTIME_FIELD_HISTORY = + 'Until this shape was declared, an inline form field was `z.unknown()`: a misspelled member passed, and ' + + 'the form drew the field without it.'; + +/** + * [#21464] An inline field's `validation` block — the rules react-hook-form + * runs off it, as the form renderer reads them + * (`components/src/renderers/form/form.tsx:2795-2895`): `minLength`, + * `maxLength`, `min` and `max` are spread into the field's rules as + * `{ value, message }` objects, and `required` is read only as the MESSAGE a + * required field shows (`:2847`) — the renderer deletes the rule itself + * (`:2843`) and decides required-ness from the field's own `required` / + * `requiredWhen`, so a boolean here was dropped. objectui declares the same + * four `{ value, message }` rules (`FieldValidationRules`, + * `types/src/form.ts:1568`); its `pattern` rule needs a compiled `RegExp`, which + * JSON cannot carry, and its `validate` is a function — both are refused with + * the spelling that works. + */ +function objectFormRuntimeFieldValidation() { + const rule = (what: string) => strictObject({ + surface: `this inline form field's \`validation.${what}\` rule`, + history: OBJECT_FORM_RUNTIME_FIELD_HISTORY, + }, { + value: z.number().describe(`The ${what === 'minLength' || what === 'maxLength' ? 'character count' : 'value'} the rule checks against`), + message: z.string().describe('The message shown when the rule fails'), + }); + return strictObject({ + surface: 'this inline form field\'s `validation` block', + history: OBJECT_FORM_RUNTIME_FIELD_HISTORY, + guidance: { + pattern: + 'A `validation.pattern` rule runs only with a compiled `RegExp` value, which JSON cannot carry, so a ' + + 'written one checked nothing. Write the pattern as the field\'s own `pattern` string instead — the ' + + 'browser enforces it on the input at submit.', + validate: + 'A `validation.validate` rule is a function, which metadata cannot carry. Write the check as ' + + '`minLength` / `maxLength` / `min` / `max` here, as the field\'s `pattern`, or as a `requiredWhen` ' + + 'predicate.', + }, + }, { + required: z.string({ + error: (issue) => (typeof issue.input === 'boolean' + ? '`validation.required` is the MESSAGE a required field shows, not a switch — the form decides ' + + 'whether the field is required from its own `required` (or `requiredWhen`) and drops a boolean here. ' + + 'Write `required: true` on the field, and put a message here only to replace the default one.' + : undefined), + }).optional().describe('The message a required field shows when it is left empty (the field\'s own `required` decides whether it is required)'), + minLength: rule('minLength').optional().describe('Minimum character count, with its message'), + maxLength: rule('maxLength').optional().describe('Maximum character count, with its message'), + min: rule('min').optional().describe('Minimum value, with its message'), + max: rule('max').optional().describe('Maximum value, with its message'), + }); +} + +/** + * [#21464] The runtime form field — one entry of an `object-form`'s + * `customFields`, and the inline arm of a form section's `fields` — CLOSED, in + * camelCase, holding only the members the form draws (decision card #21704, + * fork 2, letter B). Its identity key is `name`. + * + * ## How the member reaches a draw + * + * `customFields` is merged over the fields generated from the object's metadata + * (`plugin-form/src/customFieldsMerge.ts:78-108`, called from + * `ObjectForm.tsx:1178-1185` and from every other `formType` arm): a member + * naming a generated field replaces its WHOLE definition, and any other member + * is appended. A section entry that is not the form view's `{ field }` entry is + * drawn as it stands (`sectionFields.ts:369-370`). Either way the field reaches + * the form renderer as it was written, and the renderer hands it to the field + * widget as its metadata carrier (`form.tsx:3171`, `field.field || field` — an + * inline field stashes no object field). + * + * ## The draw set, read member by member (not transcribed from objectui's `FormField`) + * + * - **the field row** (`form.tsx` `renderFormField`, `:2675-2693`): `name`, + * `label`, `description`, `type`, `widget` (`:2915-2918`, ahead of `type`), + * `required`, `disabled`, `readonly`, `hidden` (`:2696`), `validation` + * (`:2795`), `visibleWhen` / `readonlyWhen` / `requiredWhen` (`:2732`), + * `colSpan` (`:2988`), `placeholder` (`:3185`), `inputType` (`:3174`, the + * built-in input's `type`), `options` and `dependsOn` (`:2935-2941`, the + * cascading option list) and `multiple` (`:2917`); + * - **the layout** (`plugin-form/src/autoLayout.ts:162-172`): `span` and + * `colSpan`; and `group` (`fieldGroups.ts:52`), which places the field in the + * object's declared field group when the form derives its sections from them; + * - **the field widgets**, off the carrier: `rows` (`TextAreaField.tsx:102`), + * `accept` and `multiple` (`FileField.tsx:147-148`), `dimensions` + * (`VectorField.tsx:11`), `reference` (`LookupField.tsx:326`), `min` / `max` + * (`NumberField.tsx:88-89`), `minLength` / `maxLength` (`form.tsx:4080`, + * `:4146`), `pattern` (the built-in input's attribute), `returnType` + * (`FormulaField.tsx:22`), `summaryOperations` (`SummaryField.tsx:15`) and + * `columns` (`GridField.tsx:589`). + * + * Where this package already declares the member, its value schema is taken + * by reference — the object field's (`FieldSchema`) for the widget metadata + * members, the form view's option for `options`, and the evaluated predicate + * for the three `*When` rules. `label`, `description` and `placeholder` are + * plain strings: the renderer draws each as it is, so an inline locale map + * would be a React child. + * + * ## Read, and refused anyway + * + * - the `grid` widget's snake_case keys ({@link OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS}), + * by the ruling; + * - `visibleOn` (`form.tsx:2767`) and the legacy `condition` (`:2717`): two + * more spellings of the conditional-visibility predicate, which this package + * spells `visibleWhen` (ADR-0089); + * - `id`: the renderer keys the row by `id ?? name` (`:2898`), and `name` is + * already unique in the drawn list (the merge keeps the first member of a + * name), so it adds nothing; + * - `fields`: the member claim of the section-divider row the form builds from + * a section, not a member of a field. + * + * Measured outside objectui's 45 members: `group` (above) is declared, and + * `defaultValue` is refused — an inline field's default seeds nothing (the + * form opens on `initialValues` and on the object's declared defaults, + * `schemaDefaults.ts`; pinned by `initialRecordMerge-9760.test.tsx` row 7). + * + * Declared once, built once: both the `object-form` row and the shared section + * shape take this one instance. A factory the rows call, not a + * {@link lazySchema}, for the reason {@link objectGanttMarker} gives. + */ +function buildObjectFormRuntimeField() { + return strictObject({ + surface: 'this inline form field', + history: OBJECT_FORM_RUNTIME_FIELD_HISTORY, + aliases: { helpText: 'description' }, + guidance: { + visibleOn: + '`visibleOn` is not a key of an inline form field: the conditional-visibility predicate is spelled ' + + '`visibleWhen` (ADR-0089). Write the predicate as `visibleWhen`; where both were written, join them ' + + 'with `&&` in one `visibleWhen` — the form shows the field only when both hold.', + condition: + '`condition` is the form\'s legacy structured visibility rule (`{ field, equals | notEquals | in }`), ' + + 'not part of this contract: the conditional-visibility predicate is `visibleWhen` (ADR-0089), CEL ' + + 'over `record`. Write `{ field: \'status\', equals: \'open\' }` as ' + + '`visibleWhen: "record.status == \'open\'"`, `notEquals` as `!=`, and `in: [ … ]` as ' + + '`record.FIELD in [ … ]`.', + defaultValue: + 'An inline field\'s `defaultValue` seeds nothing: the form opens on the block\'s `initialValues` and on ' + + 'the object\'s own field defaults, never on an inline field\'s. Write the value in the block\'s ' + + '`initialValues` instead (`initialValues: { FIELD: VALUE }`).', + id: + 'An inline form field is identified by its `name` — the form keys each field by it, and two inline ' + + 'fields never share one — so `id` adds nothing. Delete it.', + fields: + '`fields` on a form field is the member list of the section-divider row the form builds from a ' + + 'section; it is not written on a field. Group fields with the block\'s `sections` instead.', + field: + '`field` is the identity key of the form view\'s section entry (`{ field: \'email\', … }`, which only ' + + 'a section\'s `fields` takes); an inline form field is keyed by `name`. Write one or the other.', + }, + guidanceSets: [{ + name: 'OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS', + keys: OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS, + prescription: + 'This is one of the `grid` widget\'s snake_case field-level keys (`min_rows`, `max_rows`, `allow_add`, ' + + '`allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field`). This contract spells ' + + 'configuration keys in camelCase, and these come in once the widget reads a camelCase spelling; until ' + + 'then a `grid` field takes its `columns` and the widget\'s own defaults.', + }], + }, { + name: z.string().min(1).describe('The field\'s name: its identity, and the key its value is submitted under. A member naming a field the object declares replaces that field\'s whole definition'), + label: z.string().optional().describe('The label drawn beside the control (a plain string)'), + description: z.string().optional().describe('Help text drawn under the control'), + type: z.string().optional().describe('The widget the field renders as — a built-in input type (`input`, `textarea`, `select`, …) or a field widget (`email`, `field:markdown`, …); the renderer\'s default input when omitted'), + inputType: z.string().optional().describe('The HTML `type` of the built-in input (`email`, `tel`, `number`, `date`, …)'), + widget: z.string().optional().describe('A widget to render instead of the one `type` resolves to'), + required: z.boolean().optional().describe('Refuse the submit while the field is empty'), + disabled: z.boolean().optional().describe('Draw the control greyed out and not interactive'), + readonly: z.boolean().optional().describe('Draw the value plainly, not editable'), + hidden: z.boolean().optional().describe('Do not draw the field (its value still submits)'), + placeholder: z.string().optional().describe('Placeholder text in the empty control'), + options: z.array(FormSelectOptionSchema).optional().describe('The choices of a select / radio / checkboxes field — the form view\'s own option, `{ label, value, … }`'), + validation: objectFormRuntimeFieldValidation().optional().describe('Extra rules checked at submit — `{ required?, minLength?, maxLength?, min?, max? }`, each bound rule a `{ value, message }`, and `required` the message a required field shows'), + dependsOn: z.union([z.string(), FieldSchema.shape.dependsOn.unwrap()]).optional().describe('The field(s) this field\'s options depend on: the form gates the field until they are set and re-evaluates its options as they change — a field name, or the object field\'s list of names / `{ field, param }` entries'), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the field is drawn only when TRUE'), + readonlyWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the field is read-only when TRUE'), + requiredWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the field is required when TRUE'), + colSpan: z.number().int().min(1).max(4).optional().describe('Absolute column span (1-4), clamped to the form grid\'s column count'), + span: z.enum(['auto', 'full']).optional().describe("Relative width: 'auto' (the default) sizes the field from its widget and the column count; 'full' takes the whole row"), + group: SectionGroupKeySchema.optional().describe('The object field group (`fieldGroups[].key`) this field is drawn in when the form derives its sections from the object\'s groups'), + multiple: z.boolean().optional().describe('Hold several values instead of one (file, image, lookup, user and select fields)'), + rows: FieldSchema.shape.rows.describe('Height of the textarea / markdown editor, in text rows'), + accept: FieldSchema.shape.accept.describe('Upload types a file field\'s picker offers, as MIME types or extensions (e.g. `["image/*", ".pdf"]`)'), + dimensions: FieldSchema.shape.dimensions.describe('Vector dimensionality a vector field prints beside its value'), + reference: FieldSchema.shape.reference.describe('The object a lookup / user field\'s picker queries'), + min: z.number().optional().describe('Minimum value — the native control\'s `min`, enforced by the browser at submit'), + max: z.number().optional().describe('Maximum value — the native control\'s `max`, enforced by the browser at submit'), + minLength: FieldSchema.shape.minLength.describe('Minimum character count — the native control\'s `minlength`'), + maxLength: FieldSchema.shape.maxLength.describe('Maximum character count — the control\'s ceiling (a textarea also draws its counter)'), + pattern: z.string().optional().describe('Regular expression the value must match, as a string — the native control\'s `pattern`, enforced by the browser at submit'), + returnType: FieldSchema.shape.returnType.describe('The value type a formula field displays (number / text / boolean / date)'), + summaryOperations: FieldSchema.shape.summaryOperations.describe('The roll-up a summary field displays — the object field\'s own `{ object, field, function, … }`'), + columns: FieldSchema.shape.inlineColumns.describe('The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes'), + }); +} +let objectFormRuntimeFieldOnce: ReturnType | undefined; +/** The one {@link buildObjectFormRuntimeField} instance, built with the first row that takes it. */ +const objectFormRuntimeField = () => (objectFormRuntimeFieldOnce ??= buildObjectFormRuntimeField()); + +/** A form section's `{ field }` entry, as the page-block section takes it. */ +type ObjectFormSectionFieldEntryInput = + Omit + & { label?: string; placeholder?: string; helpText?: string; fields?: ObjectFormSectionFieldEntryInput[] }; +/** {@link ObjectFormSectionFieldEntryInput} once parsed. */ +type ObjectFormSectionFieldEntry = + Omit + & { label?: string; placeholder?: string; helpText?: string; span?: 'auto' | 'full'; fields?: ObjectFormSectionFieldEntry[] }; + +/** + * [#21464] The form view's `{ field }` entry, as a page block's section takes + * it — the second of the three entry arms the form reads (decision card + * #21704, fork 3, letter B). + * + * The form reads it in `plugin-form/src/sectionFields.ts:373-455`: the entry + * names an object field by `field`, and every other key it carries overrides + * that field's generated definition, key by key — the key set of the form + * view's own entry (`FormFieldSchema`, `view.zod.ts`). So the members ARE that + * schema's, by reference (pinned def by def), with three differences, each + * from how the page block reaches the form: + * + * - **canonical spellings only.** A form view folds the deprecated `visibleOn` + * into `visibleWhen` at parse; a page block's `properties` is never parsed on + * the way to the form, so no fold runs, and the deprecated spelling is + * refused with the canonical one (ADR-0089). + * - **`label`, `placeholder` and `helpText` are plain strings.** The form + * copies each onto the field it draws as it stands (`:386-388`), and the + * renderer draws a label as a React child, so an inline locale map would + * throw. + * - **no parse-time fill.** The arm is the view entry's object half, without + * its `visibleOn` fold, and `span` drops the default the form view fills (the + * form draws an absent `span` as `'auto'` anyway); the one parse-time change + * left is the evaluated predicate's own envelope for a bare CEL string. + * + * Its sub-field list (`fields`, for a composite field) is this same entry, + * recursively, as the form view's is its own entry — so the canonical rule + * holds at every depth. + */ +function buildObjectFormSectionFieldEntry(): z.ZodType { + // The object half of the form view's entry: `FormFieldSchema` is that object + // piped into its `visibleOn` fold, and its declared type is the pipe's, so + // the half is read off the pipe here, in one place. + const viewShape = (FormFieldSchema as unknown as z.ZodPipe, z.ZodType>).in.shape; + const { + visibleOn: _foldedAtParse, label: _label, placeholder: _placeholder, helpText: _helpText, span, fields: _viewSubFields, ...shape + } = viewShape; + return strictObject({ + surface: 'this form section\'s `{ field }` entry', + history: + 'Until this shape was declared, a section entry was `z.unknown()`: a misspelled override passed, and ' + + 'the form drew the field without it.', + aliases: { disabled: 'readonly' }, + guidance: { + visibleOn: + '`visibleOn` is the form view\'s deprecated spelling of `visibleWhen` (ADR-0089), which a form view ' + + 'folds at parse. A page block\'s `properties` is never parsed on the way to the form, so it takes the ' + + 'canonical spelling only: write `visibleWhen`.', + name: + 'A section field entry is either the form view\'s `{ field: \'email\', … }` entry, keyed by `field`, ' + + 'or an inline form field keyed by `name` — not both. Drop `name` to override the object field `field` ' + + 'names, or drop `field` to define the field inline.', + }, + }, { + ...shape, + span: (span as z.ZodDefault>).unwrap().optional() + .describe("Relative width: 'auto' (what the form draws when it is omitted) sizes the field from its widget and the column count; 'full' takes the whole row"), + label: z.string().optional().describe('Label override (a plain string — the form draws it as it is)'), + placeholder: z.string().optional().describe('Placeholder override (a plain string)'), + helpText: z.string().optional().describe('Help text drawn under the control (a plain string)'), + fields: z.array(z.lazy(() => objectFormSectionFieldEntry())).optional() + .describe('Sub-fields of a composite / repeater / record field, each this same entry'), + }) as unknown as z.ZodType; +} +let objectFormSectionFieldEntryOnce: ReturnType | undefined; +/** The one {@link buildObjectFormSectionFieldEntry} instance. */ +const objectFormSectionFieldEntry = () => (objectFormSectionFieldEntryOnce ??= buildObjectFormSectionFieldEntry()); + +/** A section `columns` string the form view converts at parse — and the number to write. */ +function objectFormSectionColumnsRefusal(input: unknown): string | undefined { + if (typeof input !== 'string' || !/^[1-4]$/.test(input)) return undefined; + return `\`columns\` is a number on a page block's section: write \`${input}\`, not \`'${input}'\`. A form view ` + + 'converts the string at parse, but a page block\'s `properties` is never parsed on the way to the form, ' + + 'which ignores a column count that is not a number.'; +} + +/** + * [#21464] One section of an `object-form` — and of an + * `object-master-detail-form`, whose parent half hands its `sections` to the + * form verbatim (`MasterDetailForm.tsx:1692`) — a page-block section shape of + * its own (decision card #21704, fork 3, letter B): the form view's section + * keys (`FormSectionSchema`, `view.zod.ts`, which is NOT edited) plus the three + * entry arms the form reads. + * + * ## The section keys, read + * + * `name` and `label` (the heading, `ObjectForm.tsx:1693-1695` and the + * per-`formType` maps at `:412`, `:492`, `:520`, `:556`, `:590`), + * `description`, `collapsible` / `collapsed` (`resolveSectionCollapse`, + * `:1702`), `visibleWhen` (the divider row's predicate, `:1720`), `columns` + * (`:1731`), `pane` (the split form, `SplitForm.tsx:445`), `group` (resolved + * into the field group's own section above the routing, `sectionGroups.ts`) and + * `fields`. That is exactly the form view's section's key set, and objectui + * declares the same (`ObjectFormSection`, `types/src/objectql.ts:1497`). + * + * ## Canonical spellings only + * + * A form view folds two spellings at parse: the deprecated section `visibleOn` + * into `visibleWhen`, and a string `columns` into its number. A page block's + * `properties` is never parsed on the way to the form, so neither fold runs — + * and the form reads only `visibleWhen` off a section and only a NUMBER + * `columns` (`clampCol`, `:1599`), so both spellings were dropped in silence. + * Both are refused with the canonical spelling. `label` is a plain string: + * the form draws the heading as it stands. Nothing carries a schema default; + * the one parse-time change is the evaluated predicate's own — a bare CEL + * `visibleWhen` parses to its `{ dialect, source }` envelope, as on every + * surface — and the form reads either spelling. + * + * ## The entry arms + * + * A field name; the form view's `{ field }` entry + * ({@link buildObjectFormSectionFieldEntry}); and the inline runtime form + * field ({@link buildObjectFormRuntimeField}), drawn as it stands + * (`sectionFields.ts:369-370`, kept by objectstack-ai/objectui#11550's + * ruling). The group-reference rule is the form view's own + * ({@link sectionGroupReferenceRefinement}): a section declares its members by + * `fields` or by `group`, and a `group` section carries no key the group + * declares. + * + * Declared once, built once: both rows take this one instance. + */ +function buildObjectFormSection() { + return strictObject({ + surface: 'this form section', + history: + 'Until this shape was declared, a form section was `z.unknown()`: a misspelled key passed, and the ' + + 'form drew the section without it.', + aliases: { fieldGroup: 'group', groupKey: 'group' }, + guidance: { + visibleOn: + '`visibleOn` is the form view\'s deprecated spelling of a section\'s `visibleWhen` (ADR-0089), which a ' + + 'form view folds at parse. A page block\'s `properties` is never parsed on the way to the form, and ' + + 'the form reads only `visibleWhen` off a section, so a `visibleOn` here gated nothing. Write it as ' + + '`visibleWhen`.', + }, + }, { + name: z.string().optional().describe('Stable section identifier (snake_case) — the heading resolves through `objects.._sections..label`'), + label: z.string().optional().describe('Section heading (a plain string)'), + description: z.string().optional().describe('Text drawn under the heading'), + collapsible: z.boolean().optional().describe('Draw a disclosure control on the heading, so a reader can close the section and open it again. `collapsed: true` implies it'), + collapsed: z.boolean().optional().describe('Start the section closed (implies `collapsible`)'), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE'), + columns: z.number({ error: (issue) => objectFormSectionColumnsRefusal(issue.input) }).int().min(1).max(4).optional() + .describe('Field-grid columns for this section (1-4), a number'), + pane: z.enum(['primary', 'secondary']).optional().describe("The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary'"), + group: SectionGroupKeySchema.optional().describe('Field group key (snake_case) whose members and presentation this section inherits, from the object\'s `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`)'), + fields: z.array(z.union([ + z.string(), + objectFormSectionFieldEntry(), + objectFormRuntimeField(), + ])).optional().describe('The section\'s fields, in order — each a field name, the form view\'s `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members'), + }).superRefine(sectionGroupReferenceRefinement({ + surface: 'this form section', + // The form view section's own lists: the keys `deriveFieldGroupLayout` + // fills from the group, and the two booleans only a `true` of declares. + derivedKeys: ['name', 'label', 'description', 'visibleWhen'], + trueOnlyDerivedKeys: ['collapsible', 'collapsed'], + })); +} +let objectFormSectionOnce: ReturnType | undefined; +/** The one {@link buildObjectFormSection} instance, shared by both form rows. */ +const objectFormSection = () => (objectFormSectionOnce ??= buildObjectFormSection()); + /** * `object-form` (objectui `plugin-form/src/ObjectForm.tsx` @ `eb7f586b`, plus * the sub-forms it forwards the whole bag into: `TabbedForm`, `WizardForm`, @@ -5981,34 +6386,25 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ fields: formFieldNameList().optional() .describe('Field names to draw, in order — bare names selecting from the object\'s fields and from `customFields`. A `{ name }` or `{ field }` object entry is refused: a per-form label or required override goes on a `sections[].fields` entry'), /** - * [#21464] Kept `z.unknown()`, in the enumeration pin's ledger as a fork the - * S-objectui-held stage reported: each member is objectui's runtime form - * field (`FormField`, identity key `name`, `types/src/form.ts:1770` at the - * `.objectui-sha` pin `ab1879721595`), drawn whole by the form renderer - * (`plugin-form/src/customFieldsMerge.ts:78-108`), and the spec declares no - * such field — its own form field (`FormFieldSchema`, `view.zod.ts`) is keyed - * by `field` and is never matched by the merge. Writing that declaration - * here needs decisions no ruling has made: objectui's field is OPEN (an index - * signature beside forty-five members, `:1906`), and eight of them are the - * grid widget's snake_case keys (`min_rows`, `allow_add`, …), which this - * package's camelCase rule for config keys does not admit as written. + * [#21464] Typed in the S-forms stage, as ruled on the decision card #21704 + * (fork 2, letter B): each member is the closed runtime form field + * {@link buildObjectFormRuntimeField} declares — the members the form draws, + * in camelCase, keyed by `name` — merged over the generated fields + * (`plugin-form/src/customFieldsMerge.ts:78-108`). Until then it was + * `z.unknown()`: objectui's own field is open (an index signature beside + * forty-five members) and spells eight of them in snake_case. */ - customFields: z.unknown().optional().describe('Custom field definitions merged into the generated set'), + customFields: z.array(objectFormRuntimeField()).optional() + .describe('Field definitions merged over the set generated from the object\'s metadata — each a closed inline field `{ name, label?, type?, required?, … }`: a member naming a field the object declares replaces that field\'s whole definition, any other is added after the generated fields. With no object behind the form, the members are its only fields'), /** - * [#21464] HELD at `z.unknown()` entries, in the enumeration pin's ledger - * with `customFields`'s fork. The form view's own `sections` - * (`FormSectionSchema`) is the by-reference shape, and every section key the - * renderer reads is declared there, but a section's `fields` also draws an - * inline runtime form field `{ name, type, … }` as it stands - * (`plugin-form/src/sectionFields.ts:369-370` at the `.objectui-sha` pin - * `ab1879721595`, its "shape 3"), which the form view's field entry (keyed by - * `field`) refuses. objectstack-ai/objectui#11550's ruling KEPT that entry — - * objectui declares it (`ObjectFormSection.fields: (string | FormField)[]`) - * and its README's data-source-free wizard relies on it — so this member - * takes a shape only once the spec declares the runtime form field. + * [#21464] Typed in the S-forms stage, as ruled on the decision card #21704 + * (fork 3, letter B): a page-block section shape of its own + * ({@link buildObjectFormSection}) — the form view's section keys plus the + * three entry arms the form reads, canonical spellings only. The stored form + * view's `FormSectionSchema` is unchanged. */ - sections: z.array(z.unknown()).optional() - .describe('Form sections ({ label, description?, fields } — wizard steps / tab panes)'), + sections: z.array(objectFormSection()).optional() + .describe('Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }`, or `{ group, columns?, pane? }` to inherit an object field group. Each `fields` entry is a field name, the form view\'s `{ field, … }` entry, or an inline form field `{ name, type, … }`'), title: I18nLabelSchema.optional().describe('Form title'), description: I18nLabelSchema.optional().describe('Form description (rendered by the drawer/modal presentations)'), defaultTab: z.string().optional().describe('Initially active tab (tabbed)'), @@ -6269,13 +6665,13 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ /** * [#21464] `sections` and `fields` are handed to the parent `object-form` * verbatim (`plugin-form/src/MasterDetailForm.tsx:1692-1693` at the - * `.objectui-sha` pin `ab1879721595`), so each is read exactly as that + * `.objectui-sha` pin `2e818d0b51ec`), so each is read exactly as that * block's member is. `fields` takes the same field-name list - * ({@link formFieldNameList}); `sections` is HELD with that block's, in the - * enumeration pin's ledger, for the same reason (see - * {@link ObjectFormPropsSchema}'s `sections`). + * ({@link formFieldNameList}); `sections` takes the same section shape — the + * one instance {@link buildObjectFormSection} builds (S-forms stage). */ - sections: z.array(z.unknown()).optional().describe('Parent form sections'), + sections: z.array(objectFormSection()).optional() + .describe('Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` or `{ group, columns?, pane? }`'), fields: formFieldNameList().optional() .describe('Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused'), details: z.array(masterDetailDetailEntry()).optional() From 9baaccd836798c4e26c5f872622b41d0c9f1cba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:19:39 +0000 Subject: [PATCH 02/13] fix(spec): ObjectFormPropsParsed for the predicate-carrying form members; ledger the new dropped-refinement sites [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 9 +++++++-- .../spec/src/type-alias-convention.pin.test.ts | 18 ++++++++++++++---- packages/spec/src/ui/component.zod.ts | 9 +++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 6b4e590c64a..12b947e94c4 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 218, - "droppedRefinementSites": 665, + "droppedRefinementSites": 670, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1376,6 +1376,9 @@ }, "ui/ObjectFormProps": { "sites": [ + "customFields.element.columns.element", + "customFields.element.summaryOperations.filter.lazy", + "sections.element", "submitBehavior.options[1].url" ] }, @@ -1416,7 +1419,9 @@ }, "ui/ObjectMasterDetailFormProps": { "sites": [ - "details.element.columns.element" + "details.element.columns.element", + "sections.element", + "sections.element.fields.element.options[2].summaryOperations.filter.lazy" ] }, "ui/ObjectMetricProps": { diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 87b68bc36ed..8f99fd872b5 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -274,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 773 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 772 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1490,12 +1490,15 @@ export type Iso_ui_chart__ChartTypeSchema = Assert, z.infer< typeof M170.ElementDefinitionListPropsSchema > >>; -export type Iso_ui_component__ObjectFormPropsSchema = Assert, z.infer< typeof M170.ObjectFormPropsSchema > >>; export type Iso_ui_component__PageContainerProps = Assert, z.infer< typeof M170.PageContainerProps > >>; export type Iso_ui_component__RecordAlertActionSchema = Assert, z.infer< typeof M170.RecordAlertActionSchema > >>; export type Iso_ui_component__RecordHighlightsField = Assert, z.infer< typeof M170.RecordHighlightsField > >>; @@ -1665,7 +1668,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 773 isomorphic pins', () => { + it('still declares all 772 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2411,7 +2414,14 @@ describe('ADR-0122 type-alias convention', () => { // (ADR-0049). Its five pins (`ActionRefSchema`, `GuardRefSchema`, // `StateMachineSchema`, `StateNodeSchema`, `TransitionSchema`) went with the // module, and so did its `M42` import. -5 removed. - expect(pins).toHaveLength(773); + // + // 773 -> 772 is #21464's S-forms stage: `object-form`'s `customFields` and + // `sections` now carry the evaluated `*When` predicates (a bare CEL string + // parses to its `{ dialect, source }` envelope), so input ≠ infer and + // `ObjectFormPropsSchema` left the isomorphic family for an + // `ObjectFormPropsParsed` alias, the route the object-* family note above + // prescribes. -1 removed. + expect(pins).toHaveLength(772); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index a132b878260..047176005b4 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6474,6 +6474,15 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectFormProps = z.input; +/** + * Post-parse shape of {@link ObjectFormProps} — transforms run (ADR-0122). + * [#21464] Since the S-forms stage `customFields` and `sections` carry the + * evaluated `*When` predicates, whose bare CEL string parses to its + * `{ dialect, source }` envelope, so input ≠ infer and the block left the + * type-alias convention pin's isomorphic family (its Iso line deleted with this + * alias), the route {@link ObjectMasterDetailFormPropsParsed} took. + */ +export type ObjectFormPropsParsed = z.infer; // `formType` old-vocabulary prescriptions (#11873; the objectui#5939 // measurement). Declared with `//` on purpose — the `LIST_VIEW_EXPORT_PDF_RETIRED` From a8e89c19da914a4fbd2f9367c44ee286d5190222 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:22:32 +0000 Subject: [PATCH 03/13] chore(spec): regenerate the component reference, the strictness counts, api-surface and export-origins [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...props-form-custom-fields-sections-typed.md | 2 +- content/docs/references/ui/component.mdx | 74 ++++++++++++++++++- .../ui.md | 10 +-- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md index 3cdc4265fe3..643e7624682 100644 --- a/.changeset/21464-component-props-form-custom-fields-sections-typed.md +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -16,7 +16,7 @@ Clause-②: yes (narrowing) - **The `sections` of `object-form` and `object-master-detail-form` are one page-block section shape.** They were `z.array(z.unknown())`. A section takes the form view's section keys — `name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`, `columns`, `pane`, `group`, `fields` — and the form view's group-reference rule; each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline runtime form field (the `customFields` member). The stored form view's `FormSectionSchema` is unchanged. - **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so a form view's parse-time folds do not run there: a section `visibleOn` and a string `columns` reached the form raw and were dropped. Both are refused with the canonical spelling, and so is a `{ field }` entry's or an inline field's `visibleOn`. - **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, and a locale map where the form draws a plain string. -- **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`. No new export: the shapes are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot. +- **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`; the shapes themselves are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot, so the `object-form` row's input and parsed types now differ and it gains the one new export, the type `ObjectFormPropsParsed` (ADR-0122), as `ObjectMasterDetailFormPropsParsed` already is. ## FROM → TO diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index de50e449ad5..ab4a89f2ca1 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -524,8 +524,8 @@ Sort field and direction pair | **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `number` | optional | Number of field columns (multi-column forms), honoured under either `layout` | | **fields** | `string[]` | optional | Field names to draw, in order — bare names selecting from the object's fields and from `customFields`. A `{ name }` or `{ field }` object entry is refused: a per-form label or required override goes on a `sections[].fields` entry | -| **customFields** | `any` | optional | Custom field definitions merged into the generated set | -| **sections** | `any[]` | optional | Form sections (`{ label, description?, fields }` — wizard steps / tab panes) | +| **customFields** | `{ name: string; label?: string; description?: string; type?: string; … }[]` | optional | Field definitions merged over the set generated from the object's metadata — each a closed inline field `{ name, label?, type?, required?, … }`: a member naming a field the object declares replaces that field's whole definition, any other is added after the generated fields. With no object behind the form, the members are its only fields | +| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }`, or `{ group, columns?, pane? }` to inherit an object field group. Each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline form field `{ name, type, … }` | | **title** | `string \| Record` | optional | Form title | | **description** | `string \| Record` | optional | Form description (rendered by the drawer/modal presentations) | | **defaultTab** | `string` | optional | Initially active tab (tabbed) | @@ -557,6 +557,59 @@ Sort field and direction pair | **initialData** | `Record` | optional | Alternate spelling of `initialValues` the renderer also reads | | **mobile** | `{ stickyActions?: boolean; stepper?: boolean \| 'auto'; stepperMinFields?: integer; stepperFieldsPerStep?: integer; … }` | optional | Phone presentation options, each opt-in — `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`. Read by the flat (simple, sectionless) form | +### Nested Shape: `ObjectFormProps.customFields[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | The field's name: its identity, and the key its value is submitted under. A member naming a field the object declares replaces that field's whole definition | +| **label** | `string` | optional | The label drawn beside the control (a plain string) | +| **description** | `string` | optional | Help text drawn under the control | +| **type** | `string` | optional | The widget the field renders as — a built-in input type (`input`, `textarea`, `select`, …) or a field widget (`email`, `field:markdown`, …); the renderer's default input when omitted | +| **inputType** | `string` | optional | The HTML `type` of the built-in input (`email`, `tel`, `number`, `date`, …) | +| **widget** | `string` | optional | A widget to render instead of the one `type` resolves to | +| **required** | `boolean` | optional | Refuse the submit while the field is empty | +| **disabled** | `boolean` | optional | Draw the control greyed out and not interactive | +| **readonly** | `boolean` | optional | Draw the value plainly, not editable | +| **hidden** | `boolean` | optional | Do not draw the field (its value still submits) | +| **placeholder** | `string` | optional | Placeholder text in the empty control | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | The choices of a select / radio / checkboxes field — the form view's own option, `{ label, value, … }` | +| **validation** | `{ required?: string; minLength?: object; maxLength?: object; min?: object; … }` | optional | Extra rules checked at submit — `{ required?, minLength?, maxLength?, min?, max? }`, each bound rule a `{ value, message }`, and `required` the message a required field shows | +| **dependsOn** | `string \| (string \| { field: string; param?: string })[]` | optional | The field(s) this field's options depend on: the form gates the field until they are set and re-evaluates its options as they change — a field name, or the object field's list of names / `{ field, param }` entries | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the field is drawn only when TRUE | +| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the field is read-only when TRUE | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the field is required when TRUE | +| **colSpan** | `integer` | optional | Absolute column span (1-4), clamped to the form grid's column count | +| **span** | `Enum<'auto' \| 'full'>` | optional | Relative width: 'auto' (the default) sizes the field from its widget and the column count; 'full' takes the whole row | +| **group** | `string` | optional | The object field group (`fieldGroups[].key`) this field is drawn in when the form derives its sections from the object's groups | +| **multiple** | `boolean` | optional | Hold several values instead of one (file, image, lookup, user and select fields) | +| **rows** | `integer` | optional | Height of the textarea / markdown editor, in text rows | +| **accept** | `string[]` | optional | Upload types a file field's picker offers, as MIME types or extensions (e.g. `["image/*", ".pdf"]`) | +| **dimensions** | `integer` | optional | Vector dimensionality a vector field prints beside its value | +| **reference** | `string` | optional | The object a lookup / user field's picker queries | +| **min** | `number` | optional | Minimum value — the native control's `min`, enforced by the browser at submit | +| **max** | `number` | optional | Maximum value — the native control's `max`, enforced by the browser at submit | +| **minLength** | `integer` | optional | Minimum character count — the native control's `minlength` | +| **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) | +| **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit | +| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | The value type a formula field displays (number / text / boolean / date) | +| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` | +| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes | + +### Nested Shape: `ObjectFormProps.sections[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Stable section identifier (snake_case) — the heading resolves through `objects.._sections..label` | +| **label** | `string` | optional | Section heading (a plain string) | +| **description** | `string` | optional | Text drawn under the heading | +| **collapsible** | `boolean` | optional | Draw a disclosure control on the heading, so a reader can close the section and open it again. `collapsed: true` implies it | +| **collapsed** | `boolean` | optional | Start the section closed (implies `collapsible`) | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE | +| **columns** | `integer` | optional | Field-grid columns for this section (1-4), a number | +| **pane** | `Enum<'primary' \| 'secondary'>` | optional | The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary' | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`) | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … } \| { name: string; label?: string; description?: string; type?: string; … })[]` | optional | The section's fields, in order — each a field name, the form view's `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members | + ### Nested Shape: `ObjectFormProps.submitBehavior[kind='redirect']` | Property | Type | Required | Description | @@ -1097,7 +1150,7 @@ Sort field and direction pair | **recordId** | `string \| number` | optional | Parent record to load (edit mode) | | **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode | | **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half | -| **sections** | `any[]` | optional | Parent form sections | +| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` or `{ group, columns?, pane? }` | | **fields** | `string[]` | optional | Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused | | **details** | `{ childObject: string; relationshipField?: string; columns?: object[]; formFields?: string[]; … }[]` | optional | Detail collections — each a strict entry (`{ childObject, title?, addLabel?, columns?, relationshipField?, … }`) whose `columns` are the inline grid columns a relationship field's `inlineColumns` takes; the FK and columns auto-derive from child metadata when omitted | | **title** | `string \| Record` | optional | Form title | @@ -1108,6 +1161,21 @@ Sort field and direction pair | **initialData** | `Record` | optional | Alternate spelling of `initialValues` the renderer also reads | | **taxRateField** | `string` | optional | Child field holding the per-line tax rate (line-items totals) | +### Nested Shape: `ObjectMasterDetailFormProps.sections[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Stable section identifier (snake_case) — the heading resolves through `objects.._sections..label` | +| **label** | `string` | optional | Section heading (a plain string) | +| **description** | `string` | optional | Text drawn under the heading | +| **collapsible** | `boolean` | optional | Draw a disclosure control on the heading, so a reader can close the section and open it again. `collapsed: true` implies it | +| **collapsed** | `boolean` | optional | Start the section closed (implies `collapsible`) | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE | +| **columns** | `integer` | optional | Field-grid columns for this section (1-4), a number | +| **pane** | `Enum<'primary' \| 'secondary'>` | optional | The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary' | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`) | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … } \| { name: string; label?: string; description?: string; type?: string; … })[]` | optional | The section's fields, in order — each a field name, the form view's `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members | + ### Nested Shape: `ObjectMasterDetailFormProps.details[number]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 9942c5b9631..1094e8adf24 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 198 | 187 | 4 | 0 | 7 | +| `ui/` | 203 | 192 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 68 | +| `component.zod.ts` | 73 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **198** | +| **total** | **203** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 198**, in 4 file(s). +**7 strip of 203**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **198** | +| **total** | **7** | **203** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 08f63711feb..ea47b1ce2a8 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -279,6 +279,7 @@ "ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema (const)", "ObjectFormProps (type)", + "ObjectFormPropsParsed (type)", "ObjectFormPropsSchema (const)", "ObjectGanttProps (type)", "ObjectGanttPropsParsed (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 366d453f16f..56cbc3db34b 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -275,6 +275,7 @@ "ObjectCalendarPropsParsed": "src/ui/component.zod.ts#ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", + "ObjectFormPropsParsed": "src/ui/component.zod.ts#ObjectFormPropsParsed (type)", "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", "ObjectGanttProps": "src/ui/component.zod.ts#ObjectGanttProps (type)", "ObjectGanttPropsParsed": "src/ui/component.zod.ts#ObjectGanttPropsParsed (type)", From 3c4c7ce1a8a822dd739b12c662381ad27aa000e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:26:28 +0000 Subject: [PATCH 04/13] fix(spec): the page-block form section names its own surface [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 047176005b4..05ea16394aa 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6300,7 +6300,7 @@ function objectFormSectionColumnsRefusal(input: unknown): string | undefined { */ function buildObjectFormSection() { return strictObject({ - surface: 'this form section', + surface: 'this `object-form` section', history: 'Until this shape was declared, a form section was `z.unknown()`: a misspelled key passed, and the ' + 'form drew the section without it.', @@ -6329,7 +6329,7 @@ function buildObjectFormSection() { objectFormRuntimeField(), ])).optional().describe('The section\'s fields, in order — each a field name, the form view\'s `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members'), }).superRefine(sectionGroupReferenceRefinement({ - surface: 'this form section', + surface: 'this `object-form` section', // The form view section's own lists: the keys `deriveFieldGroupLayout` // fills from the group, and the two booleans only a `true` of declares. derivedKeys: ['name', 'label', 'description', 'visibleWhen'], From 587dc64f23267d6bde072b96719724143f1d15e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:44:18 +0000 Subject: [PATCH 05/13] test(spec): the S-forms pin cites the showcase wizard by file, not by line [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../ui/component-form-custom-fields-sections-typed.pin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index b358479b699..1821929c5ea 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -148,7 +148,7 @@ describe('§1 each member accepts every shape a measured writer authors', () => { name: 'items', type: 'grid', columns: [{ name: 'product', type: 'text' }, { name: 'qty', type: 'number' }] }, ], }], - // objectstack `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts:60`. + // objectstack `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts` (its `object-form` node). ['the showcase wizard\'s sections', 'object-form', { sections: [ { label: 'Basics', description: 'Name the project and bind its account.', fields: ['name', 'account', 'owner'] }, From ffb650c1255eb0e470d2b5c08dcdef63964654e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:02:03 +0000 Subject: [PATCH 06/13] docs(changeset): the S-forms census, as measured Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...464-component-props-form-custom-fields-sections-typed.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md index 643e7624682..82ca12d7328 100644 --- a/.changeset/21464-component-props-form-custom-fields-sections-typed.md +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -37,9 +37,9 @@ The one-line fix: write each inline field in camelCase with the members the form ## Who is affected, measured -A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, or the argument of a local helper that mounts one; values resolve through same-file constants and spreads, and every static value was parsed through this branch's rows. Each value with a non-static part was read by hand. +A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block, which reaches a local helper's arguments. Values resolve through same-file constants and spreads, every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. -- **objectstack** at `ced3e1ae47`: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), names only — all parse. No `customFields` writer. -- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (the same writers): every `customFields` writer parses — 31 static values, the designer's object manager among them — but two: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing. Every `sections` writer on either block parses, the plugin-form README's inline-field wizard and the field designer's inline fields included; the refused section values are objectui's probes that a retired `className` / `gridClassName` reaches nothing, and form-view or `record:details` sections, which these rows do not judge. +- **objectstack** at `316be321ef`: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. +- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (identical results; every cited reader file is byte-identical between the two): **`customFields`** — 31 values parse (four block literals, the designer's object manager, 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — every block writer parses: 85 `object-form` values with a static part (the field designer's inline fields and the plugin-form README's inline-field wizard among them) and four `object-master-detail-form` values; the 19 fully non-static values are run-time hand-offs and helper parameters, read by hand, and use declared keys only. The refused section values are objectui's probe that a retired `className` / `gridClassName` reaches nothing, and `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. - **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member. - **Deployed metadata** was not measured. From e07d2ca03c42c4ac26bffa2dd8dd4b4b0cdb2bda Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:32:23 +0000 Subject: [PATCH 07/13] test(spec): the S-forms pin takes the census's master-detail and group-reference probe values; the changeset counts helper parameters Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...ent-props-form-custom-fields-sections-typed.md | 4 ++-- ...-form-custom-fields-sections-typed.pin.test.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md index 82ca12d7328..fc94f419d26 100644 --- a/.changeset/21464-component-props-form-custom-fields-sections-typed.md +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -37,9 +37,9 @@ The one-line fix: write each inline field in camelCase with the members the form ## Who is affected, measured -A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block, which reaches a local helper's arguments. Values resolve through same-file constants and spreads, every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. +A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. - **objectstack** at `316be321ef`: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. -- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (identical results; every cited reader file is byte-identical between the two): **`customFields`** — 31 values parse (four block literals, the designer's object manager, 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — every block writer parses: 85 `object-form` values with a static part (the field designer's inline fields and the plugin-form README's inline-field wizard among them) and four `object-master-detail-form` values; the 19 fully non-static values are run-time hand-offs and helper parameters, read by hand, and use declared keys only. The refused section values are objectui's probe that a retired `className` / `gridClassName` reaches nothing, and `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. +- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (identical results; every cited reader file is byte-identical between the two): **`customFields`** — 31 values parse (four block literals, the designer's object manager, 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. - **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member. - **Deployed metadata** was not measured. diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index 1821929c5ea..c8a14815e5c 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -178,8 +178,10 @@ describe('§1 each member accepts every shape a measured writer authors', () => ], }], ['no sections', 'object-form', { sections: [] }], - // objectui `plugin-form/src/masterDetailFormTypeVocabulary.test.tsx` — the parent half's sections. - ['the parent half\'s sections', 'object-master-detail-form', { sections: [{ name: 'header', label: 'Header', fields: ['customer', 'date'] }] }], + // objectui `plugin-form/src/masterDetailFormTypeVocabulary.test.tsx:109` — the parent half's sections. + ['the parent half\'s sections', 'object-master-detail-form', { + sections: [{ name: 's1', label: 'Sec One', fields: ['ref'] }, { name: 's2', label: 'Sec Two', fields: ['memo'] }], + }], ]; for (const [label, row, props] of IDENTICAL) { it(`${row}: parses ${label} byte-identical`, () => { @@ -243,9 +245,14 @@ describe('§2 off-shape values are refused with the code and the path', () => { ['a locale-map section label', 'object-form', { sections: [{ fields: ['a'], label: { en: 'A' } }] }, [{ code: 'invalid_type', path: 'sections.0.label' }]], // objectui `__tests__/sectionStyleKeysRetired-13626.test.tsx` — the retired style keys reach nothing. ['a section style key', 'object-form', { sections: [{ fields: ['a'], className: 'p-4' }] }, [{ code: 'unrecognized_keys', path: 'sections.0' }]], - ['a section with neither `fields` nor `group`', 'object-form', { sections: [{ label: 'Empty' }] }, [{ code: 'custom', path: 'sections.0.fields' }]], + // objectui `plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx:270` and `:307` — the + // renderer's own probes of two shapes that test says this door refuses at parse. + ['a section with neither `fields` nor `group`', 'object-form', { sections: [{ label: 'Memberless' }] }, [{ code: 'custom', path: 'sections.0.fields' }]], ['`group` beside `fields`', 'object-form', { sections: [{ group: 'contact_info', fields: ['a'] }] }, [{ code: 'custom', path: 'sections.0.group' }]], - ['a group-owned key beside `group`', 'object-form', { sections: [{ group: 'contact_info', label: 'Contact' }] }, [{ code: 'custom', path: 'sections.0.label' }]], + ['group-owned keys beside `group`', 'object-form', { sections: [{ group: 'contact_info', label: 'My Own Label', collapsible: true }] }, [ + { code: 'custom', path: 'sections.0.label' }, + { code: 'custom', path: 'sections.0.collapsible' }, + ]], ['an unknown pane', 'object-form', { sections: [{ fields: ['a'], pane: 'left' }] }, [{ code: 'invalid_value', path: 'sections.0.pane' }]], ['a numeric entry', 'object-form', { sections: [{ fields: [5] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], ['a `{ field }` entry\'s `visibleOn`', 'object-form', { sections: [{ fields: [{ field: 'a', visibleOn: 'record.b == 1' }] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], From b4734397524627c26ec82874ed9fe83c97f632dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:58:23 +0000 Subject: [PATCH 08/13] docs(changeset): name the objectstack census corpus Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../21464-component-props-form-custom-fields-sections-typed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md index fc94f419d26..8838972d732 100644 --- a/.changeset/21464-component-props-form-custom-fields-sections-typed.md +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -39,7 +39,7 @@ The one-line fix: write each inline field in camelCase with the members the form A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. -- **objectstack** at `316be321ef`: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. +- **objectstack** at `ced3e1ae47` (the five commits `main` has gained since add no writer): three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. - **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (identical results; every cited reader file is byte-identical between the two): **`customFields`** — 31 values parse (four block literals, the designer's object manager, 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. - **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member. - **Deployed metadata** was not measured. From 1b08dec21d4e28a16581e50d346c7ebb02655b7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:50:13 +0000 Subject: [PATCH 09/13] test(spec): the S-forms pin takes runtime option values, the shipped object-manager dialog, and a closed option element [wip, red first] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...m-custom-fields-sections-typed.pin.test.ts | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index c8a14815e5c..a9e5b28a39b 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -47,7 +47,8 @@ import { ObjectFormPropsSchema, ObjectMasterDetailFormPropsSchema, } from './component.zod'; -import { FormFieldSchema, FormSectionSchema } from './view.zod'; +import { FormFieldSchema, FormSectionSchema, FormSelectOptionSchema } from './view.zod'; +import { SelectOptionSchema } from '../data/field.zod'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; type Row = 'object-form' | 'object-master-detail-form'; @@ -86,6 +87,8 @@ function objectOf(member: unknown): { shape: Record } { } const runtimeField = () => objectOf(ObjectFormPropsSchema.shape.customFields); +/** The element of the runtime field's `options`. */ +const runtimeOption = () => objectOf(runtimeField().shape.options); const section = () => objectOf(ObjectFormPropsSchema.shape.sections); /** The section `fields` entry union's three arms: name, `{ field }` entry, inline field. */ const entryArms = () => { @@ -131,6 +134,27 @@ describe('§1 each member accepts every shape a measured writer authors', () => options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }], }], }], + // An inline option's value is a RUNTIME value, not a stored field's identifier: the option + // widgets compare it by identity and stringify it only at the control (`matchOptionValue` maps + // the pick back), so a capitalised string, a number and a boolean each round-trip as written. + ['runtime option values: a capitalised string, a number and a boolean', 'object-form', { + customFields: [ + { name: 'icon', label: 'Icon', type: 'select', options: [{ label: 'Box', value: 'Box' }, { label: 'Shopping cart', value: 'ShoppingCart' }] }, + { name: 'size', label: 'Size', type: 'radio', options: [{ label: 'One', value: 1 }, { label: 'Two', value: 2 }] }, + { name: 'agree', label: 'Agree', type: 'select', options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] }, + ], + }], + // The other two keys an option reader draws: a lookup's typeahead searches `description` + // (`LookupField.tsx`), and the cascade offers an option only while its `visibleWhen` holds. + ['an option\'s description and its visibility predicate', 'object-form', { + customFields: [{ + name: 'region', type: 'lookup', dependsOn: 'country', + options: [ + { label: 'Shanghai', value: 'sh', description: 'East China', visibleWhen: { dialect: 'cel', source: "record.country == 'cn'" } }, + { label: 'Ohio', value: 'oh' }, + ], + }], + }], ['validation rules and native bounds', 'object-form', { customFields: [{ name: 'code', type: 'input', inputType: 'text', minLength: 2, maxLength: 8, pattern: '^[A-Z]+$', min: 1, max: 9, @@ -191,6 +215,40 @@ describe('§1 each member accepts every shape a measured writer authors', () => }); } + // objectui `plugin-designer/src/ObjectManager.tsx` (about `:239`-`:247` at the `.objectui-sha` pin): + // the registered `object-manager` component's create / edit dialog, a `formType: 'modal'` block. + // Its two select members build their options from the file's own constants, each entry + // `{ label: v, value: v }`; the labels it draws through `t(...)` are written out as strings here. + it('object-form: parses the shipped object-manager dialog\'s inline fields byte-identical', () => { + const OBJECT_GROUPS = ['Custom Objects', 'System Objects', 'Integration', 'Analytics']; + const ICON_OPTIONS = [ + 'Box', 'Database', 'Users', 'FileText', 'Settings', + 'ShoppingCart', 'Calendar', 'Mail', 'Briefcase', 'Building', + 'Globe', 'Heart', 'Star', 'Tag', 'Bookmark', + 'Folder', 'Archive', 'Package', 'Truck', 'CreditCard', + ]; + const readOnly = false; + const props = { + objectName: 'object_definition', + formType: 'modal', + mode: 'create', + modalSize: 'lg', + readOnly, + customFields: [ + { name: 'name', label: 'Object name', type: 'text', required: true, placeholder: 'api_name', disabled: readOnly }, + { name: 'label', label: 'Object label', type: 'text', required: true, placeholder: 'Display Name', disabled: readOnly }, + { name: 'pluralLabel', label: 'Plural label', type: 'text', placeholder: 'Display Names', disabled: readOnly }, + { name: 'description', label: 'Description', type: 'textarea', disabled: readOnly }, + { name: 'icon', label: 'Icon', type: 'select', options: ICON_OPTIONS.map((i) => ({ label: i, value: i })), disabled: readOnly }, + { name: 'group', label: 'Group', type: 'select', options: OBJECT_GROUPS.map((g) => ({ label: g, value: g })), disabled: readOnly }, + { name: 'sortOrder', label: 'Sort order', type: 'number', disabled: readOnly }, + ], + }; + const r = parse('object-form', props); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual(props); + }); + it('a bare CEL predicate parses to its envelope, as on every evaluated slot — and the envelope is kept', () => { const r = parse('object-form', { sections: [{ fields: ['a', { field: 'b', visibleWhen: 'record.x == 1' }], visibleWhen: 'record.y == 2' }], @@ -238,6 +296,12 @@ describe('§2 off-shape values are refused with the code and the path', () => { ['a bare `validation.minLength`', 'object-form', { customFields: [{ name: 'a', validation: { minLength: 2 } }] }, [{ code: 'invalid_type', path: 'customFields.0.validation.minLength' }]], ['a column span past the grid', 'object-form', { customFields: [{ name: 'a', colSpan: 5 }] }, [{ code: 'too_big', path: 'customFields.0.colSpan' }]], ['a malformed field group key', 'object-form', { customFields: [{ name: 'a', group: 'Contact Info' }] }, [{ code: 'invalid_format', path: 'customFields.0.group' }]], + // The option element is closed too: a key no option reader draws is refused, not carried. + ['an undeclared option key', 'object-form', { customFields: [{ name: 'a', type: 'select', options: [{ label: 'A', value: 'a', bogus: 1 }] }] }, [{ code: 'unrecognized_keys', path: 'customFields.0.options.0' }]], + ['an option colour, which no option control draws', 'object-form', { customFields: [{ name: 'a', type: 'select', options: [{ label: 'A', value: 'a', color: '#f00' }] }] }, [{ code: 'unrecognized_keys', path: 'customFields.0.options.0' }]], + ['an option `default`, which seeds nothing', 'object-form', { customFields: [{ name: 'a', type: 'select', options: [{ label: 'A', value: 'a', default: true }] }] }, [{ code: 'unrecognized_keys', path: 'customFields.0.options.0' }]], + ['an option with no label', 'object-form', { customFields: [{ name: 'a', type: 'select', options: [{ value: 'a' }] }] }, [{ code: 'invalid_type', path: 'customFields.0.options.0.label' }]], + ['an object option value', 'object-form', { customFields: [{ name: 'a', type: 'select', options: [{ label: 'A', value: { id: 1 } }] }] }, [{ code: 'invalid_union', path: 'customFields.0.options.0.value' }]], ['a number for `sections`', 'object-form', { sections: 42 }, [{ code: 'invalid_type', path: 'sections' }]], ['a section `visibleOn`', 'object-form', { sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }] }, [{ code: 'unrecognized_keys', path: 'sections.0' }]], ['a string `columns`', 'object-form', { sections: [{ fields: ['a'], columns: '2' }] }, [{ code: 'invalid_type', path: 'sections.0.columns' }]], @@ -280,6 +344,16 @@ describe('§2 off-shape values are refused with the code and the path', () => { expect(say({ validation: { pattern: { value: '^a', message: 'x' } } })).toMatch(/field's own `pattern` string/); }); + it('each refused option key carries its prescription, and an option alias names its key', () => { + const say = (option: Record) => + firstMessage(parse('object-form', { customFields: [{ name: 'a', type: 'select', options: [{ label: 'A', value: 'a', ...option }] }] })); + expect(say({ color: '#f00' })).toMatch(/object field's own option/); + expect(say({ default: true })).toMatch(/block's\s+`initialValues`/); + expect(say({ disabled: true })).toMatch(/`visibleWhen`/); + expect(say({ icon: 'star' })).toMatch(/drawn as its `label`/); + expect(say({ text: 'A' })).toMatch(/`text` → `label`/); + }); + it('each refused section spelling carries the canonical one', () => { expect(firstMessage(parse('object-form', { sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }] }))) .toMatch(/gated nothing\. Write it as `visibleWhen`/); @@ -310,6 +384,22 @@ describe('§3 the declared members, and one shape for both rows', () => { ]); }); + it('its option declares exactly the keys the form\'s option readers draw, with a runtime `value`', () => { + const option = runtimeOption(); + expect(Object.keys(option.shape).sort()).toEqual(['description', 'label', 'value', 'visibleWhen']); + // Not the stored field's option: that one's `value` is a lowercase identifier. + expect(option).not.toBe(objectOf(FormSelectOptionSchema)); + // The three keys the object field's option already declares are its own, by reference. + const own = SelectOptionSchema.shape as unknown as Record; + const shape = option.shape as unknown as Record; + for (const key of ['label', 'description', 'visibleWhen']) { + expect(shape[key]!._zod.def, key).toBe(own[key]!._zod.def); + } + const value = option.shape.value as z.ZodType; + for (const ok of ['Box', 'open', 2, 0, true, false]) expect(value.safeParse(ok).success, String(ok)).toBe(true); + for (const bad of [null, undefined, {}, ['a']]) expect(value.safeParse(bad).success, String(bad)).toBe(false); + }); + it('no member of it is spelled snake_case', () => { expect(Object.keys(runtimeField().shape).filter((k) => /_/.test(k))).toEqual([]); }); From c04a77716c1327f5b3b27177f0dbffc59d7fcd0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:53:21 +0000 Subject: [PATCH 10/13] =?UTF-8?q?fix(spec):=20an=20inline=20object-form=20?= =?UTF-8?q?field's=20option=20is=20the=20runtime=20option=20the=20form=20d?= =?UTF-8?q?raws=20=E2=80=94=20label,=20value=20(a=20string,=20a=20number?= =?UTF-8?q?=20or=20a=20boolean),=20description,=20visibleWhen=20=E2=80=94?= =?UTF-8?q?=20closed=20[wip]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option element was the form view's (FormSelectOptionSchema), whose value is a stored field's lowercase identifier; the shipped object-manager dialog's { label: 'Box', value: 'Box' } options were refused. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...m-custom-fields-sections-typed.pin.test.ts | 11 ++- packages/spec/src/ui/component.zod.ts | 90 +++++++++++++++++-- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index a9e5b28a39b..f3d873d0799 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -252,7 +252,10 @@ describe('§1 each member accepts every shape a measured writer authors', () => it('a bare CEL predicate parses to its envelope, as on every evaluated slot — and the envelope is kept', () => { const r = parse('object-form', { sections: [{ fields: ['a', { field: 'b', visibleWhen: 'record.x == 1' }], visibleWhen: 'record.y == 2' }], - customFields: [{ name: 'c', visibleWhen: { dialect: 'cel', source: 'record.z == 3' }, requiredWhen: 'record.y == 2' }], + customFields: [{ + name: 'c', visibleWhen: { dialect: 'cel', source: 'record.z == 3' }, requiredWhen: 'record.y == 2', + options: [{ label: 'Shanghai', value: 'sh', visibleWhen: "record.country == 'cn'" }], + }], }); expect(issues(r)).toEqual([]); const data = r.success ? (r.data as Record) : {}; @@ -260,6 +263,7 @@ describe('§1 each member accepts every shape a measured writer authors', () => expect(data.sections[0].fields[1].visibleWhen).toEqual({ dialect: 'cel', source: 'record.x == 1' }); expect(data.customFields[0].visibleWhen).toEqual({ dialect: 'cel', source: 'record.z == 3' }); expect(data.customFields[0].requiredWhen).toEqual({ dialect: 'cel', source: 'record.y == 2' }); + expect(data.customFields[0].options[0].visibleWhen).toEqual({ dialect: 'cel', source: "record.country == 'cn'" }); }); it('absent members stay absent', () => { @@ -389,10 +393,11 @@ describe('§3 the declared members, and one shape for both rows', () => { expect(Object.keys(option.shape).sort()).toEqual(['description', 'label', 'value', 'visibleWhen']); // Not the stored field's option: that one's `value` is a lowercase identifier. expect(option).not.toBe(objectOf(FormSelectOptionSchema)); - // The three keys the object field's option already declares are its own, by reference. + // `label` and `description` are the object field's option's own, by reference. (`visibleWhen` is + // declared on the option itself: the object field's option is re-checked on write, this one never is.) const own = SelectOptionSchema.shape as unknown as Record; const shape = option.shape as unknown as Record; - for (const key of ['label', 'description', 'visibleWhen']) { + for (const key of ['label', 'description']) { expect(shape[key]!._zod.def, key).toBe(own[key]!._zod.def); } const value = option.shape.value as z.ZodType; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 05ea16394aa..373d03336dd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -41,10 +41,8 @@ import { // declaration judges the form view and the block. FormViewSchema, // [#21464] A form section's `{ field }` entry is the form view's own field - // entry, member by member, and an inline field's `options` the form view's own - // option — see `objectFormSectionFieldEntry()` and `objectFormRuntimeField()`. + // entry, member by member — see `objectFormSectionFieldEntry()`. FormFieldSchema, - FormSelectOptionSchema, type FormField, type FormFieldInput, } from './view.zod'; @@ -94,8 +92,10 @@ import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../share // `subforms[].columns` take, referenced rather than copied: all three carriers // feed one objectui grid. [#21464] An inline `object-form` field's metadata // members a field widget reads off it (`rows`, `accept`, `reference`, …) take the -// object field's own member schemas, by reference — see `objectFormRuntimeField()`. -import { InlineGridColumnSchema, FieldSchema } from '../data/field.zod'; +// object field's own member schemas, by reference, and its option's `label`, +// `description` and `visibleWhen` the object field's option's — see +// `objectFormRuntimeField()` and `buildObjectFormRuntimeOption()`. +import { InlineGridColumnSchema, FieldSchema, SelectOptionSchema } from '../data/field.zod'; // [#21589] The child field names the renderer derives a detail's line-position // field from — the one list the retired detail-entry `sortField`'s // prescriptions print (reached by relative import only, never the barrel). @@ -6022,6 +6022,79 @@ function objectFormRuntimeFieldValidation() { }); } +/** + * [#21464] One option of an inline form field's `options` — CLOSED, holding the + * keys the form's option readers draw, measured at the `.objectui-sha` pin + * `2e818d0b51ec` (each cited file byte-identical at objectui `main` `fd060f076`): + * + * - `label` and `value`: every option control the form reaches — the built-in + * select (`components/src/renderers/form/form.tsx:3975-3977`) and the four + * option widgets `SelectField`, `MultiSelectField`, `RadioField` and + * `CheckboxesField` — draws `label` (the widgets through + * `optionDisplayLabel`, `core/src/evaluator/optionRules.ts:173`, which falls + * back to the value for a blank label) and keys, compares and submits by + * `value`; + * - `visibleWhen`: the cascade offers an option only while it holds + * (`resolveCascadingOptions`, from `form.tsx:2940` and from each widget's + * `useCascadingOptions`); + * - `description`: a lookup field's typeahead searches its static options' + * description beside the label (`fields/src/widgets/LookupField.tsx:705-706`). + * + * ## `value` is a runtime value + * + * It is not the stored field's identifier (the object field's option takes a + * lowercase `SystemIdentifierSchema`): an inline form field binds no object + * column, and the readers treat the value opaquely — they compare it by + * identity, stringify it only at the control, and the built-in select maps the + * pick back to the authored value (`matchOptionValue`, `form.tsx:3955`). So it + * is `string | number | boolean`, as objectui's runtime option declares on + * purpose (`types/src/zod/form.zod.ts:142-145`: "standalone UI forms + * legitimately bind numeric/boolean values"). The shipped `object-manager` + * dialog writes `{ label: 'Box', value: 'Box' }` + * (`plugin-designer/src/ObjectManager.tsx:244`), which the stored field's + * identifier rule refused. + * + * `label` and `description` are the object field's option's own member + * schemas, by reference. `visibleWhen` is the evaluated predicate, declared + * here rather than taken from that option: the object field's option is also + * re-checked by the server on write, and an inline form field's option never + * is — it binds no object column. No form option control reads `color`, + * `default`, or objectui's `disabled` / `icon`, so each is refused with the + * spelling that works. + */ +function buildObjectFormRuntimeOption() { + const { label, description } = SelectOptionSchema.shape; + return strictObject({ + surface: 'this inline form field\'s option', + history: OBJECT_FORM_RUNTIME_FIELD_HISTORY, + aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', visible: 'visibleWhen', showWhen: 'visibleWhen' }, + guidance: { + color: + 'No form option control draws `color`: a select, radio or checkbox option is drawn as its `label`. A ' + + 'colour belongs to the object field\'s own option, which list and grid cells draw as a badge. Delete it ' + + 'here.', + default: + 'An inline option\'s `default` seeds nothing: the form opens on the block\'s `initialValues` and on the ' + + 'object\'s own field defaults. Write the pre-selected value in the block\'s `initialValues` ' + + '(`initialValues: { FIELD: VALUE }`).', + disabled: + 'No form option control reads an option\'s `disabled`: every option drawn can be picked. Offer the option ' + + 'only while it applies with its `visibleWhen`, or freeze the whole field with the field\'s own ' + + '`disabled` / `readonly`.', + icon: + 'No form option control draws an option\'s `icon`: the option is drawn as its `label`. Put the cue in the ' + + 'label, or delete it.', + }, + }, { + label, + value: z.union([z.string(), z.number(), z.boolean()]) + .describe('The value the field takes when the option is picked — a string, a number or a boolean, kept as written'), + description, + visibleWhen: EvaluatedExpressionInputSchema.optional() + .describe('Predicate (CEL) over the live `record` and `current_user` — the option is offered only while TRUE. UI gating only: nothing re-checks an inline option on write'), + }); +} + /** * [#21464] The runtime form field — one entry of an `object-form`'s * `customFields`, and the inline arm of a form section's `fields` — CLOSED, in @@ -6062,8 +6135,9 @@ function objectFormRuntimeFieldValidation() { * * Where this package already declares the member, its value schema is taken * by reference — the object field's (`FieldSchema`) for the widget metadata - * members, the form view's option for `options`, and the evaluated predicate - * for the three `*When` rules. `label`, `description` and `placeholder` are + * members and the evaluated predicate for the three `*When` rules. `options` + * takes the runtime option ({@link buildObjectFormRuntimeOption}), whose + * `value` is any runtime value the form binds. `label`, `description` and `placeholder` are * plain strings: the renderer draws each as it is, so an inline locale map * would be a React child. * @@ -6140,7 +6214,7 @@ function buildObjectFormRuntimeField() { readonly: z.boolean().optional().describe('Draw the value plainly, not editable'), hidden: z.boolean().optional().describe('Do not draw the field (its value still submits)'), placeholder: z.string().optional().describe('Placeholder text in the empty control'), - options: z.array(FormSelectOptionSchema).optional().describe('The choices of a select / radio / checkboxes field — the form view\'s own option, `{ label, value, … }`'), + options: z.array(buildObjectFormRuntimeOption()).optional().describe('The choices of a select / radio / checkboxes field (and a lookup\'s static options) — `{ label, value, description?, visibleWhen? }`, `value` a string, a number or a boolean'), validation: objectFormRuntimeFieldValidation().optional().describe('Extra rules checked at submit — `{ required?, minLength?, maxLength?, min?, max? }`, each bound rule a `{ value, message }`, and `required` the message a required field shows'), dependsOn: z.union([z.string(), FieldSchema.shape.dependsOn.unwrap()]).optional().describe('The field(s) this field\'s options depend on: the form gates the field until they are set and re-evaluates its options as they change — a field name, or the object field\'s list of names / `{ field, param }` entries'), visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the field is drawn only when TRUE'), From 4eab8b5eab6fe5d7d6c22fc8e2746c6b871378aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:54:32 +0000 Subject: [PATCH 11/13] test(dogfood): classify the object-form page block's inline field, section and option predicates in the expression conformance ledger [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../test/expression-conformance.ledger.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 99ab76ff0a5..5b4d576b642 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -444,6 +444,34 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ ], note: 'Deliberately NOT `fail-soft-log` like `cel-action-disabled`: the renderers measured here answer a faulting `disabled` with `true`, and on an un-negated enablement leg that `true` greys the control out — SchemaRenderer.tsx:1258-1265 states exactly that asymmetry ("on the negated visibility legs that means SHOWN, here it means GREYED OUT"). The objectui fix `cel-action-disabled` cites is about an EMPTY `disabled: \'\'`, which `hasDeclaredVisibilityGate` / `hasDeclaredPredicate` now treat as no gate, not about a faulting one. ⚠️ Scope consequence worth knowing: the node-gate leg evaluates at PAGE scope, so a row-scoped `record.*` predicate that does not resolve there faults and greys the button out whatever the row says. Same bare-string limit as `cel-action-block-visible-closed`. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', }, + // The S-forms stage of #21464: the `object-form` / `object-master-detail-form` + // page blocks took a closed inline form field (`customFields`, and a + // section's inline entry) with its own option, and a section shape of their + // own, each declaring the form's predicates. Read (not run) at the + // `.objectui-sha` pin `2e818d0b51ec`; every file cited below is byte-identical + // at objectui `main` `fd060f076`. + { + id: 'cel-form-block-field-rule', + summary: '`object-form` page-block inline field rules and section visibility (an inline field\'s visibleWhen / readonlyWhen / requiredWhen, a section\'s visibleWhen)', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: + 'BUILD-TIME GATE, measured here: lint/validate-component-props.ts parses `ComponentPropsMap["object-form" | "object-master-detail-form"]`, the rows that declare these slots. EVALUATOR: console (objectui) form renderer `renderFormField` (components/src/renderers/form/form.tsx:2732-2758) → `resolveFieldRuleState` (core/src/evaluator/fieldRules.ts:435-504) → `evalFieldPredicate` → @objectstack/formula celEngine (interpret), against the live form record + `previous` + the host predicate scope (`current_user`). A section reaches the same evaluator through its divider row: plugin-form `projectSectionDivider` carries the section\'s `visibleWhen` onto the row (ObjectForm.tsx:1716-1720), and a hidden divider hides the fields it claims (form.tsx:1649-1690). A predicate that faults answers the rule\'s fault constant — shown, not read-only, not required (fieldRules.ts:347-349) — and warns once per predicate source: fail-SOFT-LOG, the face `cel-ui` records for the form view\'s own field and section predicates. UI only: no object field declares these predicates, so nothing on the write path reads them', + covers: [ + 'ui/component.zod.ts:buildObjectFormRuntimeField.visibleWhen', + 'ui/component.zod.ts:buildObjectFormRuntimeField.readonlyWhen', + 'ui/component.zod.ts:buildObjectFormRuntimeField.requiredWhen', + 'ui/component.zod.ts:buildObjectFormSection.visibleWhen', + ], + note: 'Separate from `cel-ui`, which classifies the FORM VIEW\'s field and section `visibleWhen` with the same evaluator and the same fault face, because these are a page block\'s own declarations and include `readonlyWhen` / `requiredWhen`, which `cel-ui`\'s visibility summary does not describe; and separate from `cel-field-rule`, whose OBJECT-field rules the server also enforces on write. ⛔ NOT MEASURED HERE: the renderer was read at the pin, not run.', + }, + { + id: 'cel-form-block-option-visible', + summary: '`object-form` page-block inline option gating (an inline field\'s options[].visibleWhen)', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: 'console (objectui) form renderer (components/src/renderers/form/form.tsx:2934-2944) and the option widgets SelectField / MultiSelectField / RadioField / CheckboxesField → useCascadingOptions → resolveCascadingOptions → resolveVisibleOptions (core/src/evaluator/optionRules.ts:97-110) → evalFieldPredicate → @objectstack/formula celEngine (interpret), per OPTION against the live form record + the host predicate scope, with fallback TRUE: a faulting predicate leaves the option OFFERED and warns. UI gating only — an inline option binds no object column, so the rule validator that re-checks an OBJECT field\'s option on write (`cel-select-option-visible`) never sees it', + covers: ['ui/component.zod.ts:buildObjectFormRuntimeOption.visibleWhen'], + note: 'The `cel-action-param-option-visible` shape — the option widgets narrowing a list — on a page block\'s inline field, and a separate row from `cel-form-block-field-rule` because the evaluator path differs. ⛔ NOT MEASURED HERE: the renderer and the widgets were read at the pin, not run.', + }, { id: 'cel-flow', summary: 'flow / loader branching + filter predicates', From 1ea6b3cc92dc10963381e89347b9960585fb7aba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:59:20 +0000 Subject: [PATCH 12/13] chore(spec): regenerate the component reference and the strictness counts for the runtime option [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- .../2026-07-unknown-key-strictness-ledger.counts/ui.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ab4a89f2ca1..14df916b251 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -572,7 +572,7 @@ Sort field and direction pair | **readonly** | `boolean` | optional | Draw the value plainly, not editable | | **hidden** | `boolean` | optional | Do not draw the field (its value still submits) | | **placeholder** | `string` | optional | Placeholder text in the empty control | -| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | The choices of a select / radio / checkboxes field — the form view's own option, `{ label, value, … }` | +| **options** | `{ label: string; value: string \| number \| boolean; description?: string; visibleWhen?: string \| object }[]` | optional | The choices of a select / radio / checkboxes field (and a lookup's static options) — `{ label, value, description?, visibleWhen? }`, `value` a string, a number or a boolean | | **validation** | `{ required?: string; minLength?: object; maxLength?: object; min?: object; … }` | optional | Extra rules checked at submit — `{ required?, minLength?, maxLength?, min?, max? }`, each bound rule a `{ value, message }`, and `required` the message a required field shows | | **dependsOn** | `string \| (string \| { field: string; param?: string })[]` | optional | The field(s) this field's options depend on: the form gates the field until they are set and re-evaluates its options as they change — a field name, or the object field's list of names / `{ field, param }` entries | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the field is drawn only when TRUE | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 1094e8adf24..699e00cc539 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 203 | 192 | 4 | 0 | 7 | +| `ui/` | 204 | 193 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 73 | +| `component.zod.ts` | 74 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **203** | +| **total** | **204** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 203**, in 4 file(s). +**7 strip of 204**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **203** | +| **total** | **7** | **204** | | Bucket | Sites | |---|---| From e948519eddacfb6664be7b6ab17b485e152cd892 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:04:05 +0000 Subject: [PATCH 13/13] =?UTF-8?q?docs(spec):=20the=20S-forms=20changeset?= =?UTF-8?q?=20and=20both=20D3=20entries=20quote=20the=20re-run=20census=20?= =?UTF-8?q?=E2=80=94=20option=20lists=20evaluated,=20the=20object-manager?= =?UTF-8?q?=20writer=20parses,=20the=20sections=20refusals=20are=20the=20g?= =?UTF-8?q?roup-reference=20probes=20[wip]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...props-form-custom-fields-sections-typed.md | 12 ++--- .../18.ui-object-form-custom-fields-typed.ts | 31 ++++++++----- .../18.ui-object-form-sections-typed.ts | 13 +++--- packages/spec/src/migrations/registry.ts | 44 ++++++++++++------- 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/.changeset/21464-component-props-form-custom-fields-sections-typed.md b/.changeset/21464-component-props-form-custom-fields-sections-typed.md index 8838972d732..b7778a880a2 100644 --- a/.changeset/21464-component-props-form-custom-fields-sections-typed.md +++ b/.changeset/21464-component-props-form-custom-fields-sections-typed.md @@ -12,10 +12,10 @@ Clause-②: yes (narrowing) **`@objectstack/spec`** -- **`object-form` `customFields` is a list of closed runtime form fields.** It was `z.unknown()`. Each member is the field the form merges over the fields it generates from the object's metadata and draws as written, and the spec now declares it: `name` (its identity), `label`, `description`, `type`, `inputType`, `widget`, `required`, `disabled`, `readonly`, `hidden`, `placeholder`, `options`, `validation`, `dependsOn`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan`, `span`, `group`, and the metadata a field widget reads off it — `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `returnType`, `summaryOperations`, `columns`. Members this package already declares take that declaration by reference (the object field's own, the form view's option, the evaluated predicates); `label`, `description` and `placeholder` are plain strings. +- **`object-form` `customFields` is a list of closed runtime form fields.** It was `z.unknown()`. Each member is the field the form merges over the fields it generates from the object's metadata and draws as written, and the spec now declares it: `name` (its identity), `label`, `description`, `type`, `inputType`, `widget`, `required`, `disabled`, `readonly`, `hidden`, `placeholder`, `options`, `validation`, `dependsOn`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan`, `span`, `group`, and the metadata a field widget reads off it — `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `returnType`, `summaryOperations`, `columns`. Members this package already declares take that declaration by reference (the object field's own, the evaluated predicates); `label`, `description` and `placeholder` are plain strings. An `options` entry is the runtime option the form's option controls draw, closed: `label`, `value`, `description` (a lookup searches it), `visibleWhen` (the cascade offers the option only while it holds). Its `value` is a string, a number or a boolean, kept as written — an inline field binds no object column, so a stored field's lowercase-identifier rule does not apply, and `{ label: 'Box', value: 'Box' }` parses. - **The `sections` of `object-form` and `object-master-detail-form` are one page-block section shape.** They were `z.array(z.unknown())`. A section takes the form view's section keys — `name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`, `columns`, `pane`, `group`, `fields` — and the form view's group-reference rule; each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline runtime form field (the `customFields` member). The stored form view's `FormSectionSchema` is unchanged. - **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so a form view's parse-time folds do not run there: a section `visibleOn` and a string `columns` reached the form raw and were dropped. Both are refused with the canonical spelling, and so is a `{ field }` entry's or an inline field's `visibleOn`. -- **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, and a locale map where the form draws a plain string. +- **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, an option's `color`, `default`, `disabled` or `icon` (no form option control reads them), and a locale map where the form draws a plain string. - **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`; the shapes themselves are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot, so the `object-form` row's input and parsed types now differ and it gains the one new export, the type `ObjectFormPropsParsed` (ADR-0122), as `ObjectMasterDetailFormPropsParsed` already is. ## FROM → TO @@ -28,6 +28,8 @@ Clause-②: yes (narrowing) | `customFields: [{ name: 'a', validation: { required: true } }]` | `customFields: [{ name: 'a', required: true }]` (a string `validation.required` is the message) | | `customFields: [{ name: 'a', validation: { pattern: { value, message } } }]` | `customFields: [{ name: 'a', pattern: '^[A-Z]+$' }]` | | `customFields: [{ name: 'items', type: 'grid', min_rows: 1 }]` | `customFields: [{ name: 'items', type: 'grid', columns: [ … ] }]` — the widget's defaults until it reads a camelCase key | +| `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold', default: true }] }]` | `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], initialValues: { tier: 'gold' }` | +| `options: [{ label: 'Gold', value: 'gold', color: '#d4af37' }]` on an inline field | `options: [{ label: 'Gold', value: 'gold' }]` — a colour belongs on the object field's own option | | `sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }]` | `sections: [{ fields: ['a'], visibleWhen: 'record.b == 1' }]` | | `sections: [{ fields: ['a'], columns: '2' }]` | `sections: [{ fields: ['a'], columns: 2 }]` | | `sections: [{ fields: [{ field: 'a', visibleOn: '…' }] }]` | `sections: [{ fields: [{ field: 'a', visibleWhen: '…' }] }]` | @@ -37,9 +39,9 @@ The one-line fix: write each inline field in camelCase with the members the form ## Who is affected, measured -A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. +A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, and through a `.map` over a constant list — the first run of this census read such a list as non-static and so never parsed the object manager's options; that miss is why an inline option's `value` is now a runtime value. Every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand. -- **objectstack** at `ced3e1ae47` (the five commits `main` has gained since add no writer): three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. -- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `b92329c894` (identical results; every cited reader file is byte-identical between the two): **`customFields`** — 31 values parse (four block literals, the designer's object manager, 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. +- **objectstack** at `316be321ef`, this branch's merge base: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge. +- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `fd060f076` (every cited reader file is byte-identical between the two; `main` adds three test values, which parse): **`customFields`** — 31 values parse (four block literals; the designer's object manager, whose `icon` and `group` options `{ label: 'Box', value: 'Box' }`, `{ label: 'Custom Objects', value: 'Custom Objects' }`, … parse as runtime option values — typed as the form view's option, a stored field's lowercase identifier, both were refused; and 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge. - **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member. - **Deployed metadata** was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts index df1ef382844..938af4da280 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-custom-fields-typed.ts @@ -9,29 +9,38 @@ import type { SemanticMigration } from '../../types.js'; // declares a closed runtime form field of the members the form draws, in // camelCase, keyed by `name`. D3 only: page-component `properties` is not // parsed on the metadata save or load path, so a stored page is never refused; -// and the authored census found no working member to respell — the refused -// values are a type-level test's `visibleOn` and a fixture pinning that an -// inline `defaultValue` seeds nothing. +// and the authored census, with every inline option list evaluated, found no +// working member to respell — the refused values are a type-level test's +// `visibleOn` and a fixture pinning that an inline `defaultValue` seeds +// nothing. The shipped object-manager dialog's options (`{ label: 'Box', value: +// 'Box' }`, …) parse because an inline option's `value` is a runtime value, not +// a stored field's identifier; typed as the form view's option, they did not. export const entry: SemanticMigration = { id: 'ui-object-form-custom-fields-typed', surface: 'page `object-form` components — `properties.customFields` (which used to accept any value)', - replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, … }` — the members the ' - + 'form draws, in camelCase. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a ' - + 'member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave the `grid` widget\'s ' - + 'snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.', + replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the ' + + 'members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with ' + + '`value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as ' + + '`visibleWhen`, move a member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave ' + + 'the `grid` widget\'s snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase ' + + 'spelling.', reason: 'The form merges `customFields` over the fields it generates from the object\'s metadata — a member ' + 'naming a declared field replaces that field\'s whole definition, any other is added — and draws each ' + 'member as it was written, handing it to the field widget as its metadata. The page-component row ' + 'declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the ' + 'component-props gate, and the form drew the field without it. The row now takes a closed runtime form ' + 'field of the members the form draws, keyed by `name`, each typed to its read — by reference where this ' - + 'package already declares the member (the object field\'s metadata members, the form view\'s option, the ' - + 'evaluated predicates). It is read where every page component\'s props are: the component-props gate ' + + 'package already declares the member (the object field\'s metadata members, the evaluated predicates). ' + + 'An option is the runtime option the form\'s option controls draw — `label`, `value`, `description`, ' + + '`visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds ' + + 'no object column, so a stored field\'s lowercase identifier rule does not apply to it. It is read where ' + + 'every page component\'s props are: the component-props gate ' + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' - + 'found no working member to respell. Deployed metadata NOT MEASURED.', + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' + + 'with every inline option list evaluated, found no working member to respell. Deployed metadata NOT ' + + 'MEASURED.', acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. ' + 'Each form draws every inline field with the label, type and rules its member names.', diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts index 33b26e5d7c4..2dff86b334e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-sections-typed.ts @@ -10,9 +10,11 @@ import type { SemanticMigration } from '../../types.js'; // keys plus the three entry arms the form reads, canonical spellings only — and // the stored form view is unchanged. D3 only: page-component `properties` is // not parsed on the metadata save or load path, so a stored page is never -// refused; and the authored census found no working section to respell — the -// refused values are objectui's probes that a section's retired style keys -// reach nothing. +// refused; and the authored census, with every inline option list evaluated, +// found no working section to respell — the refused values are the two probes +// in objectui's `formSectionGroupReference-7051.test.tsx` (a section declaring +// neither `fields` nor `group`, and a group-owned `label` / `collapsible` +// beside `group`), shapes that test says this door refuses at parse. export const entry: SemanticMigration = { id: 'ui-object-form-sections-typed', surface: 'page `object-form` and `object-master-detail-form` components — `properties.sections` (whose ' @@ -35,8 +37,9 @@ export const entry: SemanticMigration = { + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' - + 'found no working section to respell. Deployed metadata NOT MEASURED.', + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' + + 'with every inline option list evaluated, found no working section to respell. Deployed metadata NOT ' + + 'MEASURED.', acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' + 'validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under ' + '`properties.sections`. Each form draws every section with the heading, visibility and columns it ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 944cbd946e7..6413a91ee4e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -19899,29 +19899,38 @@ const step18: MigrationStep = { // declares a closed runtime form field of the members the form draws, in // camelCase, keyed by `name`. D3 only: page-component `properties` is not // parsed on the metadata save or load path, so a stored page is never refused; - // and the authored census found no working member to respell — the refused - // values are a type-level test's `visibleOn` and a fixture pinning that an - // inline `defaultValue` seeds nothing. + // and the authored census, with every inline option list evaluated, found no + // working member to respell — the refused values are a type-level test's + // `visibleOn` and a fixture pinning that an inline `defaultValue` seeds + // nothing. The shipped object-manager dialog's options (`{ label: 'Box', value: + // 'Box' }`, …) parse because an inline option's `value` is a runtime value, not + // a stored field's identifier; typed as the form view's option, they did not. { id: 'ui-object-form-custom-fields-typed', surface: 'page `object-form` components — `properties.customFields` (which used to accept any value)', - replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, … }` — the members the ' - + 'form draws, in camelCase. Write a `visibleOn` (or a legacy `condition`) as `visibleWhen`, move a ' - + 'member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave the `grid` widget\'s ' - + 'snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase spelling.', + replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the ' + + 'members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with ' + + '`value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as ' + + '`visibleWhen`, move a member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave ' + + 'the `grid` widget\'s snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase ' + + 'spelling.', reason: 'The form merges `customFields` over the fields it generates from the object\'s metadata — a member ' + 'naming a declared field replaces that field\'s whole definition, any other is added — and draws each ' + 'member as it was written, handing it to the field widget as its metadata. The page-component row ' + 'declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the ' + 'component-props gate, and the form drew the field without it. The row now takes a closed runtime form ' + 'field of the members the form draws, keyed by `name`, each typed to its read — by reference where this ' - + 'package already declares the member (the object field\'s metadata members, the form view\'s option, the ' - + 'evaluated predicates). It is read where every page component\'s props are: the component-props gate ' + + 'package already declares the member (the object field\'s metadata members, the evaluated predicates). ' + + 'An option is the runtime option the form\'s option controls draw — `label`, `value`, `description`, ' + + '`visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds ' + + 'no object column, so a stored field\'s lowercase identifier rule does not apply to it. It is read where ' + + 'every page component\'s props are: the component-props gate ' + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' - + 'found no working member to respell. Deployed metadata NOT MEASURED.', + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' + + 'with every inline option list evaluated, found no working member to respell. Deployed metadata NOT ' + + 'MEASURED.', acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. ' + 'Each form draws every inline field with the label, type and rules its member names.', @@ -20023,9 +20032,11 @@ const step18: MigrationStep = { // keys plus the three entry arms the form reads, canonical spellings only — and // the stored form view is unchanged. D3 only: page-component `properties` is // not parsed on the metadata save or load path, so a stored page is never - // refused; and the authored census found no working section to respell — the - // refused values are objectui's probes that a section's retired style keys - // reach nothing. + // refused; and the authored census, with every inline option list evaluated, + // found no working section to respell — the refused values are the two probes + // in objectui's `formSectionGroupReference-7051.test.tsx` (a section declaring + // neither `fields` nor `group`, and a group-owned `label` / `collapsible` + // beside `group`), shapes that test says this door refuses at parse. { id: 'ui-object-form-sections-typed', surface: 'page `object-form` and `object-master-detail-form` components — `properties.sections` (whose ' @@ -20048,8 +20059,9 @@ const step18: MigrationStep = { + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census ' - + 'found no working section to respell. Deployed metadata NOT MEASURED.', + + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' + + 'with every inline option list evaluated, found no working section to respell. Deployed metadata NOT ' + + 'MEASURED.', acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' + 'validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under ' + '`properties.sections`. Each form draws every section with the heading, visibility and columns it '