diff --git a/.changeset/27155-form-view-type-layout-only.md b/.changeset/27155-form-view-type-layout-only.md index e7e508f7734..34aebfcfa6d 100644 --- a/.changeset/27155-form-view-type-layout-only.md +++ b/.changeset/27155-form-view-type-layout-only.md @@ -18,6 +18,6 @@ The model: `type` says how the form lays out its sections. Where a form opens is - **FROM → TO.** `type: 'split'` → `type: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the form's `splitSize` / `splitResizable`; declare `subforms` to edit child records beside the parent. `type: 'drawer'` or `type: 'modal'` → delete the value for `simple`, or write the layout you want. A list opens a clicked record as its `navigation.mode` says (the drawer by default), and the console's New and Edit entries open the record form themselves, sized by the form's `modalSize`. - **The one-line fix.** Delete the retired value and the three split-only keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-presentation-types-removed` conversion), so a form view saved with one of the three values keeps loading as a `simple` form. - **What the parse says.** Each retired value is refused by name with its prescription, for example "'modal' was removed from the form view `type` enum in @objectstack/spec 18.3.0 (ADR-0050) — …", 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. `pane`, `splitSize` and `splitResizable` are refused on every layout. TypeScript rejects each at the authoring site. A misspelt value keeps zod's own message. -- **Unchanged.** `splitDirection`, `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes the other three from this schema until its renderer follows. +- **Unchanged.** `modalSize` stays declared: the console reads it for its record dialog. `splitDirection` (#27159) and `drawerSide` / `drawerWidth` (#29012) retire from the form view in the same release; the React `ObjectForm` block keeps the drawer pair as its own props. The console (`@object-ui/app-shell`): Studio's New View form offers the three form layouts only. It offered all six, and a form view created with `split`, `drawer` or `modal` would now be refused when saved. diff --git a/.changeset/29012-form-view-drawer-keys-retired.md b/.changeset/29012-form-view-drawer-keys-retired.md new file mode 100644 index 00000000000..0e036d2542f --- /dev/null +++ b/.changeset/29012-form-view-drawer-keys-retired.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/skills': patch +--- + +feat(spec)!: a form view no longer declares `drawerSide` / `drawerWidth`; no form view opens in a drawer, so neither key was ever read there + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed + +Skills-docs: regenerated here — `skills/objectstack-ui/references/react-blocks.md` (`gen:react-blocks`) lists ``'s `drawerSide` / `drawerWidth` as binding props from the block's overlay instead of data props from the form view schema, with the same types; `drawerWidth`'s description drops the form view's `[DEPRECATED → size buckets]` tag (the block's drawer has no size bucket) and says the value is the drawer's width, or its height from the top or bottom edge; no hand-written file under `skills/**` teaches either key on a form view (a `git grep` of `skills/` names only that contract and `rules/record-page.md`'s list-drawer width on `navigation`, which this does not touch). + + + +**BREAKING** accept-set narrowing on the form view surface (`@objectstack/spec`), ADR-0049 enforce-or-remove. + +`type: 'drawer'` left the form view earlier in this release, so no form view opens in a drawer. No form-view surface read the two keys either: the console's form page, its New and Edit dialogs, the record form page and Studio all ignored them, so a form view saved with `drawerSide: 'left', drawerWidth: '480px'` was accepted, served and drawn exactly as one without them. + +- **FROM → TO.** `form: { …, drawerSide, drawerWidth }` (and `formViews.`, a form view item's `config`) → `form: { … }`. Nothing on the form view replaces them. A list opens a clicked record in its drawer (`navigation.mode`) and sizes it with the `navigation.size` bucket; a React page that opens a form in a drawer writes the props on the block, `ObjectForm` with `formType='drawer'`. +- **The one-line fix.** Delete `drawerSide` and `drawerWidth` from the form view. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-drawer-keys-removed` conversion), so a form view saved with the keys keeps loading, and nothing a reader sees changes. +- **What the parse says.** "`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. …" (and the same for `drawerWidth`), 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 keys at the authoring site. +- **Unchanged.** The React `ObjectForm` block still takes `drawerSide` / `drawerWidth` for the drawer it opens with `formType='drawer'`: the react-tier contract now declares them on the block itself, with the same types and descriptions. The `object-form` page block keeps both in its own props. `modalSize` stays on the form view; the console's record dialog reads it. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index feda4f59e89..2d1c10ea9de 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1916,8 +1916,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1995,8 +1995,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 9ae054c37e6..f5e5689d813 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -400,8 +400,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1809,8 +1809,8 @@ Tab configuration for multi-tab view interface | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1888,8 +1888,8 @@ Tab configuration for multi-tab view interface | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -2227,8 +2227,8 @@ This schema accepts one of the following structures: | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -2427,8 +2427,8 @@ This schema accepts one of the following structures: | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. 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. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. 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. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. 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. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. 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. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index be773fe3492..03f58c6218a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -574,8 +574,8 @@ "ui/FormView:defaultTab", "ui/FormView:defaults", "ui/FormView:description", - "ui/FormView:drawerSide", - "ui/FormView:drawerWidth", + "ui/FormView:drawerSide [RETIRED]", + "ui/FormView:drawerWidth [RETIRED]", "ui/FormView:groups", "ui/FormView:layout", "ui/FormView:modalSize", diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index 260f861304e..a040c094b70 100644 --- a/packages/spec/liveness/state-counts/view.md +++ b/packages/spec/liveness/state-counts/view.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `view` | 75 | 0 | 0 | 8 | 0 | 83 | +| `view` | 73 | 0 | 0 | 10 | 0 | 83 | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index caceb08cfe7..824b9507c0d 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -320,16 +320,16 @@ "note": "REMOVED 2026-10-28 (#27155, ADR-0050 Accepted by ruling 6464210455 on #27032, letter A′) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape: FormViewSchema, the container's form / formViews, both ViewItem form arms and the flattened form overlay) and stripped from stored rows and sources by the protocol-18 form-view-presentation-types-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It made the split form's panes resizable. Its only reader was ui/packages/plugin-form/src/SplitForm.tsx, reached through ObjectForm's `split` arm and RecordFormPage's split hand-off, both behind `type: 'split'`, which left the enum in the same change; nothing to write instead — a `tabbed` form gives each section its own tab." }, "drawerSide": { - "status": "live", - "verifiedAt": "2026-10-28", + "status": "dead", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "note": "objectui: DrawerForm.tsx:185. INERT ON A FORM VIEW since 2026-10-28 (#27155): its form-view reader sat behind `type: 'drawer'`, which left the enum. The row stays live because the React `ObjectForm` block takes its contract from FormViewSchema (packages/spec/src/ui/react-blocks.ts dataProps), and that tier's `formType='drawer'` still reads it: since #27159 the block renderer is the caller that opens the form in a drawer (ui/packages/plugin-form/src/ObjectFormContainer.tsx hands it to DrawerForm.tsx), so the key keeps a live reader after ObjectForm dropped its drawer arm. Its enforce-or-remove question for form views stays on #27153." + "note": "REMOVED 2026-11-02 (#29012, ADR-0049 enforce-or-remove, triage ruling 6571544001: retire) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape: FormViewSchema, the container's form / formViews, both ViewItem form arms and the flattened form overlay) and stripped from stored rows and sources by the protocol-18 form-view-drawer-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Its form-view reader sat behind `type: 'drawer'`, which left the enum on #27155; measured at origin/main 104cd1a5, no metadata form-view surface reads it (ui/packages/plugin-form/src/ObjectFormContainer.tsx is the only caller that hands it to DrawerForm.tsx, and it serves the `object-form` block's `formType='drawer'`, which a form view's `type` cannot reach). The React `ObjectForm` block keeps the prop in its own overlay (packages/spec/src/ui/react-blocks.ts), and the `object-form` page block declares it in ObjectFormPropsSchema; nothing to write on a form view instead — a list opens a clicked record in its drawer (`navigation.mode`)." }, "drawerWidth": { - "status": "live", - "verifiedAt": "2026-10-28", + "status": "dead", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "note": "objectui: DrawerForm.tsx:377-382 still reads it. DEPRECATED in spec (#2578 → size buckets; a pixel width can't be authored blind) — live-but-deprecated, remove the read when the deprecation completes. INERT ON A FORM VIEW since 2026-10-28 (#27155): its form-view reader sat behind `type: 'drawer'`, which left the enum; it stays for the React `ObjectForm` block contract, as drawerSide does: the block's drawer container (ObjectFormContainer.tsx → DrawerForm.tsx, #27159) still reads it (#27153)." + "note": "REMOVED 2026-11-02 (#29012, ADR-0049 enforce-or-remove, triage ruling 6571544001: retire) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape) and stripped from stored rows and sources by the protocol-18 form-view-drawer-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It was already DEPRECATED (#2578 → size buckets) with no size-bucket conversion of its own, and inert on a form view since `type: 'drawer'` left the enum on #27155: the only reader, DrawerForm.tsx, is reached only through the `object-form` block's `formType='drawer'` (ObjectFormContainer.tsx). The React `ObjectForm` block keeps the prop in its own overlay (packages/spec/src/ui/react-blocks.ts); nothing to write on a form view instead — a list's record drawer takes its starting width from `navigation.size`." }, "modalSize": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 36f5e353794..46dc0b14d60 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8491,6 +8491,133 @@ const formViewButtonsResetRemoved: MetadataConversion = { }, }; +/** + * `drawerSide` and `drawerWidth` leave the form view (protocol 18, #29012 — + * ADR-0049 enforce-or-remove, triage ruling: retire). `type: 'drawer'` left the + * form view in #27155 ({@link formViewPresentationTypesRemoved}), so no form + * view opens in a drawer, and no metadata form-view surface reads either key: + * a stored `drawerSide: 'left'` saved, served and changed nothing. + * + * Authoring refuses both keys by their `retiredKey()` tombstones + * (`ui/view.zod.ts`). Deleting is the whole edit, and it changes nothing a + * reader sees. The React `ObjectForm` block keeps its own `drawerSide` / + * `drawerWidth` props (`react-blocks.ts` overlay) and the `object-form` PAGE + * BLOCK keeps them in `ObjectFormPropsSchema`: its `formType: 'drawer'` + * container reads them, so this entry never walks page components. + * + * **Reach: every FORM payload**, as {@link formViewButtonsResetRemoved}: + * {@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 schema accepted both keys through 18.2.0 and `PUT /meta/view` persists + * form views, so a stored `view` row CAN carry them. **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`). + * + * Its D3 entry is `ui-form-view-drawer-keys-retired`. + */ +const formViewDrawerKeysRemoved: MetadataConversion = { + id: 'form-view-drawer-keys-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'view.form.drawerSide / view.form.drawerWidth / view.formViews..drawerSide / view.formViews..drawerWidth', + summary: + 'form view `drawerSide` and `drawerWidth` removed (no form view opens in a drawer since `type: \'drawer\'` ' + + 'retired, and no form-view surface read either key). Both keys are deleted, which changes nothing a reader ' + + 'sees; the React `ObjectForm` block and the `object-form` page block keep their own `drawerSide` / ' + + '`drawerWidth` for the drawer they open', + apply(stack, emit) { + const convertForm = (form: Dict, path: string): Dict => stripKeys(form, ['drawerSide', 'drawerWidth'], emit, path); + 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: { + pages: [ + { + name: 'fvd_task_desk', + regions: [ + { + name: 'main', + components: [ + // The `object-form` page block keeps both keys: its drawer reads them. + { type: 'object-form', properties: { objectName: 'fvd_task', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' } }, + ], + }, + ], + }, + ], + views: [ + { + object: 'fvd_task', + // Both keys go; the rest of the form rides through. + form: { columns: 2, drawerSide: 'left', drawerWidth: '480px', sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + // `modalSize` is live (the console's record dialog reads it) and stays. + quick: { modalSize: 'lg', drawerWidth: '50%' }, + // A form without the keys rides through by reference. + plain: { columns: 1 }, + }, + }, + // A form view item record: the payload is `config`. + { name: 'fvd_task.peek', object: 'fvd_task', viewKind: 'form', config: { drawerSide: 'right' } }, + // A list payload is never judged. + { name: 'fvd_task.all', object: 'fvd_task', viewKind: 'list', config: { type: 'grid', columns: ['title'] } }, + ], + viewItems: [ + // An assembled flattened form overlay — the body IS the payload. + { name: 'fvd_task.side', object: 'fvd_task', viewKind: 'form', drawerSide: 'top' }, + ], + }, + after: { + pages: [ + { + name: 'fvd_task_desk', + regions: [ + { + name: 'main', + components: [ + { type: 'object-form', properties: { objectName: 'fvd_task', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' } }, + ], + }, + ], + }, + ], + views: [ + { + object: 'fvd_task', + form: { columns: 2, sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + quick: { modalSize: 'lg' }, + plain: { columns: 1 }, + }, + }, + { name: 'fvd_task.peek', object: 'fvd_task', viewKind: 'form', config: {} }, + { name: 'fvd_task.all', object: 'fvd_task', viewKind: 'list', config: { type: 'grid', columns: ['title'] } }, + ], + viewItems: [ + { name: 'fvd_task.side', object: 'fvd_task', viewKind: 'form' }, + ], + }, + // `form`: both keys (2). `quick`, the item record and the assembled + // overlay: one each (3). The page block, `plain` and the list emit none. + expectedNotices: 5, + }, +}; + /** * [commit c459da6bc] The per-option `default` key leaves the FORM-VIEW options * vocabulary (protocol 18; maintainer-ruled narrowing 2026-08-28 on the @@ -8998,10 +9125,10 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { * tombstone. Deleting is the whole edit: a form whose `type` was retired lands * on the `simple` default, the conversion invents no layout. The keys go on * every form, whatever its `type`: each is refused on every form now, and a - * `pane` on a non-split form was already a parse error. `drawerSide`, - * `drawerWidth` and `modalSize` stay — the first two for the React - * `ObjectForm` block contract, whose drawer container reads them, `modalSize` - * because the console's record dialog reads it on every layout. + * `pane` on a non-split form was already a parse error. `modalSize` stays, + * because the console's record dialog reads it on every layout. `drawerSide` + * and `drawerWidth` stayed here and left the form view later in the same + * release ([#29012] {@link formViewDrawerKeysRemoved}). * * **[#27159] The renderer half, absorbed here** (the skill's same-major rule: * 18.3.0 is unreleased — `@objectstack/spec` is 18.2.0 on `main` — and one @@ -9177,8 +9304,9 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { label: 'Schedule', pane: 'secondary', fields: ['due_date'] }, ], }, - // A drawer form: the member goes, its other keys stay. - quick: { type: 'drawer', drawerSide: 'left', sections: [{ label: 'Quick', fields: ['status'] }] }, + // A drawer form: the member goes, its other keys stay. ([#29012] Not + // `drawerSide`, which `form-view-drawer-keys-removed` now strips.) + quick: { type: 'drawer', title: 'Quick', sections: [{ label: 'Quick', fields: ['status'] }] }, // A modal form whose sections ride the legacy `groups` alias. dialog: { type: 'modal', modalSize: 'lg', groups: [{ label: 'Edit', fields: ['title'] }] }, }, @@ -9233,7 +9361,7 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { label: 'Schedule', fields: ['due_date'] }, ], }, - quick: { drawerSide: 'left', sections: [{ label: 'Quick', fields: ['status'] }] }, + quick: { title: 'Quick', sections: [{ label: 'Quick', fields: ['status'] }] }, dialog: { modalSize: 'lg', groups: [{ label: 'Edit', fields: ['title'] }] }, }, }, @@ -15411,6 +15539,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: formFieldPublicPickerRemoved, order: 53 }, { conversion: formLayoutInlineGridToVertical, order: 40 }, { conversion: formViewButtonsResetRemoved, order: 79 }, + { conversion: formViewDrawerKeysRemoved, order: 81 }, { 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__drawerSide.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts new file mode 100644 index 00000000000..13bf48e7db5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29012 — ADR-0049 enforce-or-remove on the form view's drawer keys (triage +// ruling: retire). `type: 'drawer'` left the form view in #27155, so no form +// view opens in a drawer and no form-view surface read the key. Tombstoned +// with `retiredKey()`; the React `ObjectForm` block keeps its own `drawerSide` +// overlay prop. D2: `form-view-drawer-keys-removed`; D3: +// `ui-form-view-drawer-keys-retired`. +export const entry = 'ui/FormView:drawerSide'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts new file mode 100644 index 00000000000..d7361659d32 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29012 — the second drawer key, retired with `drawerSide` on the same +// evidence (no form view opens in a drawer since #27155). It was already +// `[DEPRECATED → size buckets]` and had no size-bucket conversion of its own: +// the D2 entry `form-view-drawer-keys-removed` deletes it, and a list's record +// drawer takes its starting width from `navigation.size`. D3: +// `ui-form-view-drawer-keys-retired`. +export const entry = 'ui/FormView:drawerWidth'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts new file mode 100644 index 00000000000..d92b00ec364 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29012 (ADR-0049 enforce-or-remove, triage ruling: retire) — the D3 entry of +// the `form-view-drawer-keys-removed` family. The keys are registered in +// RETIRED_KEYS_BY_MAJOR as `ui/FormView:drawerSide` and `ui/FormView:drawerWidth`. +export const entry: SemanticMigration = { + id: 'ui-form-view-drawer-keys-retired', + surface: + 'view.form.drawerSide / view.form.drawerWidth and view.formViews..drawerSide / ' + + 'view.formViews..drawerWidth — removed from the form view (the React `ObjectForm` block and ' + + 'the `object-form` page block keep their own)', + replacement: + 'nothing on the form view: no form view opens in a drawer. Delete the keys. A list opens a clicked ' + + 'record in its drawer (`navigation.mode`) sized by the `navigation.size` bucket, and a React page ' + + 'that opens a form in a drawer writes the props on the block: `ObjectForm` with `formType=\'drawer\'` ' + + 'and its own `drawerSide` / `drawerWidth`', + reason: + 'ADR-0049: `type: \'drawer\'` left the form view in the same release, so no form view opens in a drawer, ' + + 'and no form-view surface (the console form page, its New and Edit dialogs, the record form page, ' + + 'Studio) read either key — a stored value saved, served and changed nothing. The D2 conversion ' + + '`form-view-drawer-keys-removed` makes each stored or existing source parse by deleting the keys, ' + + 'which changes nothing a reader sees. What it cannot decide is whether a form view that carried them ' + + 'was meant to open in a drawer somewhere: an author who wanted that opens it from a React page through ' + + 'the `ObjectForm` block\'s drawer, or lets a list open the record in its drawer', + acceptanceCriteria: + 'No form view declares `drawerSide` or `drawerWidth`; each is refused at parse with its prescription, ' + + 'and TypeScript rejects it at a `FormView` position. The React `ObjectForm` block still publishes both ' + + 'props, and the `object-form` page block still accepts them with `formType: \'drawer\'`.', + conversionIds: ['form-view-drawer-keys-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index e8732b60f0c..e3d474bcb1b 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3021,6 +3021,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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-drawer-keys-retired', + order: 116, + text: + 'It also retires the form view\'s `drawerSide` and `drawerWidth` (ADR-0049): once `type: ' + + '\'drawer\'` left the form view, no form view opened in a drawer and no form-view surface read ' + + 'either key. Both are refused at parse with their prescription; the D2 conversion ' + + '`form-view-drawer-keys-removed` strips them from existing sources and stored rows, which changes ' + + 'nothing a reader sees. The React `ObjectForm` block keeps both as props of its own for the drawer ' + + 'it opens with `formType=\'drawer\'`, as does the `object-form` page block. Its D3 record is the ' + + 'semantic entry `ui-form-view-drawer-keys-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 569e74795d0..12f610a65ee 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 @@ -415,6 +415,11 @@ const JOIN: Readonly> = { 'form-view-buttons-reset-removed': [ tombstone((FormViewSchema.shape.buttons as unknown as { unwrap: () => unknown }).unwrap(), 'view.form.buttons', 'reset'), ], + // #29012: the drawer keys, tombstoned on the form view. + 'form-view-drawer-keys-removed': [ + tombstone(FormViewSchema, 'view.form', 'drawerSide'), + tombstone(FormViewSchema, 'view.form', 'drawerWidth'), + ], // #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/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 10220b34c84..fd5adc838bd 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -445,13 +445,6 @@ const LEDGER: ReadonlyArray = [ key: 'titleFormat', why: "`[DEPRECATED → nameField (ADR-0079)]` render-only title template the server cannot return or query, deliberately not offered to new authors; its own describe prescribes the migration — a single-field title to `nameField`, a composite title that import, sort or search must match to a stored computed field (a non-formula field with an `expression`) designated as `nameField`, a text formula staying legal for display only — and every target is authorable", }, - { - kind: 'omit', - type: 'view', - path: ROOT_PATH, - key: 'drawerWidth', - why: '`[DEPRECATED → size buckets]` pixel drawer width, deliberately not offered to new authors: a pixel width cannot be chosen without knowing the client viewport, so the renderer derives it from the size bucket', - }, { kind: 'omit', type: 'view', diff --git a/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts b/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts new file mode 100644 index 00000000000..95a8fb9b4b6 --- /dev/null +++ b/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FormView.drawerSide` / `drawerWidth` are retired (#29012, ADR-0049 + * enforce-or-remove, triage ruling: retire). `type: 'drawer'` left the form + * view in #27155, so no form view opens in a drawer, and no metadata form-view + * surface reads either key: a stored value saved, served and changed nothing. + * + * Bookkeeping shapes, pinned below: + * 1. Both keys are `retiredKey()` tombstones, refused with their + * prescription at every form-view door (the schema, a container's `form` + * / `formViews.*`, a ViewItem form record, a flattened form overlay, and + * `defineStack`). + * 2. The drawer that reads them keeps them: the React `ObjectForm` block + * declares both in its overlay (`react-blocks.ts`) with the meaning it + * always published, and the `object-form` page block + * (`ObjectFormPropsSchema`) still accepts them. + * 3. One D2 conversion, retired from the load path: + * `form-view-drawer-keys-removed` strips both keys from stored rows and + * existing sources; its D3 entry is `ui-form-view-drawer-keys-retired`. + * 4. Tree-scoped absence: no form view inside the declared radius still + * authors either key. + * + * 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 { ObjectFormPropsSchema } from './component.zod'; +import { REACT_BLOCKS, REACT_OVERLAY_SHADOWS } from './react-blocks'; +import { FormViewSchema, VIEW_METADATA_MEMBERS, ViewItemSchema, ViewSchema } from './view.zod'; + +const CONVERSION_ID = 'form-view-drawer-keys-removed'; +const D3_ID = 'ui-form-view-drawer-keys-retired'; + +const FIRST_SENTENCE = { + drawerSide: + '`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it.', + drawerWidth: + '`view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client ' + + 'viewport.', +} as const; +const MIGRATE_SENTENCE = 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources;'; +const KEYS = ['drawerSide', 'drawerWidth'] as const; +const VALUES = { drawerSide: ['left', 'right', 'top', 'bottom'], drawerWidth: ['480px', '50%'] } as const; + +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.drawerSide / drawerWidth — tombstones at every form-view door', () => { + for (const key of KEYS) { + it.each(DOORS.map((d) => [d.name, d] as const))(`%s refuses \`${key}\`, whatever it carries, with its prescription`, (_, door) => { + for (const value of VALUES[key]) { + const r = door.parse({ type: 'simple', sections: SECTIONS, [key]: value }); + expect(r.success, String(value)).toBe(false); + const issues = payloadIssues(r, door); + expect(issues, JSON.stringify(issues)).toHaveLength(1); + expect(issues[0]!.path).toEqual([...door.at, key]); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE[key]), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + } + }); + } + + it.each(DOORS.map((d) => [d.name, d] as const))('CONTROL: %s still accepts the same form without the keys, `modalSize` included', (_, door) => { + const r = door.parse({ type: 'simple', sections: SECTIONS, modalSize: 'lg' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('defineStack refuses them with the ADR-0112 envelope and the prescription', () => { + let refusal: unknown; + try { + defineStack({ + views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, drawerSide: 'left', drawerWidth: '480px' } } }], + } 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('.')).sort()).toEqual([ + 'views.0.formViews.quick.drawerSide', + 'views.0.formViews.quick.drawerWidth', + ]); + for (const issue of issues) { + const key = issue.path[issue.path.length - 1] as (typeof KEYS)[number]; + expect(issue.message.startsWith(FIRST_SENTENCE[key]), issue.message).toBe(true); + } + expect(() => defineStack({ + views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, modalSize: 'lg' } } }], + } as never)).not.toThrow(); + }); + + it('is registered in RETIRED_KEYS_BY_MAJOR[18] under the exact def keys', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining(['ui/FormView:drawerSide', 'ui/FormView:drawerWidth'])); + }); + + it('fails tsc at the authoring site: the tombstones admit only absence', () => { + const form: z.input = { + sections: SECTIONS, + // @ts-expect-error — `drawerSide` is a `retiredKey()` tombstone, typed `never`. + drawerSide: 'left', + }; + const wide: z.input = { + sections: SECTIONS, + // @ts-expect-error — `drawerWidth` is a `retiredKey()` tombstone, typed `never`. + drawerWidth: '480px', + }; + expect(FormViewSchema.safeParse(form).success).toBe(false); + expect(FormViewSchema.safeParse(wide).success).toBe(false); + }); +}); + +describe('the drawer that reads them keeps them', () => { + const objectForm = REACT_BLOCKS.find((b) => b.tag === 'ObjectForm')!; + + it(' publishes both from its overlay, no longer from the form view schema', () => { + for (const key of KEYS) { + const prop = objectForm.interactions.find((i) => i.name === key); + expect(prop, key).toBeDefined(); + expect(prop!.kind).toBe('binding'); + expect(prop!.description).toContain("formType 'drawer'"); + expect(objectForm.dataProps).not.toContain(key); + } + expect(objectForm.interactions.find((i) => i.name === 'drawerSide')!.type).toBe("'top' | 'bottom' | 'left' | 'right'"); + // The collision with the tombstone's name is ledgered, not accidental. + expect(REACT_OVERLAY_SHADOWS.ObjectForm).toEqual(expect.arrayContaining([...KEYS])); + }); + + it('the `object-form` page block still accepts both with its drawer container', () => { + const r = ObjectFormPropsSchema.safeParse({ objectName: 'crm_contact', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); +}); + +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, columns: 2 }, + formViews: { + quick: { sections: SECTIONS, modalSize: 'lg', drawerSide: 'left', drawerWidth: '480px' }, + }, + }; + expect(ViewSchema.safeParse(row).success).toBe(false); + 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.drawerSide', from: 'drawerSide', to: '(removed)' }, + { conversionId: CONVERSION_ID, path: 'views[0].formViews.quick.drawerWidth', from: 'drawerWidth', to: '(removed)' }, + ]); + const parsed = ViewSchema.parse(out); + expect(parsed.formViews?.quick).not.toHaveProperty('drawerSide'); + expect(parsed.formViews?.quick).not.toHaveProperty('drawerWidth'); + expect(parsed.formViews?.quick?.modalSize).toBe('lg'); + // A form without the keys 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).toHaveProperty('drawerSide', 'left'); + }); + + it('a STORED form view item record converts its `config`', () => { + const row = { name: 'crm_lead.peek', object: 'crm_lead', viewKind: 'form', config: { sections: SECTIONS, drawerWidth: '50%' } }; + 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.drawerWidth']); + expect(out.config).toEqual({ sections: SECTIONS }); + expect(ViewItemSchema.safeParse(out).success).toBe(true); + }); + + it('never touches the `object-form` page block, whose drawer reads the keys', () => { + const input = { + pages: [{ + name: 'desk', + regions: [{ name: 'main', components: [{ type: 'object-form', properties: { objectName: 'o', formType: 'drawer', drawerSide: 'left' } }] }], + }], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices( + { views: [{ object: 'o', form: { sections: SECTIONS, drawerSide: 'right', drawerWidth: '480px' } }] }, + { 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, drawerSide: 'left' } }] }; + 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 the two places a form view is written as +// an object literal: a `form: { … }` literal whose own top-level keys include +// a drawer key, and a `formViews: { … }` literal one of whose entries does. The +// bound, stated: the keys stay legal on the React `ObjectForm` block (JSX +// attributes) and the `object-form` page block (`properties: { … }`), which +// neither shape matches; a form assembled by spread or a computed key, a +// ViewItem form record's `config`, YAML, and the console under `ui/` (outside +// this radius) are outside what this walk sees. +describe('tree-scoped absence: no form view inside the declared radius still authors a drawer key', () => { + 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']); + const DRAWER_KEY = /(?:^|[\s,{])["']?(?:drawerSide|drawerWidth)["']?\s*:/; + + /** The text of each `{ … }` literal at `depth` inside the literal opened at `start`. */ + const topLevelText = (text: string, start: number, depth: number): string[] => { + const out: string[] = []; + let d = 0; + let cur = ''; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '{') { + d += 1; + if (d === depth) cur = ''; + } else if (ch === '}') { + if (d === depth) out.push(cur); + d -= 1; + if (d === 0) break; + } else if (d === depth) cur += ch; + } + return out; + }; + + /** Whether some form view literal holds a drawer key 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 = /["']?\b(form|formViews)["']?\s*:\s*\{/g; + for (let m = opener.exec(text); m; m = opener.exec(text)) { + const brace = m.index + m[0].length - 1; + const depth = m[1] === 'form' ? 1 : 2; + if (topLevelText(text, brace, depth).some((t) => DRAWER_KEY.test(t))) 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 keys. + */ + const EXCLUDED = new Set([ + // This pin authors the keys to assert their refusal and their conversion. + THIS_FILE, + ]); + 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("form: { type: 'simple', drawerSide: 'left', sections: [] },")).toBe(true); + expect(judge("formViews: { quick: { modalSize: 'lg', drawerWidth: '480px' } },")).toBe(true); + expect(judge('{ "formViews": {\n "quick": { "drawerSide": "left" }\n} }')).toBe(true); + expect(judge("Prose.\n\n```ts\nform: { drawerWidth: '50%' }\n```\n")).toBe(true); + // Neighbours that must NOT match: the blocks that keep the keys, and deeper nesting. + expect(judge("properties: { objectName: 'task', formType: 'drawer', drawerSide: 'right' },")).toBe(false); + expect(judge('')).toBe(false); + expect(judge("form: { modalSize: 'lg', sections: [{ label: 'A', fields: [] }] },")).toBe(false); + expect(judge("formViews: { quick: { modalSize: 'lg' } },")).toBe(false); + expect(judge("form: { sections: [{ props: { drawerSide: 'left' } }] }")).toBe(false); + expect(judge('a form once wrote `form: { drawerSide: "left" }`.')).toBe(false); + }); + + it('no form view authoring `drawerSide` or `drawerWidth` 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 (/["']?\bformViews["']?\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 + // `formViews` block were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(0); + expect(offenders, 'a form view authoring a drawer key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/form-view-type-layout-retirement.test.ts b/packages/spec/src/ui/form-view-type-layout-retirement.test.ts index c41150d5a7b..d95af823f44 100644 --- a/packages/spec/src/ui/form-view-type-layout-retirement.test.ts +++ b/packages/spec/src/ui/form-view-type-layout-retirement.test.ts @@ -16,8 +16,10 @@ * 2. The split-only keys `sections[].pane` (and the legacy `groups[].pane`), * `splitSize`, `splitResizable` and — since the renderer half, #27159 — * `splitDirection` are `retiredKey()` tombstones, refused on every - * layout. `drawerSide`, `drawerWidth` and `modalSize` stay declared — a - * stated boundary, pinned as one. + * layout. `modalSize` stays declared — a stated boundary, pinned as one. + * ([#29012] `drawerSide` / `drawerWidth` stayed here and were retired + * later in the same release: `form-view-drawer-keys-removed`, pinned in + * `form-view-drawer-keys-retirement.test.ts`.) * 3. The `object-form` page block (#27159): `formType` drops `split` and * keeps `drawer` / `modal` (the block renderer is the caller that opens * those containers); its `splitDirection`, `splitSize`, `splitResizable` @@ -226,16 +228,13 @@ describe('the split-only keys — `pane`, `splitSize`, `splitResizable`, `splitD ])); }); - it('STATED BOUNDARY: `drawerSide`, `drawerWidth` and `modalSize` stay declared', () => { - // `modalSize` is read by the console's record dialog on every layout; the - // other two feed the React `ObjectForm` block contract (`react-blocks.ts`), - // whose `formType='drawer'` container still reads them (#27153 keeps their - // enforce-or-remove question for form views). + it('STATED BOUNDARY: `modalSize` stays declared', () => { + // `modalSize` is read by the console's record dialog on every layout. + // [#29012] `drawerSide` / `drawerWidth` left this boundary: they are + // tombstones now (`form-view-drawer-keys-retirement.test.ts`). const r = FormViewSchema.safeParse({ type: 'simple', sections: SECTIONS, - drawerSide: 'left', - drawerWidth: '480px', modalSize: 'lg', }); expect(r.success).toBe(true); @@ -415,7 +414,8 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { splitDirection: 'vertical', sections: [{ label: 'A', pane: 'primary', fields: ['name'] }, { label: 'B', pane: 'secondary', fields: ['email'] }], }, - quick: { type: 'drawer', drawerSide: 'left', sections: [{ label: 'Q', fields: ['status'] }] }, + // [#29012] The bystander is `modalSize`: `drawerSide` is stripped by its own conversion now. + quick: { type: 'drawer', modalSize: 'lg', sections: [{ label: 'Q', fields: ['status'] }] }, }, }; const notices: ConversionNotice[] = []; @@ -432,7 +432,7 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { expect(parsed.formViews?.beside?.type).toBe('simple'); expect(parsed.formViews?.beside).not.toHaveProperty('splitDirection'); expect(parsed.formViews?.quick?.type).toBe('simple'); - expect(parsed.formViews?.quick?.drawerSide).toBe('left'); + expect(parsed.formViews?.quick?.modalSize).toBe('lg'); // A live layout is the author's and is never touched. expect(out.form).toBe(row.form); // Copy-on-write: the stored row is not mutated. diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index f359bad97c4..7915dcacfe0 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -87,6 +87,15 @@ export const REACT_OVERLAY_SHADOWS: Readonly> // REQUIRED to write on this tier — the binding `objectName` carried until // its retirement, now spelled `data={{ provider: 'object', object }}` only. ListView: ['data', 'navigation'], + // `drawerSide` / `drawerWidth` — [#29012] the schema member is a `retiredKey()` + // tombstone, not a live prop: a form VIEW never opens in a drawer, so + // `FormViewSchema` refuses both, while this block's `formType='drawer'` + // container still reads them. The overlay is now the block's only + // declaration of the two props, with the meaning the block always published + // (the #9392 rule: a prop only the react/registry tier reads lives here, not + // in `dataProps`). The collision is the tombstone's name, never a second + // meaning. + ObjectForm: ['drawerSide', 'drawerWidth'], }; /** @@ -239,7 +248,9 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ summary: "Server-connected create/edit/view form for one object. Config props come from the spec FormView schema; bind + wire it with the React props below.", schema: FormViewSchema, // [#27159] `splitDirection` left with the split form renderer (ADR-0050). - dataProps: ['layout', 'columns', 'tabPosition', 'drawerSide', 'drawerWidth', 'modalSize', 'sections', 'subforms', 'submitBehavior'], + // [#29012] `drawerSide` / `drawerWidth` left the form view (tombstones) and + // moved to the overlay below: the block's drawer container still reads them. + dataProps: ['layout', 'columns', 'tabPosition', 'modalSize', 'sections', 'subforms', 'submitBehavior'], interactions: [ OBJECT_NAME, { name: 'mode', type: "'create' | 'edit' | 'view'", kind: 'controlled', description: 'Create a new record, or edit/view an existing one — drive from React state.' }, @@ -263,6 +274,12 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ // navigation family ruling — see the baseline note on #9392). { name: 'customFields', type: 'any[]', kind: 'binding', description: 'Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source.' }, { name: 'readOnly', type: 'boolean', kind: 'binding', description: 'Render every field read-only, whatever `mode` says.' }, + // [#29012] Retired from FormViewSchema (a form view never opens in a + // drawer) and declared here, with the description the block published + // from the schema: only this block's `formType='drawer'` container + // (objectui `ObjectFormContainer` → `DrawerForm`) reads them. + { name: 'drawerSide', type: "'top' | 'bottom' | 'left' | 'right'", kind: 'binding', description: "Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer'" }, + { name: 'drawerWidth', type: 'string', kind: 'binding', description: 'Size of the drawer container the React `ObjectForm` block opens with formType \'drawer\', as a CSS length such as "480px": its width when drawerSide is left or right, its height when top or bottom. Unset, the drawer keeps its default size.' }, { name: 'modalCloseButton', type: 'boolean', kind: 'binding', description: "Show the close button of the block's modal container (formType 'modal')." }, { name: 'contentLayout', type: "'simple' | 'tabbed'", kind: 'binding', description: "How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'." }, { name: 'confirmOnDiscard', type: 'boolean', kind: 'binding', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately.' }, diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index e7791b6c7cf..812ef2cff51 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -380,10 +380,11 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha it('derives its vocabulary from the members, so a new arm key is admitted automatically', () => { // Not a hand-written list: every top-level key any member declares counts. - // `drawerSide` is a FormView key nobody would think to allow-list by - // hand. ([#7741] full acceptance additionally needs the binding pair; the - // vocabulary question this test asks is unchanged.) - expect(ViewMetadataSchema.safeParse({ drawerSide: 'left', object: 'crm_lead', viewKind: 'form' }).success).toBe(true); + // `showStepIndicator` is a FormView key nobody would think to allow-list + // by hand. ([#7741] full acceptance additionally needs the binding pair; + // the vocabulary question this test asks is unchanged. [#29012] It was + // `drawerSide` until that key became a tombstone.) + expect(ViewMetadataSchema.safeParse({ showStepIndicator: true, object: 'crm_lead', viewKind: 'form' }).success).toBe(true); // …and a key that exists only NESTED (inside `config`) is not top-level // vocabulary, so it cannot smuggle a garbage body through. expect(ViewMetadataSchema.safeParse({ groupByField: 'stage' }).success).toBe(false); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 53cd12faac2..e89e25aad27 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5015,6 +5015,31 @@ const FORM_VIEW_BUTTONS_RESET_RETIRED = + 'longer clears after a save, and a form that should declares `submitBehavior: { kind: \'continue\' }`. ' + FORM_VIEW_TYPE_RETIRED_FIX; +// [#29012] The drawer keys (ADR-0049 enforce-or-remove, triage ruling: retire). +// `type: 'drawer'` left the form view in #27155, so no form view opens in a +// drawer, and no metadata form-view surface (the console's form page, its New +// / Edit dialogs, the record form page, Studio) reads either key: a stored +// `drawerSide: 'left'` saved, served and changed nothing. The one drawer that +// reads them is the container the React `ObjectForm` block opens with +// `formType='drawer'`, and that block keeps both as props of its own +// (`react-blocks.ts` overlay; the `object-form` page block's +// `ObjectFormPropsSchema` declares them too). The +// `form-view-drawer-keys-removed` conversion (ADR-0087 D2) strips them from +// stored rows and sources. +const FORM_VIEW_DRAWER_SIDE_RETIRED = + '`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer ' + + '(`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it ' + + 'opens with `formType=\'drawer\'`. ' + + FORM_VIEW_TYPE_RETIRED_FIX; + +const FORM_VIEW_DRAWER_WIDTH_RETIRED = + '`view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client ' + + 'viewport. Delete the key: a list\'s record drawer takes its starting width from the `navigation.size` ' + + 'bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. ' + + 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 @@ -5163,16 +5188,13 @@ export const FormViewSchema = lazySchema(() => strictObject({ /** [#27155] RETIRED with `type: 'split'` — {@link FORM_VIEW_SPLIT_RESIZABLE_RETIRED}. */ splitResizable: retiredKey(FORM_VIEW_SPLIT_RESIZABLE_RETIRED), /** - * Drawer side and width. [#27155] Inert on a form view since `type: 'drawer'` - * was retired. [#27159] Both keep a live reader: the React `ObjectForm` block - * takes its contract from this schema (`react-blocks.ts`), and a React page's - * `formType='drawer'` opens the drawer container the block supplies, which - * reads them. The drawer half of their enforce-or-remove question stays open - * on #27153. + * [#29012] RETIRED — {@link FORM_VIEW_DRAWER_SIDE_RETIRED}. Inert on a form + * view since `type: 'drawer'` left it (#27155); the React `ObjectForm` block + * declares its own `drawerSide` in its overlay (`react-blocks.ts`). */ - drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe("Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer'"), - /** @deprecated [#2578 → `modalSize` / size buckets] A pixel width can't be authored blind (unknown client viewport); the renderer derives width from content + viewport. */ - drawerWidth: z.string().optional().describe('[DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType \'drawer\', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.'), + drawerSide: retiredKey(FORM_VIEW_DRAWER_SIDE_RETIRED), + /** [#29012] RETIRED — {@link FORM_VIEW_DRAWER_WIDTH_RETIRED}. */ + drawerWidth: retiredKey(FORM_VIEW_DRAWER_WIDTH_RETIRED), /** * Size bucket of the dialog a caller opens this form in. Live on every * layout: the console's New / Edit dialog reads it off the form view it diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 6dbf4e9f628..e6cb09a24da 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -74,6 +74,7 @@ "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-drawer-keys-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/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 5e5d0513044..9ec87202440 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -23,6 +23,8 @@ Server-connected create/edit/view form for one object. Config props come from th | `initialValues` | `Record` | binding | | Prefill values in create mode. | | `customFields` | `any[]` | binding | | Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source. | | `readOnly` | `boolean` | binding | | Render every field read-only, whatever `mode` says. | +| `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | binding | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | +| `drawerWidth` | `string` | binding | | Size of the drawer container the React `ObjectForm` block opens with formType 'drawer', as a CSS length such as "480px": its width when drawerSide is left or right, its height when top or bottom. Unset, the drawer keeps its default size. | | `modalCloseButton` | `boolean` | binding | | Show the close button of the block's modal container (formType 'modal'). | | `contentLayout` | `'simple' \| 'tabbed'` | binding | | How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'. | | `confirmOnDiscard` | `boolean` | binding | | Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately. | @@ -44,8 +46,6 @@ Server-connected create/edit/view form for one object. Config props come from th | `layout` | `'vertical' \| 'horizontal'` | data | | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | `columns` | `integer` | data | | Number of columns for the form body | | `tabPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Tab strip position (tabbed forms) | -| `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| `drawerWidth` | `string` | data | | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chos… | | `modalSize` | `'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'` | data | | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal… | | `sections` | `object[]` | data | | | | `subforms` | `object[]` | data | | Inline master-detail child collections |