diff --git a/.changeset/27153-form-view-buttons-reset-retired.md b/.changeset/27153-form-view-buttons-reset-retired.md new file mode 100644 index 00000000000..31914fb13cf --- /dev/null +++ b/.changeset/27153-form-view-buttons-reset-retired.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@object-ui/plugin-form': minor +'@object-ui/types': minor +--- + +feat(spec)!: a form view's `buttons` block holds `submit` and `cancel` only; `buttons.reset` is retired, because no form ever drew a reset button + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed (`FormViewSchema.buttons.reset` is a tombstone) + + + +**BREAKING** accept-set narrowing on the form view surface (`@objectstack/spec`), ADR-0049 enforce-or-remove. + +The console draws a form's Submit and Cancel buttons on every entry point (the form page, the New and Edit dialogs, the record form page, master-detail forms). None of them drew a reset button, and `buttons.reset.label` was never read. The one renderer fold that read `buttons.reset.show` passed it to ObjectForm's clear-after-save flag, so `show: true` emptied the form after a successful save instead of adding a button. + +- **FROM → TO.** `buttons: { submit, cancel, reset }` → `buttons: { submit, cancel }`. No button replaces it: Cancel leaves the form without saving. `reset.show: true` used to clear the form after each save on ObjectView and the record form page; a converted form no longer clears after a save. A form that should clear after a save declares `submitBehavior: { kind: 'continue' }`, which ObjectForm and the console form page honour. +- **The one-line fix.** Delete `buttons.reset`. `os migrate meta --from 17` lists the edit for existing sources, and `--write` applies it. A stored `sys_metadata` row is converted when it is read (the `form-view-buttons-reset-removed` conversion), so a form view saved with the key keeps loading. +- **What the parse says.** "`view.form.buttons.reset` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form ever drew a reset button, and its label was never read. …", at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. TypeScript rejects the key at the authoring site. +- **Unchanged.** `buttons.submit` and `buttons.cancel` parse and render as before. + +The renderer (`@object-ui/plugin-form`, `@object-ui/types`): `ObjectForm` no longer folds `buttons.reset.show` onto `showReset`, and the `ObjectFormSchema.buttons` mirror (type and zod) drops `reset`. The flat `showReset` prop itself is unchanged. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a0dd03200ef..68c397e19b2 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1918,7 +1918,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `GetUiViewResponse.listViews[string]` @@ -1997,7 +1997,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `GetUiViewResponse.protection` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index d1b4ef1a9c9..0e9c95c95fb 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -408,7 +408,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `FormView.data[provider='object']` @@ -504,7 +504,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **submit** | `{ show?: boolean; label?: string \| Record }` | optional | Submit button | | **cancel** | `{ show?: boolean; label?: string \| Record }` | optional | Cancel button | -| **reset** | `{ show?: boolean; label?: string \| Record }` | optional | Reset button | +| **reset** | `never` | optional | [REMOVED] `view.form.buttons.reset` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form ever drew a reset button, and its label was never read. Delete the key: a form has `submit` and `cancel` buttons only, and Cancel leaves the form without saving. `reset.show: true` used to clear the form after each save (the ObjectForm fold that ObjectView and the record form page render); a form converted by this release no longer clears after a save, and a form that should declares `submitBehavior: { kind: 'continue' }`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -1788,7 +1788,7 @@ Tab configuration for multi-tab view interface | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `View.listViews[string]` @@ -1867,7 +1867,7 @@ Tab configuration for multi-tab view interface | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `View.protection` @@ -2104,7 +2104,7 @@ This schema accepts one of the following structures: | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `ViewItem[viewKind='form'].protection` @@ -2288,7 +2288,7 @@ This schema accepts one of the following structures: | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `ViewItemWire[viewKind='form'].protection` diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 28fdc358a4a..58468cce32f 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -370,7 +370,7 @@ }, "buttons": { "status": "live", - "note": "objectui: ObjectForm.tsx foldFormButtons() reads schema.buttons and folds submit/cancel/reset {show,label} onto the flat showSubmit/submitText/showCancel/cancelText/showReset props (framework#1894 / #2998); ObjectView + RecordFormPage forward it. Flat keys kept as deprecated back-compat (explicit flat key wins)." + "note": "objectui: ObjectForm.tsx foldFormButtons() reads schema.buttons and folds submit/cancel {show,label} onto the flat showSubmit/submitText/showCancel/cancelText props (framework#1894 / #2998); ObjectView forwards it, and every console form entry point resolves submit/cancel through resolveFormViewChrome / readFormButton (#26843). Flat keys kept as deprecated back-compat (explicit flat key wins). SUB-KEY buttons.reset REMOVED 2026-10-28 (#27153, ADR-0049 enforce-or-remove, ruled retire): measured at origin/main fbe344b3, six button bars (components form.tsx, console FormPage, ModalForm, DrawerForm, WizardForm, MasterDetailForm) drew submit/cancel and none drew reset; reset.label had no reader; the one fold of reset.show landed on ObjectForm showReset, which means clear-after-save (resetOnSubmit), not a button. Tombstoned inside the buttons block (retiredKey carries the prescription) and stripped from stored rows and sources by the protocol-18 form-view-buttons-reset-removed conversion; the fold was removed in the same change. Sub-keys cannot be drilled (one level only), so the removal is recorded here; submit and cancel keep this row live." }, "defaults": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3261c0c4618..06b7d886350 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8193,6 +8193,115 @@ const permissionAllowRestorePurgeRemoved: MetadataConversion = { }, }; +/** + * `buttons.reset` leaves the form view (protocol 18, #27153 — ADR-0049 + * enforce-or-remove, ruled retire on the card): six console form button bars + * draw `submit` and `cancel`, none drew a reset button, and `reset.label` had + * no reader. The one fold that read `reset.show` mapped it onto ObjectForm's + * clear-after-save flag, so the key never did what it said. + * + * Authoring refuses the key by its `retiredKey()` tombstone inside the + * `buttons` block (`ui/view.zod.ts`); `submit` and `cancel` beside it are + * untouched. Deleting is the whole edit. A `buttons` block left empty stays an + * empty block, which parses and draws the default buttons. + * + * **Reach: every FORM payload**, as + * {@link formViewPresentationTypesRemoved}: {@link mapViewPayloads} + * (`form`, `formViews.*`, a form view item's `config`, a flattened form + * overlay) and the assembled-manifest `viewItems` channel + * ({@link ASSEMBLED_VIEW_ITEMS_KEY}). A list payload is never judged. + * + * The block accepted `reset` through 18.2.0 and `PUT /meta/view` persists form + * views, so a stored `view` row CAN carry it. **Retired from the load path from + * day one**: a live author meets the parse's prescription, and this entry + * serves stored-row rehydration, assembled artifacts and `os migrate meta`. + * Idempotent by construction (`stripKeys`). + * + * Behaviour after the edit: `reset.show: true` used to clear the form after + * each save (the ObjectForm fold that ObjectView and the record form page + * render). A converted row no longer clears after a save. This entry does NOT + * map the key onto `submitBehavior`, because whether the author wanted the + * clear is not decidable from the row; the D3 entry + * `ui-form-view-buttons-reset-retired` carries that, and names + * `submitBehavior: { kind: 'continue' }` for an author who did. + */ +const formViewButtonsResetRemoved: MetadataConversion = { + id: 'form-view-buttons-reset-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'view.form.buttons.reset / view.formViews..buttons.reset', + summary: + 'form view `buttons.reset` removed (no form drew a reset button and its label had no reader; a form has ' + + '`submit` and `cancel` only). The key is deleted; `submit` and `cancel` are unchanged. A form whose ' + + '`reset.show: true` cleared it after a save no longer clears; `submitBehavior: { kind: \'continue\' }` ' + + 'declares that', + apply(stack, emit) { + const convertForm = (form: Dict, path: string): Dict => { + const buttons = form.buttons; + if (!isDict(buttons)) return form; + const stripped = stripKeys(buttons, ['reset'], emit, `${path}.buttons`); + return stripped === buttons ? form : { ...form, buttons: stripped }; + }; + const withViews = mapViewPayloads(stack, (payload, kind, path) => + kind === 'form' ? convertForm(payload, path) : payload); + return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => { + if (item.viewKind !== 'form') return item; + if (isDict(item.config)) { + const config = convertForm(item.config, `${path}.config`); + return config === item.config ? item : { ...item, config }; + } + // A flattened form overlay — the body IS the payload. A present but + // malformed `config` is neither shape and is left for the parse. + return item.config === undefined ? convertForm(item, path) : item; + }); + }, + fixture: { + before: { + views: [ + { + object: 'fvr_ticket', + // `submit` and `cancel` ride through; only `reset` goes. + form: { buttons: { submit: { label: 'Send' }, cancel: { show: false }, reset: { show: true, label: 'Clear' } } }, + formViews: { + // A block holding only `reset` is left as an empty block. + quick: { buttons: { reset: { show: false } } }, + // A form without `reset` rides through by reference. + plain: { buttons: { submit: { show: true } } }, + }, + }, + // A form view item record: the payload is `config`. + { name: 'fvr_ticket.peek', object: 'fvr_ticket', viewKind: 'form', config: { buttons: { reset: { label: 'Undo' } } } }, + // A list payload is never judged. + { name: 'fvr_ticket.all', object: 'fvr_ticket', viewKind: 'list', config: { type: 'grid', columns: ['subject'] } }, + ], + viewItems: [ + // An assembled flattened form overlay — the body IS the payload. + { name: 'fvr_ticket.side', object: 'fvr_ticket', viewKind: 'form', buttons: { reset: { show: true } } }, + ], + }, + after: { + views: [ + { + object: 'fvr_ticket', + form: { buttons: { submit: { label: 'Send' }, cancel: { show: false } } }, + formViews: { + quick: { buttons: {} }, + plain: { buttons: { submit: { show: true } } }, + }, + }, + { name: 'fvr_ticket.peek', object: 'fvr_ticket', viewKind: 'form', config: { buttons: {} } }, + { name: 'fvr_ticket.all', object: 'fvr_ticket', viewKind: 'list', config: { type: 'grid', columns: ['subject'] } }, + ], + viewItems: [ + { name: 'fvr_ticket.side', object: 'fvr_ticket', viewKind: 'form', buttons: {} }, + ], + }, + // `form`, `quick`, the item record and the assembled overlay: one each. + expectedNotices: 4, + }, +}; + /** * [commit c459da6bc] The per-option `default` key leaves the FORM-VIEW options * vocabulary (protocol 18; maintainer-ruled narrowing 2026-08-28 on the @@ -14806,6 +14915,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: flowDecisionModeInclusiveExplicit, order: 45 }, { conversion: formFieldPublicPickerRemoved, order: 53 }, { conversion: formLayoutInlineGridToVertical, order: 40 }, + { conversion: formViewButtonsResetRemoved, order: 79 }, { conversion: formViewOptionDefaultRemoved, order: 17 }, { conversion: formViewPresentationTypesRemoved, order: 78 }, { conversion: formViewSubformColumnsCanonicalized, order: 51 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__buttons.reset.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__buttons.reset.ts new file mode 100644 index 00000000000..97f8cd2fbd6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__buttons.reset.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #27153 — ADR-0049 enforce-or-remove on the form view's `buttons` block: six +// console form button bars draw submit and cancel and none drew a reset button; +// `reset.label` had no reader. Tombstoned with `retiredKey()` inside the live +// `buttons` block, so `submit` and `cancel` keep parsing. D2: +// `form-view-buttons-reset-removed`; D3: `ui-form-view-buttons-reset-retired`. +// Nested key of an inline block — no `authorable-surface/` line of its own. +export const entry = 'ui/FormView:buttons.reset'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-buttons-reset-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-buttons-reset-retired.ts new file mode 100644 index 00000000000..c24a5c60a47 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-buttons-reset-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #27153 (ADR-0049 enforce-or-remove, ruled retire on the card) — the D3 entry +// of the `form-view-buttons-reset-removed` family. The key is registered in +// RETIRED_KEYS_BY_MAJOR as `ui/FormView:buttons.reset`. +export const entry: SemanticMigration = { + id: 'ui-form-view-buttons-reset-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'view.form.buttons.reset and view.formViews..buttons.reset — removed from the form view ' + + 'buttons block (submit and cancel are unchanged)', + replacement: + 'nothing to draw instead: a form has `submit` and `cancel` buttons only, and Cancel leaves the form ' + + 'without saving. Delete the key. `reset.show: true` used to clear the form after each save on ' + + 'ObjectView and the record form page; a converted form no longer does, and a form that should ' + + 'declares `submitBehavior: { kind: \'continue\' }`', + reason: + 'ADR-0049: no form ever drew a reset button and `reset.label` was never read, while the one renderer ' + + 'fold that read `reset.show` mapped it onto clearing the form after a successful save. The D2 ' + + 'conversion `form-view-buttons-reset-removed` makes each stored or existing source parse by ' + + 'deleting the key, so a converted form that cleared after a save stops clearing. What it cannot ' + + 'decide is whether that form wanted the clear: an author who did writes `submitBehavior: { kind: ' + + '\'continue\' }`, which ObjectForm and the console form page honour', + acceptanceCriteria: + 'No form view declares `buttons.reset`; it is refused at parse with its prescription, and TypeScript ' + + 'rejects it at a `FormView` position. `buttons.submit` and `buttons.cancel` still parse and still ' + + 'render on every console form entry point.', + conversionIds: ['form-view-buttons-reset-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index b45f7cb156f..a7fda425146 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2986,6 +2986,17 @@ 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-form-view-buttons-reset-retired', + order: 113, + text: + 'It also retires the form view\'s `buttons.reset` (ADR-0049): no form drew a reset button and ' + + 'its label was never read, so a form has `submit` and `cancel` only. The key is refused at ' + + 'parse with its prescription; the D2 conversion `form-view-buttons-reset-removed` strips it ' + + 'from existing sources and stored rows, so a form that `reset.show: true` cleared after a save ' + + 'stops clearing; an author who wants that declares `submitBehavior: { kind: \'continue\' }`. ' + + 'Its D3 record is the semantic entry `ui-form-view-buttons-reset-retired`.', + }, { id: 'ui-form-view-type-layout-only', order: 112, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 4406639c973..b948f409dd6 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -404,6 +404,10 @@ const JOIN: Readonly> = { 'recordAction', ), ], + // #27153: `buttons.reset`, tombstoned inside the live `buttons` block. + 'form-view-buttons-reset-removed': [ + tombstone((FormViewSchema.shape.buttons as unknown as { unwrap: () => unknown }).unwrap(), 'view.form.buttons', 'reset'), + ], // #27155: the form view `type` narrowed to the form's layout — each retired // member refused by the enum's own error map, plus the three split-only keys // tombstoned beside it. diff --git a/packages/spec/src/ui/form-view-buttons-reset-retirement.test.ts b/packages/spec/src/ui/form-view-buttons-reset-retirement.test.ts new file mode 100644 index 00000000000..486dcd23045 --- /dev/null +++ b/packages/spec/src/ui/form-view-buttons-reset-retirement.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FormView.buttons.reset` is retired (#27153, ADR-0049 enforce-or-remove, + * ruled retire on the card): six console form button bars draw `submit` and + * `cancel`, none drew a reset button, and `reset.label` had no reader. + * + * Bookkeeping shapes, pinned below: + * 1. `buttons.reset` is a `retiredKey()` tombstone inside the live `buttons` + * block, refused with its prescription at every form-view door (the + * schema, a container's `form` / `formViews.*`, a ViewItem form record, a + * flattened form overlay, and `defineStack`); `submit` and `cancel` beside + * it keep parsing. + * 2. One D2 conversion, retired from the load path: + * `form-view-buttons-reset-removed` strips the key from stored rows and + * existing sources; its D3 entry is `ui-form-view-buttons-reset-retired`. + * 3. Tree-scoped absence: no `buttons` block inside the declared radius + * still authors `reset`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (that envelope belongs to the + * doors), so each schema refusal is pinned by the `path` and the + * prescription's first sentence; the `defineStack` door adds the envelope's + * `code` + `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { FormViewSchema, VIEW_METADATA_MEMBERS, ViewItemSchema, ViewSchema } from './view.zod'; + +const CONVERSION_ID = 'form-view-buttons-reset-removed'; +const D3_ID = 'ui-form-view-buttons-reset-retired'; + +const FIRST_SENTENCE = + '`view.form.buttons.reset` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form ever drew a reset ' + + 'button, and its label was never read.'; +const MIGRATE_SENTENCE = 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources;'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; data?: unknown; error?: { issues: Issue[] } }; +type Door = { name: string; parse: (form: Record) => Parse; at: PropertyKey[] }; + +const SECTIONS = [{ label: 'Main', fields: ['name'] }]; + +/** Every door a form view payload is parsed through, with the path the payload sits at. */ +const DOORS: readonly Door[] = [ + { name: 'FormViewSchema', parse: (f) => FormViewSchema.safeParse(f), at: [] }, + { name: 'view.form', parse: (f) => ViewSchema.safeParse({ form: f }), at: ['form'] }, + { name: 'view.formViews.*', parse: (f) => ViewSchema.safeParse({ formViews: { quick: f } }), at: ['formViews', 'quick'] }, + { + name: 'ViewItem form record', + parse: (f) => ViewItemSchema.safeParse({ name: 'crm_lead.quick', object: 'crm_lead', viewKind: 'form', config: f }), + at: ['config'], + }, + { + name: 'flattened form overlay', + parse: (f) => VIEW_METADATA_MEMBERS.formOverlay.safeParse({ ...f, object: 'crm_lead', viewKind: 'form' }), + at: [], + }, +]; + +/** The issues a door reports under the payload — a wrapper may report others of its own. */ +const payloadIssues = (r: Parse, door: Door) => + (r.error?.issues ?? []).filter((i) => door.at.every((seg, k) => i.path[k] === seg)); + +describe('FormView.buttons.reset — a tombstone inside the live `buttons` block', () => { + it.each(DOORS.map((d) => [d.name, d] as const))('%s refuses `buttons.reset`, whatever it carries, with its prescription', (_, door) => { + for (const reset of [{ show: true }, { show: false }, { label: 'Clear' }, {}]) { + const r = door.parse({ sections: SECTIONS, buttons: { submit: { label: 'Save' }, reset } }); + expect(r.success, JSON.stringify(reset)).toBe(false); + const issues = payloadIssues(r, door); + expect(issues, JSON.stringify(issues)).toHaveLength(1); + expect(issues[0]!.path).toEqual([...door.at, 'buttons', 'reset']); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + } + }); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s still accepts `submit` and `cancel`', (_, door) => { + const r = door.parse({ sections: SECTIONS, buttons: { submit: { show: true, label: 'Save' }, cancel: { show: false } } }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('CONTROL: an unknown button keeps the strict block\'s own refusal — "was removed" would misinform', () => { + const r = FormViewSchema.safeParse({ sections: SECTIONS, buttons: { clear: { show: true } } }) as Parse; + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.code).toBe('unrecognized_keys'); + expect(r.error!.issues[0]!.message).not.toContain('was removed'); + }); + + it('defineStack refuses it with the ADR-0112 envelope and the prescription', () => { + let refusal: unknown; + try { + defineStack({ views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, buttons: { reset: { show: true } } } } }] } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['views.0.formViews.quick.buttons.reset']); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE)).toBe(true); + expect(() => defineStack({ + views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, buttons: { cancel: { label: 'Back' } } } } }], + } as never)).not.toThrow(); + }); + + it('is registered in RETIRED_KEYS_BY_MAJOR[18] under its exact def key', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/FormView:buttons.reset'); + }); + + it('fails tsc at the authoring site: the tombstone admits only absence', () => { + const form: z.input = { + sections: SECTIONS, + buttons: { + submit: { label: 'Save' }, + // @ts-expect-error — `buttons.reset` is a `retiredKey()` tombstone, typed `never`. + reset: { show: true }, + }, + }; + expect(FormViewSchema.safeParse(form).success).toBe(false); + }); +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); +const ours = (n: ConversionNotice) => n.conversionId === CONVERSION_ID; + +describe(`the D2 conversion ${CONVERSION_ID}`, () => { + it('a STORED view container replays to a body the strict door accepts', () => { + const row = { + object: 'crm_lead', + form: { sections: SECTIONS, buttons: { submit: { label: 'Send' } } }, + formViews: { + quick: { sections: SECTIONS, buttons: { cancel: { show: false }, reset: { show: true, label: 'Clear' } } }, + }, + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map(brief)).toEqual([ + { conversionId: CONVERSION_ID, path: 'views[0].formViews.quick.buttons.reset', from: 'reset', to: '(removed)' }, + ]); + const parsed = ViewSchema.parse(out); + expect(parsed.formViews?.quick?.buttons).toEqual({ cancel: { show: false } }); + // A form without `reset` is the author's and is never touched. + expect(out.form).toBe(row.form); + // Copy-on-write: the stored row is not mutated. + expect(row.formViews.quick.buttons).toHaveProperty('reset'); + }); + + it('a STORED form view item record converts its `config`', () => { + const row = { name: 'crm_lead.peek', object: 'crm_lead', viewKind: 'form', config: { sections: SECTIONS, buttons: { reset: {} } } }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => n.path)).toEqual(['views[0].config.buttons.reset']); + expect(out.config).toEqual({ sections: SECTIONS, buttons: {} }); + expect(ViewItemSchema.safeParse(out).success).toBe(true); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices( + { views: [{ object: 'o', form: { sections: SECTIONS, buttons: { reset: { show: false } } } }] }, + { includeRetired: true }, + ); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter(ours)).toEqual([]); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel rewrites no live source', () => { + const input = { views: [{ object: 'o', form: { sections: SECTIONS, buttons: { reset: { show: true } } } }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is wired into the step-18 chain as a retired entry stamped after 18.2.0', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toBe('18.2.0'); + }); + + it(`carries the D3 entry ${D3_ID}, naming its D2 conversion`, () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entries[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps the TYPED authoring sites; this walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), with the example +// apps' own `src/` trees. It judges a `buttons` OBJECT literal whose own +// top-level keys include `reset`. The bound, stated: a list view's `buttons` +// is an array and a settings page's `reset` action sits under `actions`, so +// neither is judged; a block assembled by spread or a computed key, YAML, and +// the console under `ui/` (outside this radius) are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors `buttons.reset`', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** Whether some `buttons: { … }` literal holds `reset` among its own top-level keys. */ + const judge = (raw: string): boolean => { + // Inline code spans are prose; newline-bounded, so a fenced example is still judged. + const text = raw.replace(/`[^`\n]*`/g, ''); + const opener = /["']?\bbuttons["']?\s*:\s*\{/g; + for (let m = opener.exec(text); m; m = opener.exec(text)) { + let depth = 0; + let top = ''; + for (let i = m.index + m[0].length - 1; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) break; + } else if (depth === 1) top += ch; + } + if (/(?:^|[\s,{])["']?reset["']?\s*:/.test(top)) return true; + } + return false; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The spec's own schema test asserts the refusal. + 'packages/spec/src/ui/view.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement form on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores its neighbours (anti-vacuity)', () => { + expect(judge("buttons: { submit: { label: 'Save' }, reset: { show: false } },")).toBe(true); + expect(judge('{ "buttons": {\n "reset": { "label": "Clear" }\n} }')).toBe(true); + expect(judge("Prose.\n\n```ts\nbuttons: { reset: {} }\n```\n")).toBe(true); + // Neighbours that must NOT match. + expect(judge("buttons: { submit: { label: 'Save' }, cancel: { show: false } },")).toBe(false); + expect(judge("buttons: ['approve', 'reset'],")).toBe(false); + expect(judge("actions: { reset: { label: 'Reset to environment defaults' } },")).toBe(false); + expect(judge("buttons: { submit: { label: 'Save', reset: 1 } }")).toBe(false); + expect(judge('a form once wrote `buttons: { reset: {} }`.')).toBe(false); + }); + + it('no `buttons` block authoring `reset` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (/["']?\bbuttons["']?\s*:\s*\{/.test(text)) mentioning += 1; + if (judge(text)) offenders.push(rel); + } + } + // Anti-vacuity: the walk covered the tree, and files that DO author a + // `buttons` block were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(0); + expect(offenders, 'a `buttons` block authoring `reset` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index e65df57096d..dfb0e7e73bc 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3219,11 +3219,21 @@ describe('FormViewSchema - buttons & defaults', () => { buttons: { submit: { show: true, label: 'Save' }, cancel: { show: true, label: 'Discard' }, - reset: { show: false }, }, }); expect(result.buttons?.submit).toEqual({ show: true, label: 'Save' }); - expect(result.buttons?.reset).toEqual({ show: false }); + expect(result.buttons?.cancel).toEqual({ show: true, label: 'Discard' }); + }); + + it('refuses `buttons.reset` — a retiredKey() tombstone (#27153): no form drew a reset button', () => { + const r = FormViewSchema.safeParse({ + type: 'simple', + sections: [{ fields: ['name'] }], + buttons: { submit: { show: true }, reset: { show: false } }, + }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toEqual(['buttons.reset']); + expect(r.error!.issues[0]!.message).toMatch(/buttons\.reset.*was removed.*Delete the key/s); }); it('should accept a partial buttons block (each button optional)', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0c2f6a49a0f..6e405453c35 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4115,7 +4115,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ }))); /** - * A single form action button (submit / cancel / reset): visibility + label. + * A single form action button (submit / cancel): visibility + label. * Leaf of the {@link FormViewSchema} `buttons` block (#2998). * `.strict()` per ADR-0089 D3a so a typo'd key errors instead of vanishing. */ @@ -4545,6 +4545,22 @@ const FORM_VIEW_SPLIT_RESIZABLE_RETIRED = + 'view draws panes. Delete the key. ' + FORM_VIEW_TYPE_RETIRED_FIX; +// [#27153] `buttons.reset` (ADR-0049 enforce-or-remove, seat ruling on the +// card): six form button bars drew submit and cancel and none drew reset, and +// `reset.label` had no reader. The one fold that read `reset.show` mapped it +// onto ObjectForm's clear-after-save flag, so `show: true` emptied the form +// after a save instead of drawing a button. The +// `form-view-buttons-reset-removed` conversion (ADR-0087 D2) strips the key, so +// a converted form stops clearing after a save; the prescription names +// `submitBehavior: { kind: 'continue' }`, the declared spelling of that effect. +const FORM_VIEW_BUTTONS_RESET_RETIRED = + '`view.form.buttons.reset` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form ever drew a reset ' + + 'button, and its label was never read. Delete the key: a form has `submit` and `cancel` buttons only, and ' + + 'Cancel leaves the form without saving. `reset.show: true` used to clear the form after each save (the ' + + 'ObjectForm fold that ObjectView and the record form page render); a form converted by this release no ' + + 'longer clears after a save, and a form that should declares `submitBehavior: { kind: \'continue\' }`. ' + + FORM_VIEW_TYPE_RETIRED_FIX; + // Form-view `layout` retired-value prescriptions (#20221, ADR-0049 // enforce-or-remove). Declared with `//` on purpose — the // `LIST_VIEW_EXPORT_PDF_RETIRED` placement note applies here too. An @@ -4880,17 +4896,21 @@ export const FormViewSchema = lazySchema(() => strictObject({ /** * Structured action-button config: per-button visibility + label for - * `submit` / `cancel` / `reset`. The spec home for the flat renderer-invented - * keys ObjectUI's `ObjectForm` used to read (`showSubmit`/`submitText`/ - * `showCancel`/`cancelText`/`showReset`) — now consumed: `ObjectForm` folds - * `buttons.*` down onto those flat props at render (framework#1894, #2998), - * with the flat keys kept only as deprecated back-compat. Authoring `buttons` - * takes effect; a stale flat key still wins where explicitly set. + * `submit` / `cancel`. The spec home for the flat renderer-invented keys + * ObjectUI's `ObjectForm` used to read (`showSubmit`/`submitText`/ + * `showCancel`/`cancelText`) — now consumed: `ObjectForm` folds `buttons.*` + * down onto those flat props at render (framework#1894, #2998), and every + * console form entry point draws them (#26843). Authoring `buttons` takes + * effect; a stale flat key still wins where explicitly set. + * + * [#27153] `reset` is a `retiredKey()` tombstone (ADR-0049 enforce-or-remove): + * no form drew a reset button and its `label` had no reader. */ buttons: z.object({ submit: FormButtonConfigSchema.optional().describe('Submit button'), cancel: FormButtonConfigSchema.optional().describe('Cancel button'), - reset: FormButtonConfigSchema.optional().describe('Reset button'), + /** [#27153] RETIRED — {@link FORM_VIEW_BUTTONS_RESET_RETIRED}. */ + reset: retiredKey(FORM_VIEW_BUTTONS_RESET_RETIRED), }).strict().optional() .describe('Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm.'), diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 2fb9c1a02f0..a91cfdd58e5 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -70,6 +70,7 @@ "src/ui/element-flat-binding-retirement.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", + "src/ui/form-view-buttons-reset-retirement.test.ts", "src/ui/form-view-type-layout-retirement.test.ts", "src/ui/master-detail-detail-sort-field-retirement.test.ts", "src/ui/navigation-mode-retirement.test.ts", diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 1bf663260e0..ffa956c8fa9 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -1428,8 +1428,8 @@ export function readPrefill( * One `FormView.buttons` entry as this page draws it (objectstack-ai/objectstack#26843): * shown unless the author wrote `show: false`, and the authored label — an * `I18nLabel`, resolved against the visitor's language — or `''` when there is - * none, so the caller's own label shows. `buttons.reset` is never read: this - * page draws no reset button. + * none, so the caller's own label shows. A form has no reset button: the spec + * refuses `buttons.reset` (objectstack-ai/objectstack#27153). */ export function readFormButton( config: { show?: boolean; label?: unknown } | undefined, diff --git a/ui/packages/app-shell/src/utils/recordFormNavigation.ts b/ui/packages/app-shell/src/utils/recordFormNavigation.ts index 03039a8619f..2e621bcd461 100644 --- a/ui/packages/app-shell/src/utils/recordFormNavigation.ts +++ b/ui/packages/app-shell/src/utils/recordFormNavigation.ts @@ -402,8 +402,8 @@ function createSeed( * * - `buttons.submit` / `buttons.cancel`: an authored `show` over the host's * `true`; an authored `label`, an `I18nLabel` resolved against `language`, - * over the host's label. `buttons.reset` is not read: no host draws a - * reset button. + * over the host's label. A form has no reset button: the spec refuses + * `buttons.reset` (objectstack-ai/objectstack#27153). * - `layout`: the authored value when the spec's enum allows it, else * `'vertical'`, the renderer default. * - `defaults`, on a create only: the opening values under the ones the host diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index 275fce164d7..e8a26d773f8 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -115,8 +115,7 @@ export interface ObjectFormComponentProps { * Fold the structured, spec-aligned `buttons`/`defaults` surface * (`@objectstack/spec` FormViewSchema; framework#1894 / objectstack-ai/objectstack#2998) down onto the * flat renderer props ObjectForm and its variants read - * (`showSubmit`/`submitText`/`showCancel`/`cancelText`/`showReset`/ - * `initialValues`). This is the objectui-side consumer of those spec keys — it + * (`showSubmit`/`submitText`/`showCancel`/`cancelText`/`initialValues`). This is the objectui-side consumer of those spec keys — it * is what lets them flip `experimental → live` in the spec liveness ledger. * * Spec is the authored surface, but an explicitly-set flat key still wins so @@ -134,7 +133,6 @@ function foldFormButtons(schema: ObjectFormComponentProps['schema']): ObjectForm if (buttons?.submit?.label != null && s.submitText === undefined) out.submitText = buttons.submit.label; if (buttons?.cancel?.show !== undefined && s.showCancel === undefined) out.showCancel = buttons.cancel.show; if (buttons?.cancel?.label != null && s.cancelText === undefined) out.cancelText = buttons.cancel.label; - if (buttons?.reset?.show !== undefined && s.showReset === undefined) out.showReset = buttons.reset.show; if (defaults && s.initialValues === undefined) out.initialValues = defaults; return out as ObjectFormComponentProps['schema']; } @@ -278,8 +276,8 @@ const ObjectFormBody: React.FC = ({ // Fail-open when no provider mounted (perms.isLoaded false). const schema = useMemo(() => { // framework#1894 / objectstack-ai/objectstack#2998 (ADR-0078): the authored @objectstack/spec - // FormViewSchema carries the structured `buttons.{submit,cancel,reset}. - // {show,label}` + `defaults` surface, but this renderer historically read + // FormViewSchema carries the structured `buttons.{submit,cancel}. + // {show,label}` + `defaults` surface (`buttons.reset` retired, #27153), but this renderer historically read // only the flat `showSubmit`/`submitText`/…/`initialValues`. Fold the // structured shape down onto those flat props FIRST so every downstream // read (and every variant we dispatch `schema` into) sees it. An diff --git a/ui/packages/plugin-form/src/__tests__/formButtonsDefaults.test.tsx b/ui/packages/plugin-form/src/__tests__/formButtonsDefaults.test.tsx index 842fa93d4cc..b63018ad855 100644 --- a/ui/packages/plugin-form/src/__tests__/formButtonsDefaults.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/formButtonsDefaults.test.tsx @@ -2,7 +2,8 @@ * ObjectForm structured `buttons` / `defaults` fold (framework#1894 / #2998). * * `@objectstack/spec` FormViewSchema declares the structured - * `buttons.{submit,cancel,reset}.{show,label}` + `defaults` authoring surface, + * `buttons.{submit,cancel}.{show,label}` + `defaults` authoring surface + * (`buttons.reset` is retired, objectstack-ai/objectstack#27153), * but ObjectForm reads flat `showSubmit`/`submitText`/…/`initialValues`. These * tests lock in the fold that reconciles the two — the objectui-side consumer * that lets the two spec keys flip `experimental → live` in the liveness ledger diff --git a/ui/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts b/ui/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts index 17712667063..30abe5f176f 100644 --- a/ui/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts +++ b/ui/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts @@ -109,7 +109,7 @@ const ROWS: ReadonlyArray<{ key: string; valid: unknown; wrong: unknown }> = [ { key: 'modalSize', valid: 'lg', wrong: 'huge' }, { key: 'modalCloseButton', valid: false, wrong: 'no' }, { key: 'mobile', valid: { stickyActions: true, stepper: 'auto', stepperMinFields: 6 }, wrong: { stepper: 'sometimes' } }, - { key: 'buttons', valid: { submit: { show: true, label: 'Save' }, reset: { show: false } }, wrong: { submit: { show: 'yes' } } }, + { key: 'buttons', valid: { submit: { show: true, label: 'Save' }, cancel: { show: false } }, wrong: { submit: { show: 'yes' } } }, { key: 'defaults', valid: { status: 'open', priority: 2 }, wrong: ['status'] }, { key: 'subforms', valid: [{ childObject: 'order_line', minRows: 1 }], wrong: [{ relationshipField: 'order' }] }, ]; diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 256ad8f64ce..f583b33307d 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -2087,16 +2087,15 @@ export interface ObjectFormSchema extends BaseSchema { /** * Structured, spec-aligned form action-button config — the authoring surface - * for submit/cancel/reset visibility + labels, mirroring `@objectstack/spec` + * for submit/cancel visibility + labels, mirroring `@objectstack/spec` * `FormViewSchema.buttons` (framework#1894 / objectstack-ai/objectstack#2998). ObjectForm normalizes - * this down onto the flat `showSubmit`/`submitText`/`showCancel`/`cancelText`/ - * `showReset` props at render, so prefer this over those flat keys (which + * this down onto the flat `showSubmit`/`submitText`/`showCancel`/`cancelText` + * props at render, so prefer this over those flat keys (which * remain only as deprecated back-compat). An explicitly-set flat key wins. */ buttons?: { submit?: { show?: boolean; label?: string }; cancel?: { show?: boolean; label?: string }; - reset?: { show?: boolean; label?: string }; }; /** diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 97a0b0504b9..b7af49d8f42 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -1051,8 +1051,7 @@ export const ObjectFormSchema = BaseSchema.extend({ buttons: z.looseObject({ submit: ObjectFormButtonToggleSchema.optional(), cancel: ObjectFormButtonToggleSchema.optional(), - reset: ObjectFormButtonToggleSchema.optional(), - }).optional().describe('Structured submit/cancel/reset visibility and labels; folded onto the flat keys, which win when set'), + }).optional().describe('Structured submit/cancel visibility and labels; folded onto the flat keys, which win when set'), defaults: z.record(z.string(), z.any()).optional() .describe('Create-mode initial values keyed by field name; folded into initialValues, which wins when set'), subforms: z.array(z.looseObject({