diff --git a/.changeset/21464-component-props-form-family-typed.md b/.changeset/21464-component-props-form-family-typed.md new file mode 100644 index 0000000000..24ff21831f --- /dev/null +++ b/.changeset/21464-component-props-form-family-typed.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: four members of an `object-form` page block take the shape the form reads instead of any value — `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` (#21464) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: 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`** + +- **Four members are typed.** `ComponentPropsMap['object-form']` declared `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` as `z.unknown()`, although the form reads each with one shape. Any value passed, and an off-shape one was answered with a silent default: a `submitBehavior` whose `kind` the form does not know showed the thank-you panel; a misspelled `contentLayout` stacked the modal's sections; a `navigateOnSuccess` that is not a string failed the submit after the record had been written; a misspelled `mobile` member was ignored. +- **`submitBehavior` is the form view's own block, by reference** — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }`, `{ kind: 'continue' }` or `{ kind: 'next-record' }` — with the same rule on a `redirect` `url` a form view carries: a relative path, interpolating declared record fields as `{{record.field_name}}`. +- **The measured shape, where no form view declares the member:** `contentLayout` is `'simple'` or `'tabbed'`; `navigateOnSuccess` is a relative path string (`{id}` / `{recordId}` interpolate the saved record's id); `mobile` is `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers. +- **`ObjectFormProps`** carries these types on the four members instead of `unknown`. +- **The form's `fields` and `sections`, and the master-detail form's `fields` and `sections`, are not narrowed** and still accept any value. The form draws a top-level `fields` entry written as `{ name }` by that name, and it draws an inline runtime field (`{ name, type, … }`) written inside a section's `fields` as it stands — two shapes the typed members (field-name strings; the form view's section, whose field entry is keyed by `field`) would refuse. Each is held until that read is ruled. The master-detail form hands both members to its form unchanged, so they are held with the form's. +- **`customFields` is not narrowed either.** Its entries are the console's runtime form field (keyed by `name`), which the spec has not declared; it is typed once the spec declares it. + +## FROM → TO + +| you wrote on an `object-form` | write instead | +|:--|:--| +| `submitBehavior: 'thank-you'` | `submitBehavior: { kind: 'thank-you' }` | +| `submitBehavior: { kind: 'toast' }` (any `kind` outside the four) | one of `thank-you`, `redirect`, `continue`, `next-record` | +| `submitBehavior: { kind: 'thank-you', heading: 'Done' }` | `{ kind: 'thank-you', title: 'Done' }` | +| `submitBehavior: { kind: 'redirect', url: 'https://app.example.com/done' }` | a relative path: `url: '/done'` | +| `contentLayout: 'tabs'` | `contentLayout: 'tabbed'` | +| `navigateOnSuccess: { url: '/orders/{id}' }` | `navigateOnSuccess: '/orders/{id}'`, or `submitBehavior: { kind: 'redirect', url: '/orders/{{record.id}}' }` | +| `mobile: { stepper: 'yes' }` | `mobile: { stepper: true }`, or `'auto'` for phone-width viewports only | +| `mobile: { stepperFieldsPerStep: 0 }` | delete the key (one field a step is the default), or a positive integer | + +The one-line fix: write each member as the table above shows. No conversion is registered, because an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote; the D3 entry `ui-object-form-members-typed` carries that judgment. + +## Who is affected, measured + +A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, a call into a local helper that builds the node, or a direct parse through the row. Each member's value is read through same-file constants and local helpers. The control is `objectName` on the same nodes. + +- **objectstack** at `e909aa0a23`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 16 `object-form` nodes (the control on 13). Three values among the four members: the showcase's new-project wizard `submitBehavior` (a thank-you panel) and two copies of it in the lint and spec tests. All three parse. +- **objectui** at the `.objectui-sha` pin `89cad75d55`: 539 `object-form` nodes (the control on 522). Across the four members there are 73 values: 60 are static, and 56 of them parse. The 4 that do not are test fixtures of a protocol-relative redirect (`//example.com/thanks`), each asserting that the form refuses it and navigates nowhere. Of the 13 values that are not static, 9 are relative redirects that parse by inspection, and 4 are redirect fixtures the form refuses (three same-origin absolute URLs and one protocol-relative one). No refused value is one the form draws. +- **Deployed metadata** was not measured. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ba4022cdce..7531d2b515 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -539,7 +539,7 @@ Sort field and direction pair | **drawerWidth** | `string \| number` | optional | Drawer width (drawer) | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal) | | **modalCloseButton** | `boolean` | optional | Show the modal close button (modal) | -| **contentLayout** | `any` | optional | Modal content layout config (modal) | +| **contentLayout** | `Enum<'simple' \| 'tabbed'>` | optional | How the modal presentation lays out its sections (modal) — 'simple' stacks them (the default); 'tabbed' puts each section on its own tab once more than one section has a field to show | | **confirmOnDiscard** | `boolean` | optional | Confirm before discarding edits (drawer/modal) | | **submitText** | `string \| Record` | optional | Submit button label | | **cancelText** | `string \| Record` | optional | Cancel button label | @@ -548,14 +548,32 @@ Sort field and direction pair | **showSubmit** | `boolean` | optional | Show the submit button | | **showCancel** | `boolean` | optional | Show the cancel button | | **showReset** | `boolean` | optional | Show the reset button | -| **submitBehavior** | `any` | optional | What happens after a successful submit (`{ kind: 'thank-you' \| …, title?, message? }`) | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | What happens after a successful submit — the same block a form view's `submitBehavior` declares: `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` (a relative `url`, interpolating declared record fields as `{{record.field_name}}`), `{ kind: 'continue' }` or `{ kind: 'next-record' }`. Takes precedence over `navigateOnSuccess` and `resetOnSuccess` | | **successMessage** | `string \| Record` | optional | Toast message on successful submit | | **resetOnSuccess** | `boolean` | optional | Reset the form after a successful submit | -| **navigateOnSuccess** | `any` | optional | Navigate after a successful submit | +| **navigateOnSuccess** | `string` | optional | Relative path to navigate to after a successful create/update — `{id}` / `{recordId}` are replaced with the saved record's id, URL-escaped; an absolute URL is refused at submit and reported on the success toast. Ignored when `submitBehavior` is set — prefer `submitBehavior` | | **readOnly** | `boolean` | optional | Render every field read-only | | **initialValues** | `Record` | optional | Prefill values (create mode) | | **initialData** | `Record` | optional | Alternate spelling of `initialValues` the renderer also reads | -| **mobile** | `any` | optional | Mobile presentation overrides | +| **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.submitBehavior[kind='redirect']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'redirect'` | ✅ | | +| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | +| **delayMs** | `integer` | optional | | + +### Nested Shape: `ObjectFormProps.mobile` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **stickyActions** | `boolean` | optional | Pin the submit / cancel bar to the bottom of a phone-width viewport | +| **stepper** | `boolean \| 'auto'` | optional | One-step-at-a-time wizard for a flat form — `true` always, `'auto'` only on a phone-width viewport once the form has `stepperMinFields` fields, `false` (the default) never | +| **stepperMinFields** | `integer` | optional | The field count at which `stepper: 'auto'` steps up (default 8) | +| **stepperFieldsPerStep** | `integer` | optional | Fields shown per stepper step (default 1) | +| **fullscreenLongText** | `boolean` | optional | Offer a fullscreen editor on textarea and rich-text fields | --- 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 b7dba0b3b2..a2b753ec14 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/` | 191 | 180 | 4 | 0 | 7 | +| `ui/` | 192 | 181 | 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` | 61 | +| `component.zod.ts` | 62 | | `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** | **191** | +| **total** | **192** | ## `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 191**, in 4 file(s). +**7 strip of 192**, 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** | **191** | +| **total** | **7** | **192** | | Bucket | Sites | |---|---| diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 8b331234f2..f90453139c 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 217, - "droppedRefinementSites": 653, + "publishedSchemasWithDroppedRefinements": 218, + "droppedRefinementSites": 654, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1364,6 +1364,11 @@ "filter.element" ] }, + "ui/ObjectFormProps": { + "sites": [ + "submitBehavior.options[1].url" + ] + }, "ui/ObjectGanttProps": { "sites": [ "filter.element", diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-form-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-members-typed.ts new file mode 100644 index 0000000000..a579243d97 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-form-members-typed.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — four members of the `object-form` page block were `z.unknown()` +// although the form reads each with a fixed shape, so an off-shape value passed +// the component-props gate and the form fell back or ignored it in silence. The +// row now takes the form view's own `submitBehavior` by reference and the +// measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The +// form's `fields` and `sections` and the master-detail form's two are held at +// `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime +// field inside a section, which the typed shapes would refuse), and +// `customFields` waits for the spec to declare objectui's runtime form field. +// D3 only: page-component `properties` is not parsed on the metadata save or +// load path, so a stored page is never refused; an off-shape value has no +// rewrite that says what the author meant; and the authored census found no +// authored value to respell — the refused values are fixtures probing that the +// form refuses them. +export const entry: SemanticMigration = { + id: 'ui-object-form-members-typed', + surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, ' + + '`.navigateOnSuccess` and `.mobile` (which used to accept any value)', + replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the ' + + 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, ' + + 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; ' + + '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, ' + + 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two ' + + 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` ' + + 'destination to a relative path; write `heading` as `title`.', + reason: 'The form reads these members with one shape, and the page-component row declared them ' + + '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one ' + + 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; ' + + 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a ' + + 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it ' + + 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes ' + + 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect ' + + '`url` through — so one value is judged the same way on the form view and the block, and the measured ' + + 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay ' + + 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, ' + + 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the ' + + 'runtime form field its entries are. 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 an off-shape value has no rewrite that both keeps what the ' + + 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the ' + + 'upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. ' + + '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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d4adb357c6..09cc405144 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6138,6 +6138,22 @@ 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-members-typed', + order: 67, + text: + 'It also types four members of the `object-form` page block (#21464, the third stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, ' + + '`navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, ' + + 'so a `submitBehavior` `kind` the form does not know passed every door and fell through to the ' + + 'thank-you panel. `submitBehavior` takes the form view\'s own block by reference; the other three ' + + 'take the measured shape. The form\'s `fields` and `sections` and the master-detail form\'s two ' + + 'stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, ' + + 'which the typed shapes would refuse — and `customFields` stays open until the spec declares the ' + + 'runtime form field its entries are. 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-members-typed`.', + }, { id: 'ui-object-grid-export-options-closed', order: 59, @@ -19479,6 +19495,55 @@ 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 — four members of the `object-form` page block were `z.unknown()` + // although the form reads each with a fixed shape, so an off-shape value passed + // the component-props gate and the form fell back or ignored it in silence. The + // row now takes the form view's own `submitBehavior` by reference and the + // measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The + // form's `fields` and `sections` and the master-detail form's two are held at + // `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime + // field inside a section, which the typed shapes would refuse), and + // `customFields` waits for the spec to declare objectui's runtime form field. + // D3 only: page-component `properties` is not parsed on the metadata save or + // load path, so a stored page is never refused; an off-shape value has no + // rewrite that says what the author meant; and the authored census found no + // authored value to respell — the refused values are fixtures probing that the + // form refuses them. + { + id: 'ui-object-form-members-typed', + surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, ' + + '`.navigateOnSuccess` and `.mobile` (which used to accept any value)', + replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the ' + + 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, ' + + 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; ' + + '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, ' + + 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two ' + + 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` ' + + 'destination to a relative path; write `heading` as `title`.', + reason: 'The form reads these members with one shape, and the page-component row declared them ' + + '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one ' + + 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; ' + + 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a ' + + 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it ' + + 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes ' + + 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect ' + + '`url` through — so one value is judged the same way on the form view and the block, and the measured ' + + 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay ' + + 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, ' + + 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the ' + + 'runtime form field its entries are. 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 an off-shape value has no rewrite that both keeps what the ' + + 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the ' + + 'upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. ' + + '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.', + }, // #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a // bare format array (the list view's legacy spelling, which the list view lifts // to `{ formats }`) was accepted on the grid, whose renderer reads diff --git a/packages/spec/src/ui/component-form-family-typed-members.pin.test.ts b/packages/spec/src/ui/component-form-family-typed-members.pin.test.ts new file mode 100644 index 0000000000..8dbaa06bc6 --- /dev/null +++ b/packages/spec/src/ui/component-form-family-typed-members.pin.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21464, stage 3] Four of the form family's nine `z.unknown()` members are + * typed: `object-form` `contentLayout`, `submitBehavior`, `navigateOnSuccess` + * and `mobile`. The other five stay in the enumeration pin's ledger: the + * form's `fields` and `sections` and the master-detail form's two are held, + * because the form draws a value each typed shape would refuse (a `{ name }` + * field entry; an inline runtime field inside a section), and `customFields` + * waits with the objectui-held contracts (its entries are objectui's runtime + * `FormField`, which the spec has not declared). + * + * ## The defect this file closes + * + * Each of the four is read with one shape (measured at the `.objectui-sha` pin + * `89cad75d55`; the read points are in the members' docblocks), and the row + * declared them `z.unknown()`. So a `submitBehavior` whose `kind` the form does + * not know, a `contentLayout: 'tabs'`, a numeric `navigateOnSuccess` and a + * misspelled `mobile` member all passed the component-props gate, and the form + * fell back to its thank-you panel, stacked the sections, threw after the + * record was written, or ignored the key — with no report. + * + * ## What is pinned, and why each half + * + * - §1 THE DECLARED SHAPES PARSE: every shape a measured writer authors parses + * byte-identical — none of the four carries a default, so the parsed value + * IS the authored one. A refusal pin with no lit control passes just as well + * when the door refuses everything. + * - §2 THE REFUSALS: an off-shape value of each member is refused with the + * code AND the path, so a refusal for the wrong reason reds. + * - §3 ONE SCHEMA: `submitBehavior` holds the form view's own def by identity, + * and the two shapes declared here hold exactly the measured vocabulary. + * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. + * + * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the + * other half: these four 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 } from './component.zod'; +import { FormViewSchema } from './view.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +const BASE = { objectName: 'account' } as const; +const parse = (props: Record) => ComponentPropsMap['object-form'].safeParse({ ...BASE, ...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('.') })); +} + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the declared shapes parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 each member accepts every shape a measured writer authors', () => { + const BYTE_IDENTICAL: ReadonlyArray]> = [ + ['contentLayout \'tabbed\'', { contentLayout: 'tabbed' }], + ['contentLayout \'simple\'', { contentLayout: 'simple' }], + // The showcase's own wizard page (`examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`). + ['a thank-you panel with a title and a message', { + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Your new project is ready.' }, + }], + ['a bare thank-you panel', { submitBehavior: { kind: 'thank-you' } }], + ['a relative redirect with a delay', { submitBehavior: { kind: 'redirect', url: '/apps/x/done', delayMs: 241 } }], + ['a redirect interpolating declared record fields', { + submitBehavior: { kind: 'redirect', url: '/t/{{record.slug}}?ref={{record.id}}' }, + }], + ['continue', { submitBehavior: { kind: 'continue' } }], + ['next-record', { submitBehavior: { kind: 'next-record' } }], + ['a navigateOnSuccess template', { navigateOnSuccess: '/apps/x/o/record/{id}' }], + ['mobile fullscreenLongText', { mobile: { fullscreenLongText: true } }], + ['mobile stickyActions', { mobile: { stickyActions: true } }], + ['an empty mobile block (its presence marks the wrapper)', { mobile: {} }], + ['mobile stepper true with a minimum', { mobile: { stepper: true, stepperMinFields: 99 } }], + ['mobile stepper auto with a minimum', { mobile: { stepper: 'auto', stepperMinFields: 3 } }], + ['mobile stepper with fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 2 } }], + ['mobile stepper false', { mobile: { stepper: false } }], + ]; + + for (const [label, props] of BYTE_IDENTICAL) { + it(`parses ${label}, byte-identical`, () => { + const r = parse(props); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, ...props }); + }); + } + + it('an absent member stays absent', () => { + const r = parse({}); + expect(issues(r)).toEqual([]); + expect(r.success && Object.keys(r.data)).toEqual(['objectName']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the refusals +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 each member refuses an off-shape value', () => { + const REFUSED: ReadonlyArray, code: string, path: string]> = [ + ['a contentLayout outside the two', { contentLayout: 'tabs' }, 'invalid_value', 'contentLayout'], + ['a contentLayout object', { contentLayout: { mode: 'tabbed' } }, 'invalid_value', 'contentLayout'], + ['a submitBehavior kind the form does not know', { submitBehavior: { kind: 'toast' } }, 'invalid_union', 'submitBehavior.kind'], + ['a submitBehavior with no kind', { submitBehavior: { title: 'Done' } }, 'invalid_union', 'submitBehavior.kind'], + ['a bare-string submitBehavior', { submitBehavior: 'thank-you' }, 'invalid_type', 'submitBehavior'], + ['a protocol-relative redirect', { submitBehavior: { kind: 'redirect', url: '//example.com/thanks' } }, 'custom', 'submitBehavior.url'], + ['an absolute redirect', { submitBehavior: { kind: 'redirect', url: 'https://app.example.com/thanks' } }, 'custom', 'submitBehavior.url'], + ['a negative redirect delay', { submitBehavior: { kind: 'redirect', url: '/done', delayMs: -1 } }, 'too_small', 'submitBehavior.delayMs'], + ['a thank-you `heading`', { submitBehavior: { kind: 'thank-you', heading: 'Done' } }, 'unrecognized_keys', 'submitBehavior'], + ['options on continue', { submitBehavior: { kind: 'continue', title: 'Again' } }, 'unrecognized_keys', 'submitBehavior'], + ['a numeric navigateOnSuccess', { navigateOnSuccess: 42 }, 'invalid_type', 'navigateOnSuccess'], + ['an object navigateOnSuccess', { navigateOnSuccess: { url: '/x' } }, 'invalid_type', 'navigateOnSuccess'], + ['a stepper outside true / false / auto', { mobile: { stepper: 'yes' } }, 'invalid_union', 'mobile.stepper'], + ['a mobile member the form does not read', { mobile: { sticky: true } }, 'unrecognized_keys', 'mobile'], + ['zero fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 0 } }, 'too_small', 'mobile.stepperFieldsPerStep'], + ['a fractional minimum', { mobile: { stepperMinFields: 2.5 } }, 'invalid_type', 'mobile.stepperMinFields'], + ['a boolean mobile', { mobile: true }, 'invalid_type', 'mobile'], + ]; + + for (const [label, props, code, path] of REFUSED) { + it(`refuses ${label} — ${code} at ${path}`, () => { + const r = parse(props); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code, path }]); + }); + } + + it('says a thank-you `heading` is its `title`', () => { + const r = parse({ submitBehavior: { kind: 'thank-you', heading: 'Done' } }); + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/`heading` → `title`/); + }); + + it('LIT CONTROL — an unknown top-level key is still refused at the row itself', () => { + expect(issues(parse({ contentLayout: 'tabbed', notAFormKey: 1 }))).toEqual([{ code: 'unrecognized_keys', path: '' }]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one schema, not a copy of its shape +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the members hold the form view\'s own def, and the measured vocabulary', () => { + const form = () => ObjectFormPropsSchema.shape; + + it('submitBehavior is the form view\'s own member — the same def', () => { + expect(form().submitBehavior.unwrap()._zod.def).toBe(FormViewSchema.shape.submitBehavior.unwrap()._zod.def); + }); + + it('contentLayout declares exactly the read\'s two layouts', () => { + expect([...form().contentLayout.unwrap().options].sort()).toEqual(['simple', 'tabbed']); + }); + + it('a mobile block declares exactly the five members the form reads', () => { + expect(Object.keys(form().mobile.unwrap().shape).sort()) + .toEqual(['fullscreenLongText', 'stepper', 'stepperFieldsPerStep', 'stepperMinFields', 'stickyActions']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 the ADR-0087 entry', () => { + it('is registered as the D3 entry step 18 carries, beside the earlier stages\' entries', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); + expect(ids).toContain('ui-object-form-members-typed'); + expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed'); + expect(ids).toContain('ui-object-map-gantt-tree-navigation-typed'); + }); +}); 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 3938c5e1d3..9671e85282 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 @@ -35,7 +35,11 @@ * `rowActions` / `bulkActions` / `batchActions`, `object-kanban` `columns`, * `object-calendar` `calendar`) in * `component-list-family-typed-members.pin.test.ts`. The family's ninth, - * `object-grid` `columns`, is held below. + * `object-grid` `columns`, is held below. The form family (`object-form` + * `contentLayout` / `submitBehavior` / `navigateOnSuccess` / `mobile`) in + * `component-form-family-typed-members.pin.test.ts`; its `fields` and + * `sections`, and the master-detail form's two, are held below, and its + * `customFields` waits with the objectui-held contracts. * * ## The STAGED reason is debt, not a verdict * @@ -124,9 +128,8 @@ function unknownMembers(schema: unknown): UnknownMember[] { */ const STAGES = { 'object-metric': 'the metric tile\'s four config blocks; the dashboard widget\'s `compareTo` and the chart\'s `aggregate` / `drillDown` are the by-reference candidates, and `trend` has no spec declaration, so it is typed to the renderer\'s read', - 'object-form': 'the form and master-detail form rows; `FormViewSchema` (`sections`, `submitBehavior`) is the by-reference candidate', - 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, and `UIActionSchema`, an objectui interface that borrows some members from the spec `Action`); the spec declares each first, contract-first, then the row takes it', - 'held-for-decision': 'a by-reference shape exists, but measured writers author values it refuses — the narrowing waits for a ruling', + 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, `UIActionSchema` — an objectui interface that borrows some members from the spec `Action` — and the runtime form field `FormField`, identity key `name`); the spec declares each first, contract-first, then the row takes it', + 'held-for-decision': 'a typed shape exists (by reference, or the renderer\'s own declared type), but measured writers author values it refuses that the renderer draws — the narrowing waits for a ruling', } as const; type Stage = keyof typeof STAGES; @@ -227,14 +230,11 @@ on(['object-metric'], ['aggregate'], staged('object-metric', 'plugin-dashboard/s on(['object-metric'], ['trend'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:254 (typed :176)')); on(['object-metric'], ['drillDown'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:265 (`ObjectMetricDrillDownConfig`, :218)')); on(['object-metric'], ['compareTo'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:267 (`CompareToConfig`, :241)')); -on(['object-form'], ['fields[]'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:961')); -on(['object-form'], ['customFields'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:755, :1180')); -on(['object-form'], ['sections[]'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:364, :1518')); -on(['object-form'], ['contentLayout'], staged('object-form', 'plugin-form/src/ModalForm.tsx:854 (`\'simple\' | \'tabbed\'`, :151)')); -on(['object-form'], ['submitBehavior'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1312-1313')); -on(['object-form'], ['navigateOnSuccess'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1373, :1412-1424')); -on(['object-form'], ['mobile'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1857')); -on(['object-master-detail-form'], ['sections[]', 'fields[]'], staged('object-form', 'plugin-form/src/MasterDetailForm.tsx:1692-1693, into the parent form')); +// 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 — so the spec declares that contract first. +on(['object-form'], ['customFields'], staged('objectui-held', 'plugin-form/src/customFieldsMerge.ts:78-108 (`FormField`, by `name`), from ObjectForm.tsx:755, :1180')); on(['object-gantt'], ['markers[]'], staged('objectui-held', 'plugin-gantt/src/ObjectGantt.tsx:2497 (`GanttMarker`)')); on(['object-timeline'], ['items[]'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:587')); on(['object-timeline'], ['mapping'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:551, :576-579')); @@ -255,6 +255,27 @@ on(['object-kanban'], ['conditionalFormatting'], staged('held-for-decision', 'pl // `ListColumn` declares no `options`, so the narrowing would refuse a value the // grid draws: held until objectstack-ai/objectui#11544 is ruled. on(['object-grid'], ['columns[]'], staged('held-for-decision', 'plugin-grid/src/ObjectGrid.tsx:2158 (`normalizeColumns`), `columns[].options` drawn by the group-header formatter at :2997-3001')); +// The renderer's own declared type for the form's `fields` is field-name +// strings (`ObjectFormSchema.fields: string[]`), but its read also draws a +// `{ name }` entry by that name, and measured writers author one: objectui's +// published page-builder guide (`skills/objectui/guides/page-builder.md:263`), +// its field-security and system-managed payload pins, and the `{ name }` row it +// pins as behaviour (`plugin-form/src/__tests__/objectFormFieldsMembers-8071.test.tsx:165-167`). +// Typing the member to strings would refuse a value the form draws. +on(['object-form'], ['fields[]'], staged('held-for-decision', 'plugin-form/src/ObjectForm.tsx:961-981 and flatFields.ts:71-79, a `{ name }` entry drawn by that name')); +// The form view's own `sections` is the by-reference shape, and every section +// key the form reads is declared there — but a section's `fields` also draws an +// inline runtime form field `{ name, type, … }` as it stands ("shape 3"), which +// the form view's field entry (keyed by `field`) refuses; objectui's README +// (`plugin-form/README.md:764`) and its submit-target pins +// (`plugin-form/src/submitTargetRefusal.test.tsx:331-358`) author that shape +// and assert that it renders and submits. +on(['object-form'], ['sections[]'], staged('held-for-decision', 'plugin-form/src/sectionFields.ts:367-369 (shape 3), reached from ObjectForm.tsx:364, :1518 and every sectioned arm')); +// The master-detail form hands both to its parent `object-form` verbatim, so +// each is read exactly as that block's member is, and held with it; its own +// `fields` writers author the `{ name }` entry too +// (`plugin-form/src/__tests__/topLevelFieldsWarnCoverage-8847.test.tsx:254-257`). +on(['object-master-detail-form'], ['sections[]', 'fields[]'], staged('held-for-decision', 'plugin-form/src/MasterDetailForm.tsx:1692-1693, into the parent form, read as `object-form`\'s')); /** Every `z.unknown()` member of every row, keyed as the ledger keys it. */ function census(): Map { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 285edfb205..2594178cfc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -34,6 +34,11 @@ import { RowHeightSchema, RowColorConfigSchema, ListViewSchema, + // [#21464] `object-form.submitBehavior` is the form view's own member (read + // off `FormViewSchema.shape`, the one place that declares the post-submit + // union): both form renderers switch on exactly that union, so one + // declaration judges the form view and the block. + FormViewSchema, } from './view.zod'; // [#21445] `object-grid.bulkActionDefs` is the list view's bulk-action def, // by identity — the element `ListViewSchema.bulkActionDefs` declares. @@ -5283,6 +5288,44 @@ const OBJECT_FORM_LAYOUT_RETIRED: ReadonlyMap = new Map([ + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'], ]); +/** + * [#21464] An `object-form`'s `mobile` block — the five members the form reads + * at the `.objectui-sha` pin `89cad75d55` (`plugin-form/src/ObjectForm.tsx:1857-1942`, + * the flat arm only), and nothing else. No form view declares the block, so + * the shape is the read's own, as objectui declares it (`ObjectFormSchema.mobile`): + * + * - `stickyActions` pins the submit / cancel bar to the bottom of the viewport + * (`:1935`, `:1941`); + * - `stepper` routes a flat form through the wizard: `true` always (a desktop + * viewport included), `'auto'` only on a phone-width viewport once the form + * has `stepperMinFields` fields (`:1874-1884`, default 8); + * `stepperFieldsPerStep` is the fields per step (`:1876`, default 1 — the + * read clamps a smaller count to 1); + * - `fullscreenLongText` offers the fullscreen editor on every long-text field + * (`:1858-1869`). + * + * The block's presence marks the wrapper (`data-mobile-form`, `:1942`), so an + * empty `{}` is legal and changes nothing else. Module-private, as + * {@link ObjectKanbanLaneSchema} is. + */ +const ObjectFormMobileSchema = lazySchema(() => strictObject({ + surface: 'this `object-form` `mobile` block', + history: + 'Until this shape was declared, `mobile` was `z.unknown()`: a misspelled member or a `stepper` ' + + 'value outside `true` / `false` / `\'auto\'` passed, and the form rendered without it.', +}, { + stickyActions: z.boolean().optional() + .describe('Pin the submit / cancel bar to the bottom of a phone-width viewport'), + stepper: z.union([z.boolean(), z.literal('auto')]).optional() + .describe("One-step-at-a-time wizard for a flat form — `true` always, `'auto'` only on a phone-width viewport once the form has `stepperMinFields` fields, `false` (the default) never"), + stepperMinFields: z.number().int().positive().optional() + .describe("The field count at which `stepper: 'auto'` steps up (default 8)"), + stepperFieldsPerStep: z.number().int().positive().optional() + .describe('Fields shown per stepper step (default 1)'), + fullscreenLongText: z.boolean().optional() + .describe('Offer a fullscreen editor on textarea and rich-text fields'), +})); + /** * `object-form` (objectui `plugin-form/src/ObjectForm.tsx` @ `eb7f586b`, plus * the sub-forms it forwards the whole bag into: `TabbedForm`, `WizardForm`, @@ -5318,8 +5361,36 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ }).optional() .describe("Field layout — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns`"), columns: z.number().optional().describe('Number of field columns (multi-column forms), honoured under either `layout`'), + /** + * [#21464] HELD at `z.unknown()` entries, in the enumeration pin's ledger as + * held for a ruling. The renderer's own declared type is field-name strings + * (objectui `ObjectFormSchema.fields: string[]`), but its read also draws a + * `{ name }` entry by that name (`plugin-form/src/ObjectForm.tsx:961-981`, + * `flatFields.ts:71-79` at the pin `89cad75d55`), and measured writers author + * one — objectui's published page-builder guide among them — so typing the + * member to strings would refuse a value the form draws. + */ fields: z.array(z.unknown()).optional().describe('Limit/order the fields shown'), + /** + * [#21464] Kept `z.unknown()`, in the enumeration pin's ledger with the + * objectui-held contracts: each member is objectui's runtime form field + * (`FormField`, identity key `name`), drawn whole by the form renderer + * (`plugin-form/src/customFieldsMerge.ts:78-108` at the pin `89cad75d55`). + * 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 — so + * the spec declares that contract first, then this member takes it. + */ customFields: z.unknown().optional().describe('Custom field definitions merged into the generated set'), + /** + * [#21464] HELD at `z.unknown()` entries, in the enumeration pin's ledger as + * held for a ruling. 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:367-369` + * at the pin `89cad75d55`, its "shape 3"), which the form view's field entry + * (keyed by `field`) refuses; objectui's README and its own pins author that + * shape and assert that it renders and submits. + */ sections: z.array(z.unknown()).optional() .describe('Form sections ({ label, description?, fields } — wizard steps / tab panes)'), title: I18nLabelSchema.optional().describe('Form title'), @@ -5335,7 +5406,15 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ drawerWidth: z.union([z.string(), z.number()]).optional().describe('Drawer width (drawer)'), modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal size (modal)'), modalCloseButton: z.boolean().optional().describe('Show the modal close button (modal)'), - contentLayout: z.unknown().optional().describe('Modal content layout config (modal)'), + /** + * [#21464] Typed to the read: the modal presentation is its only reader, + * and it tests `schema.contentLayout === 'tabbed' && groups.length > 1` + * (`plugin-form/src/ModalForm.tsx:854` at the pin `89cad75d55`, declared + * `'simple' | 'tabbed'` at `:151`); any other value stacks the sections, so + * a misspelled `'tabs'` used to draw the stacked layout with no report. + */ + contentLayout: z.enum(['simple', 'tabbed']).optional() + .describe("How the modal presentation lays out its sections (modal) — 'simple' stacks them (the default); 'tabbed' puts each section on its own tab once more than one section has a field to show"), confirmOnDiscard: z.boolean().optional().describe('Confirm before discarding edits (drawer/modal)'), submitText: I18nLabelSchema.optional().describe('Submit button label'), cancelText: I18nLabelSchema.optional().describe('Cancel button label'), @@ -5344,15 +5423,42 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ showSubmit: z.boolean().optional().describe('Show the submit button'), showCancel: z.boolean().optional().describe('Show the cancel button'), showReset: z.boolean().optional().describe('Show the reset button'), - submitBehavior: z.unknown().optional() - .describe("What happens after a successful submit ({ kind: 'thank-you' | …, title?, message? })"), + /** + * [#21464] The form view's own `submitBehavior` member, by reference + * (`FormViewSchema.shape.submitBehavior`, the union discriminated on + * `kind`). Both form renderers switch on `kind` and read exactly that + * union's members — `url` and `delayMs` on `redirect`, `title` and `message` + * on `thank-you` (`plugin-form/src/ObjectForm.tsx:1312-1370` and + * `WizardForm.tsx:1005-1065` at the pin `89cad75d55`) — and judge a redirect + * `url` at submit by parsing a form view through this same schema + * (`submitRedirect.ts:202`), so one declaration judges the form view, this + * block and the renderer's own verdict. A `kind` outside the four fell + * through to the thank-you arm in silence. + */ + submitBehavior: FormViewSchema.shape.submitBehavior + .describe("What happens after a successful submit — the same block a form view's `submitBehavior` declares: `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` (a relative `url`, interpolating declared record fields as `{{record.field_name}}`), `{ kind: 'continue' }` or `{ kind: 'next-record' }`. Takes precedence over `navigateOnSuccess` and `resetOnSuccess`"), successMessage: I18nLabelSchema.optional().describe('Toast message on successful submit'), resetOnSuccess: z.boolean().optional().describe('Reset the form after a successful submit'), - navigateOnSuccess: z.unknown().optional().describe('Navigate after a successful submit'), + /** + * [#21464] Typed to the read: `resolveSuccessNavigate` + * (`plugin-form/src/successBehavior.ts:118-131` at the pin `89cad75d55`, + * called from `ObjectForm.tsx:1373` and `WizardForm.tsx:1071`) takes a + * string template, replaces `{id}` / `{recordId}` with the saved record's + * id (URL-escaped) and follows the result only when it is a relative + * reference; a declared value it refuses is reported on the success toast + * (`ObjectForm.tsx:1412-1429`). Any other value reached `template.replace` + * after the record had been written, and the submit then reported a failure. + * It is read only when `submitBehavior` is absent, and the renderer marks it + * deprecated in that key's favour. + */ + navigateOnSuccess: z.string().optional() + .describe("Relative path to navigate to after a successful create/update — `{id}` / `{recordId}` are replaced with the saved record's id, URL-escaped; an absolute URL is refused at submit and reported on the success toast. Ignored when `submitBehavior` is set — prefer `submitBehavior`"), readOnly: z.boolean().optional().describe('Render every field read-only'), initialValues: z.record(z.string(), z.unknown()).optional().describe('Prefill values (create mode)'), initialData: z.record(z.string(), z.unknown()).optional().describe('Alternate spelling of `initialValues` the renderer also reads'), - mobile: z.unknown().optional().describe('Mobile presentation overrides'), + /** [#21464] The five members the form reads — see {@link ObjectFormMobileSchema}. */ + mobile: ObjectFormMobileSchema.optional() + .describe('Phone presentation options, each opt-in — `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`. Read by the flat (simple, sectionless) form'), })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectFormProps = z.input; @@ -5503,6 +5609,13 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ error: (issue) => typeof issue.input === 'string' ? MASTER_DETAIL_FORM_TYPE_RETIRED.get(issue.input) : undefined, }).optional().describe("Parent form presentation — the two variants the renderer honours for the parent half"), + /** + * [#21464] `sections` and `fields` are handed to the parent `object-form` + * verbatim (`plugin-form/src/MasterDetailForm.tsx:1692-1693` at the pin + * `89cad75d55`), so each is read exactly as that block's member is — and is + * HELD with it, in the enumeration pin's ledger, for the same reason (see + * {@link ObjectFormPropsSchema}'s `sections` and `fields`). + */ sections: z.array(z.unknown()).optional().describe('Parent form sections'), fields: z.array(z.unknown()).optional().describe('Parent fields shown'), details: z.array(masterDetailDetailEntry()).optional()