diff --git a/.changeset/27159-object-form-split-retired.md b/.changeset/27159-object-form-split-retired.md new file mode 100644 index 00000000000..ae8e6e812a4 --- /dev/null +++ b/.changeset/27159-object-form-split-retired.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/skills': patch +'@object-ui/plugin-form': minor +'@object-ui/types': minor +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +feat(spec,plugin-form)!: `ObjectForm` lays a form out and no longer picks its container; the `object-form` block drops `formType: 'split'` and `SplitForm` retires, while the block keeps opening `drawer` and `modal` forms itself + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed (the `object-form` block's `formType` refuses `split`; its `splitDirection`, `splitSize`, `splitResizable` and `sections[].pane`, the `object-master-detail-form` block's `sections[].pane`, and `FormViewSchema.splitDirection`, are tombstones) + + + +**BREAKING** accept-set narrowing on the `object-form` page block and the form view (`@objectstack/spec`), and a removed export (`@object-ui/plugin-form`). ADR-0050, the renderer half of #27155. + +The model: a form's `formType` says how it lays out its sections. Where it opens is the caller's. On the `object-form` block the caller is the block itself, so `drawer` and `modal` stay there as the two containers the block opens; `split` drew two panes and had no other reader, so it leaves with its renderer. + +- **FROM → TO (block).** `formType: 'split'` → `formType: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the block's `splitDirection` / `splitSize` / `splitResizable`. To edit child records beside the parent, use an `object-master-detail-form` block. `formType: 'drawer'` and `formType: 'modal'` are unchanged. The `object-master-detail-form` block's parent `sections` share the shape, so delete each section's `pane` there too. +- **FROM → TO (form view).** `splitDirection` on a form view → delete it. It joins `splitSize` and `splitResizable`, which left in the same release. +- **The one-line fix.** Delete the retired value and keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored page or view row is converted when it is read (the `form-view-presentation-types-removed` conversion now reaches `object-form` page blocks, the section `pane` of `object-master-detail-form` blocks, and `splitDirection` too), so a block saved with `split` keeps loading as a `simple` form. +- **What the parse says.** `formType: 'split'` is refused by name with its prescription ("'split' was removed from the `object-form` `formType` enum in @objectstack/spec 18.3.0 (ADR-0050) — …"); each tombstoned key is refused on every `formType`. TypeScript rejects each at the authoring site. +- **`@object-ui/plugin-form`.** `ObjectForm` renders the `simple`, `tabbed` and `wizard` layouts only; a `formType` of `drawer` or `modal` on a node handed straight to `` now renders the inline form, and the host opens its own drawer or dialog around it (the console's New and Edit dialogs already mount `ModalForm` themselves). The registered `object-form` block routes `drawer` and `modal` through the new internal `ObjectFormContainer`, which hands the node to `DrawerForm` or `ModalForm` as before. `SplitForm`, `SplitFormProps` and `SplitFormSchema` are no longer exported, and the block no longer publishes the `splitDirection`, `splitSize` and `splitResizable` inputs. FROM `import { SplitForm } from '@object-ui/plugin-form'` → TO a `TabbedForm`, or the `form` renderer's `fieldPanes` for a host that composes panes itself. +- **`@object-ui/types`.** `ObjectFormSchema.formType` is `'simple' | 'tabbed' | 'wizard' | 'drawer' | 'modal'`, and its `splitDirection` / `splitSize` / `splitResizable` are the spec's tombstones by reference; the section `pane` member is gone. +- **Console.** Studio's page-block inspector no longer offers `Split` for an `object-form` (its `engine.inspector.pageBlock.option.formType.split` string is removed from `@object-ui/i18n`), the record page passes no split keys, and a view whose `type` is not one of the three form layouts is no longer taken for a form (`@object-ui/console`). +- **Supersedes one line of the form view `type` entry (#27155).** That entry says `splitDirection` stays declared; in this release it leaves too. +- **Unchanged.** `drawerSide`, `drawerWidth`, `modalSize` and `modalCloseButton` keep their readers: the block's drawer and modal containers. The enforce-or-remove question for `drawerSide` / `drawerWidth` on form views stays on #27153. diff --git a/content/docs/frontend/plugins/plugin-form.mdx b/content/docs/frontend/plugins/plugin-form.mdx index 88354d7f6c4..eac6ce91e0b 100644 --- a/content/docs/frontend/plugins/plugin-form.mdx +++ b/content/docs/frontend/plugins/plugin-form.mdx @@ -74,13 +74,25 @@ its `schema`, but never a `dataSource` — that travels on one or wraps the component the same way. The components are on the package's export surface — `ObjectForm`, -`TabbedForm`, `WizardForm`, `SplitForm`, `DrawerForm`, `ModalForm`, -`EmbeddableForm`, `MasterDetailForm`, `LineItemsPanel`, `FormAnalytics`, +`TabbedForm`, `WizardForm`, `DrawerForm`, `ModalForm`, `EmbeddableForm`, `MasterDetailForm`, `LineItemsPanel`, `FormAnalytics`, `FormSectionContainer` — alongside the layout helpers (`applyAutoLayout`, `inferColumns`, `filterCreateModeFields`, …) and the per-container schema types (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …). There is no aggregate map among them. +### Where an `object-form` block opens + +An `object-form`'s `formType` is the form's layout — `simple` (the default), +`tabbed` or `wizard` — or, on the block, the container the block opens it in: +`drawer` or `modal` (ADR-0050). `ObjectForm` only lays the form out; the block +renderer is the caller that supplies the container, handing the node to +`DrawerForm` (with `drawerSide` / `drawerWidth`) or `ModalForm` (with +`modalSize` / `modalCloseButton`). A host that renders `` directly, +or registers it under a key of its own as above, gets the inline form and opens +its own drawer or dialog around it. The `split` form type and its `SplitForm` +renderer were removed (ADR-0050): use `tabbed`, or an +`object-master-detail-form` block to edit child records beside the parent. + ## What an edit save writes Which fields an edit save sends, and which fields and line-item actions a caller is offered, is the same in the SDK and the Console. It is documented under [What an edit save writes](/docs/ui/forms#what-an-edit-save-writes). @@ -103,7 +115,7 @@ The plugin supports these field types: `@object-ui/types` alongside the rest of the JSON contract. This package imports them and does not re-export them — the form types on its own entry are the per-container ones (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, -`SplitFormSchema`, `DrawerFormSchema`, `MasterDetailFormSchema`). +`DrawerFormSchema`, `MasterDetailFormSchema`). ```typescript import type { FormSchema, FormField } from '@object-ui/types'; diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 68c397e19b2..b0f66622adc 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1906,12 +1906,12 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | @@ -1985,12 +1985,12 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 2e10ddf2b16..c2277d1e0fa 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -546,25 +546,25 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead | | **recordId** | `string \| number` | optional | Record to load (edit/view modes) | | **mode** | `Enum<'create' \| 'edit' \| 'view'>` | optional | Form mode | -| **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional | Form presentation | +| **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'drawer' \| 'modal'>` | optional | The form's layout — 'simple', 'tabbed' or 'wizard' — or the container the block opens it in — 'drawer' or 'modal', which draw the form stacked ('contentLayout: tabbed' puts a modal's sections on tabs) | | **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `number` | optional | Number of field columns (multi-column forms), honoured under either `layout` | | **fields** | `string[]` | optional | Field names to draw, in order — bare names selecting from the object's fields and from `customFields`. A `{ name }` or `{ field }` object entry is refused: a per-form label or required override goes on a `sections[].fields` entry | | **customFields** | `{ name: string; label?: string; description?: string; type?: string; … }[]` | optional | Field definitions merged over the set generated from the object's metadata — each a closed inline field `{ name, label?, type?, required?, … }`: a member naming a field the object declares replaces that field's whole definition, any other is added after the generated fields. With no object behind the form, the members are its only fields | -| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }`, or `{ group, columns?, pane? }` to inherit an object field group. Each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline form field `{ name, type, … }` | +| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, fields }`, or `{ group, columns? }` to inherit an object field group. Each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline form field `{ name, type, … }` | | **title** | `string \| Record` | optional | Form title | | **description** | `string \| Record` | optional | Form description (rendered by the drawer/modal presentations) | | **defaultTab** | `string` | optional | Initially active tab (tabbed) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed) | | **allowSkip** | `boolean` | optional | Allow skipping steps (wizard) | | **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard) | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split direction (split) | -| **splitSize** | `number` | optional | Split panel size in percent (split) | -| **splitResizable** | `boolean` | optional | Allow resizing the split (split) | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Drawer side (drawer) | -| **drawerWidth** | `string \| number` | optional | Drawer width (drawer) | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal) | -| **modalCloseButton** | `boolean` | optional | Show the modal close button (modal) | +| **splitDirection** | `never` | optional | [REMOVED] `object-form` property `splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a `formType: 'split'` form, and `split` left the block 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] `object-form` property `splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `formType: 'split'` form, and `split` left the block 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. | +| **splitResizable** | `never` | optional | [REMOVED] `object-form` property `splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes resizable of a `formType: 'split'` form, and `split` left the block 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. | +| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the block's drawer container opens from (formType 'drawer') | +| **drawerWidth** | `string \| number` | optional | Width of the block's drawer container (formType 'drawer') | +| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the block's modal container (formType 'modal') | +| **modalCloseButton** | `boolean` | optional | Show the close button of the block's modal container (formType 'modal') | | **contentLayout** | `Enum<'simple' \| 'tabbed'>` | optional | How the modal presentation lays out its sections (modal) — 'simple' stacks them (the default); 'tabbed' puts each section on its own tab once more than one section has a field to show | | **confirmOnDiscard** | `boolean` | optional | Confirm before discarding edits (drawer/modal) | | **submitText** | `string \| Record` | optional | Submit button label | @@ -640,7 +640,7 @@ Sort field and direction pair | **collapsed** | `boolean` | optional | Start the section closed (implies `collapsible`) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE | | **columns** | `integer` | optional | Field-grid columns for this section (1-4), a number | -| **pane** | `Enum<'primary' \| 'secondary'>` | optional | The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary' | +| **pane** | `never` | optional | [REMOVED] Section property `pane` (on `object-form` and `object-master-detail-form` `sections[]`) was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one pane of an `object-form` `formType: 'split'` form, and `split` left that block 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. | | **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`) | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … } \| { name: string; label?: string; description?: string; type?: string; … })[]` | optional | The section's fields, in order — each a field name, the form view's `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members | @@ -1151,7 +1151,7 @@ Sort field and direction pair | **recordId** | `string \| number` | optional | Parent record to load (edit mode) | | **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode | | **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half | -| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` or `{ group, columns?, pane? }` | +| **sections** | `{ name?: string; label?: string; description?: string; collapsible?: boolean; … }[]` | optional | Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, fields }` or `{ group, columns? }` | | **fields** | `string[]` | optional | Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused | | **details** | `{ childObject: string; relationshipField?: string; columns?: object[]; formFields?: string[]; … }[]` | optional | Detail collections — each a strict entry (`{ childObject, title?, addLabel?, columns?, relationshipField?, … }`) whose `columns` are the inline grid columns a relationship field's `inlineColumns` takes; the FK and columns auto-derive from child metadata when omitted | | **title** | `string \| Record` | optional | Form title | @@ -1173,7 +1173,7 @@ Sort field and direction pair | **collapsed** | `boolean` | optional | Start the section closed (implies `collapsible`) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE | | **columns** | `integer` | optional | Field-grid columns for this section (1-4), a number | -| **pane** | `Enum<'primary' \| 'secondary'>` | optional | The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary' | +| **pane** | `never` | optional | [REMOVED] Section property `pane` (on `object-form` and `object-master-detail-form` `sections[]`) was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one pane of an `object-form` `formType: 'split'` form, and `split` left that block 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. | | **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`) | | **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … } \| { name: string; label?: string; description?: string; type?: string; … })[]` | optional | The section's fields, in order — each a field name, the form view's `{ field, … }` entry overriding that object field, or an inline form field `{ name, type, … }`. Omit only when `group` supplies the members | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0e9c95c95fb..36a2f14f9f9 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -396,12 +396,12 @@ Form-view select option — the object-field option shape minus the per-option ` | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | @@ -1776,12 +1776,12 @@ Tab configuration for multi-tab view interface | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | @@ -1855,12 +1855,12 @@ Tab configuration for multi-tab view interface | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | @@ -2092,12 +2092,12 @@ This schema accepts one of the following structures: | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | @@ -2276,12 +2276,12 @@ This schema accepts one of the following structures: | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | | **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | | **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | -| **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | +| **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 | Drawer side (drawer forms) | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) | +| **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. | +| **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 | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index 67f090ace09..61bbb40c1de 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -94,7 +94,7 @@ regenerates from the renderer registry. Regenerate it with | [`nav`](/docs/ui/components/html#nav) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`object-calendar`](/docs/ui/components/plugins#object-calendar) | `plugin-calendar` | 11 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-chart`](/docs/ui/components/plugins#object-chart) | `plugin-charts` | 6 | [Plugin Blocks](/docs/ui/components/plugins) | -| [`object-form`](/docs/ui/components/plugins#object-form) | `plugin-form` | 40 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-form`](/docs/ui/components/plugins#object-form) | `plugin-form` | 37 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-gantt`](/docs/ui/components/plugins#object-gantt) | `plugin-gantt` | 18 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-grid`](/docs/ui/components/plugins#object-grid) | `plugin-grid` | 31 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-kanban`](/docs/ui/components/plugins#object-kanban) | `plugin-kanban` | 15 | [Plugin Blocks](/docs/ui/components/plugins) | diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index 74f5bf31a0c..415135190c0 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -131,7 +131,7 @@ Namespace `plugin-form`. | `objectName` | `string` | | Object this form creates or edits. Not required: the node's `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no inline fields (non-empty `customFields`, or `sections` whose every field is inline), the form shows a hint naming this key instead of a form with no fields. | | `fields` | `array` | | Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (SimpleObjectForm in ObjectForm.tsx; buildFlatFields in flatFields.ts for the drawer/modal presentations). | | `mode` | `enum`: `create`, `edit`, `view` | | — | -| `formType` | `enum`: `simple`, `tabbed`, `wizard`, `split`, `drawer`, `modal` | | — | +| `formType` | `enum`: `simple`, `tabbed`, `wizard`, `drawer`, `modal` | | The form layout (`simple`, `tabbed`, `wizard`), or the container the block opens it in (`drawer`, `modal`, drawn as a stacked form; `contentLayout: "tabbed"` puts a modal's sections on tabs). `split` was retired with ADR-0050: use `tabbed` (one tab per section), or an `object-master-detail-form` block to edit child records beside the parent. | | `sections` | `array` | | — | | `title` | `string` \| `object` | | Heading of the drawer and modal presentations (`formType: "drawer"` / `"modal"`). Accepts either a plain string or an inline per-locale map (`{ en: "New order", "zh-CN": "新建订单" }`) — the `I18nLabel` union the contract admits on this key — and the form resolves the map against the active UI language, falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry. | | `description` | `string` \| `object` | | Subtitle under the drawer and modal heading. Accepts either a plain string or an inline per-locale map (`{ en: "Enter the order details", "zh-CN": "填写订单信息" }`), resolved against the active UI language with the same fallback chain as `title`. | @@ -141,13 +141,10 @@ Namespace `plugin-form`. | `tabPosition` | `enum`: `top`, `bottom`, `left`, `right` | | — | | `allowSkip` | `boolean` | | — | | `showStepIndicator` | `boolean` | | — | -| `splitDirection` | `enum`: `horizontal`, `vertical` | | — | -| `splitSize` | `number` | | — | -| `splitResizable` | `boolean` | | — | | `drawerSide` | `enum`: `top`, `bottom`, `left`, `right` | | — | | `drawerWidth` | `string` | | — | | `modalSize` | `enum`: `sm`, `default`, `lg`, `xl`, `full` | | — | -| `modalCloseButton` | `boolean` | | Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by `ObjectForm`’s modal route and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown. | +| `modalCloseButton` | `boolean` | | Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by the block’s modal container and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown. | | `contentLayout` | `enum`: `simple`, `tabbed` | | How the modal presentation lays out sections. `tabbed` differs from `simple` only when more than one section has a field to show (`ModalForm` tests `schema.contentLayout === "tabbed" && groups.length > 1`). | | `confirmOnDiscard` | `boolean` | | Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately. | | `recordId` | `string` | | The record to load in `edit` / `view` mode. Leave unset for `create`. | diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index b01ba8e65a1..28459eb1788 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -3790,7 +3790,7 @@ "flow.errorHandling { strategy, maxRetries } → maxRetries 2 yields three failed attempts", "flow.nodes[].inputSchema → stored and served", "view.list: searchableFields, userFilters, resizable, compactToolbar, selection, pagination, rowHeight, grouping, rowColor, hiddenFields, fieldOrder, rowActions, bulkActions, conditionalFormatting, inlineEdit, exportOptions (needs an export grant), appearance, addRecord, showRecordCount, allowPrinting, emptyState → each renders as its key describes (gap-view card, 3fcafcd3)", - "view.form: groups, subforms, submitBehavior, modalSize, allowSkip, showStepIndicator, splitDirection / splitSize / splitResizable → render as described; buttons, layout, drawerSide, drawerWidth: FAIL today (knownGaps[0]); defaults, defaultTab, tabPosition: partial", + "view.form: groups, subforms, submitBehavior, modalSize, allowSkip, showStepIndicator → render as described; splitDirection / splitSize / splitResizable → refused at parse (retired in @objectstack/spec 18.3.0 with the split form, ADR-0050); buttons, layout, drawerSide, drawerWidth: FAIL today (knownGaps[0]); defaults, defaultTab, tabPosition: partial", "dashboard.requiredPermissions → a viewer without the capability is refused the dashboard", "dataset (Studio has no dataset editor): include, dimensions with nullLabel, measures count / sum / avg / filtered / ratio, currency format → a report bound to it shows the null bucket labelled and each measure's value", "permission.description / isDefault / allowExport / rowLevelSecurity[].positions → the Setup list shows the description; isDefault raises a suggestion; allowExport → export 200 with it, 403 EXPORT_NOT_PERMITTED without; positions limit the policy to holders of the position" diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f84653d2375..8653b7dcf6a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -571,7 +571,7 @@ "ui/FormView:sections", "ui/FormView:sharing", "ui/FormView:showStepIndicator", - "ui/FormView:splitDirection", + "ui/FormView:splitDirection [RETIRED]", "ui/FormView:splitResizable [RETIRED]", "ui/FormView:splitSize [RETIRED]", "ui/FormView:subforms", @@ -823,9 +823,9 @@ "ui/ObjectFormProps:showReset", "ui/ObjectFormProps:showStepIndicator", "ui/ObjectFormProps:showSubmit", - "ui/ObjectFormProps:splitDirection", - "ui/ObjectFormProps:splitResizable", - "ui/ObjectFormProps:splitSize", + "ui/ObjectFormProps:splitDirection [RETIRED]", + "ui/ObjectFormProps:splitResizable [RETIRED]", + "ui/ObjectFormProps:splitSize [RETIRED]", "ui/ObjectFormProps:submitBehavior", "ui/ObjectFormProps:submitText", "ui/ObjectFormProps:successMessage", diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index cfa4d8864aa..260f861304e 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` | 76 | 0 | 0 | 7 | 0 | 83 | +| `view` | 75 | 0 | 0 | 8 | 0 | 83 | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 182082b2963..f5f02e9073d 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -302,10 +302,10 @@ "note": "objectui: WizardForm.tsx:438." }, "splitDirection": { - "status": "live", + "status": "dead", "verifiedAt": "2026-10-28", "evidenceScope": "in-repo", - "note": "objectui: SplitForm.tsx:205. INERT ON A FORM VIEW since 2026-10-28 (#27155): its form-view reader sat behind `type: 'split'`, 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='split'` still reads it (ui/packages/plugin-form/src/SplitForm.tsx). It leaves with SplitForm on #27159." + "note": "REMOVED 2026-10-28 (#27159, 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) and stripped from stored rows and sources by the protocol-18 form-view-presentation-types-removed conversion, which #27159 widened before 18.3.0 shipped. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It oriented the split form's panes. Its form-view reader left with `type: 'split'` on #27155; its last reader, ui/packages/plugin-form/src/SplitForm.tsx behind the React `ObjectForm` block's `formType='split'`, was deleted with that value on #27159 (the `object-form` block's own splitDirection is tombstoned in the same change); nothing to write instead — a `tabbed` form gives each section its own tab." }, "splitSize": { "status": "dead", @@ -323,13 +323,13 @@ "status": "live", "verifiedAt": "2026-10-28", "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 (ui/packages/plugin-form/src/DrawerForm.tsx). Its enforce-or-remove question for form views stays on #27153; the block contract keys move on #27159." + "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." }, "drawerWidth": { "status": "live", "verifiedAt": "2026-10-28", "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 (#27153, #27159)." + "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)." }, "modalSize": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index c0e24cc323b..ebda65c4017 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8899,10 +8899,20 @@ 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. `splitDirection`, - * `drawerSide`, `drawerWidth` and `modalSize` stay — the first three for the - * React `ObjectForm` block contract (#27159), `modalSize` because the - * console's record dialog reads it on every layout. + * `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. + * + * **[#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 + * entry keeps the fixtures disjoint). Once `SplitForm` retired, `splitDirection` + * lost its last reader and goes from every form payload too, and the + * `object-form` PAGE BLOCK sheds its own copies: a `formType: 'split'` member + * (deleted — the block lands on `simple`), `splitDirection`, `splitSize`, + * `splitResizable` and every `sections[].pane`. The block's `drawer` / `modal` + * members are NOT touched: the block renderer still supplies those containers + * (`ObjectFormContainer` in objectui), so they stay authorable there. * * **Reach: every FORM payload**, exactly as * {@link formViewSubformColumnsCanonicalized} below: {@link mapViewPayloads} @@ -8923,6 +8933,15 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { * The D3 entry `ui-form-view-type-layout-only` carries what the edit cannot * decide: whether a former `split` form reads better `tabbed`, and how the * caller of a former `drawer` / `modal` form opens it. + * + * **Page-block reach** ([#27159]): {@link mapPageComponents} — regions, slots + * and container nesting — scoped to `type === 'object-form'`, as + * {@link formLayoutInlineGridToVertical} is. The block accepted `split` and + * the three keys through 18.2.0, so a stored page or an assembled artifact + * can carry them. The `object-master-detail-form` block's parent `sections` + * are the same section shape (`objectFormSection()`), so its `pane` parsed + * through 18.2.0 and is refused now: the walk strips `sections[].pane` there + * too, and touches nothing else on that block. */ const formViewPresentationTypesRemoved: MetadataConversion = { id: 'form-view-presentation-types-removed', @@ -8931,12 +8950,19 @@ const formViewPresentationTypesRemoved: MetadataConversion = { retiredAfter: '18.2.0', surface: "view.form.type / view.formViews..type ('split' | 'drawer' | 'modal') / " - + 'view.form.sections[].pane / view.form.splitSize / view.form.splitResizable', + + 'view.form.sections[].pane / view.form.splitSize / view.form.splitResizable / ' + + "view.form.splitDirection / page.component.object-form.formType ('split') / " + + 'page.component.object-form.sections[].pane / page.component.object-form.splitDirection / ' + + 'page.component.object-form.splitSize / page.component.object-form.splitResizable / ' + + 'page.component.object-master-detail-form.sections[].pane', summary: "form view `type` members 'split' / 'drawer' / 'modal' removed (`type` is the form's layout — " + "simple, tabbed or wizard; where a form opens is the caller's open mode, and a record beside its " - + 'list is a reader\'s preference), with the split-only keys `sections[].pane`, `splitSize` and ' - + '`splitResizable`. The retired member is deleted, which lands the form on the `simple` default', + + 'list is a reader\'s preference), with the split-only keys `sections[].pane`, `splitDirection`, ' + + '`splitSize` and `splitResizable`; the `object-form` page block sheds its `split` member and the same ' + + 'four keys (its `drawer` / `modal` containers stay), and the `object-master-detail-form` block its ' + + 'sections\' `pane`. The retired member is deleted, which lands the ' + + 'form on the `simple` default', apply(stack, emit) { const RETIRED_TYPES: ReadonlySet = new Set(['split', 'drawer', 'modal']); const stripSectionPanes = (form: Dict, key: 'sections' | 'groups', path: string): Dict => { @@ -8958,11 +8984,33 @@ const formViewPresentationTypesRemoved: MetadataConversion = { next = { ...next }; delete next.type; } - next = stripKeys(next, ['splitSize', 'splitResizable'], emit, path); + next = stripKeys(next, ['splitDirection', 'splitSize', 'splitResizable'], emit, path); next = stripSectionPanes(next, 'sections', path); return stripSectionPanes(next, 'groups', path); }; - const withViews = mapViewPayloads(stack, (payload, kind, path) => + // [#27159] The `object-form` page block: only `split` left its `formType`. + const convertBlock = (properties: Dict, path: string): Dict => { + let next = properties; + if (next.formType === 'split') { + emit({ from: 'split', to: '(removed)', path: `${path}.formType` }); + next = { ...next }; + delete next.formType; + } + next = stripKeys(next, ['splitDirection', 'splitSize', 'splitResizable'], emit, path); + return stripSectionPanes(next, 'sections', path); + }; + const withPages = mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-form' && component.type !== 'object-master-detail-form') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + // The master-detail block's parent `sections` share the `object-form` + // section shape, `pane` included; it had no `split` member or split keys. + const next = component.type === 'object-form' + ? convertBlock(properties, `${path}.properties`) + : stripSectionPanes(properties, 'sections', `${path}.properties`); + return next === properties ? component : { ...component, properties: next }; + }); + const withViews = mapViewPayloads(withPages, (payload, kind, path) => kind === 'form' ? convertForm(payload, path) : payload); return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => { if (item.viewKind !== 'form') return item; @@ -8977,14 +9025,49 @@ const formViewPresentationTypesRemoved: MetadataConversion = { }, fixture: { before: { + pages: [ + { + name: 'fv_task_desk', + regions: [ + { + name: 'main', + components: [ + // [#27159] A split block: the member, the three keys and both panes go. + { + type: 'object-form', + properties: { + objectName: 'fv_task', + formType: 'split', + splitDirection: 'horizontal', + splitSize: 40, + splitResizable: true, + sections: [ + { label: 'Task', pane: 'primary', fields: ['title'] }, + { label: 'Schedule', pane: 'secondary', fields: ['due_date'] }, + ], + }, + }, + // A drawer block is a live container on the block: untouched. + { type: 'object-form', properties: { objectName: 'fv_task', formType: 'drawer', drawerSide: 'right' } }, + // A master-detail block's parent sections share the shape: the pane goes. + { + type: 'object-master-detail-form', + properties: { objectName: 'fv_task', sections: [{ label: 'Task', pane: 'primary', fields: ['title'] }] }, + }, + // The same keys on a component that is NOT an `object-form` survive. + { type: 'element:custom', properties: { formType: 'split', splitSize: 40 } }, + ], + }, + ], + }, + ], views: [ { object: 'fv_task', // A live layout rides through by reference. form: { type: 'tabbed', sections: [{ label: 'Main', fields: ['title'] }] }, formViews: { - // A split form: the member, both panes and both split keys go; - // `splitDirection` stays (the React block contract still reads it). + // A split form: the member, both panes and the three split keys go. beside: { type: 'split', splitSize: 40, @@ -9012,13 +9095,40 @@ const formViewPresentationTypesRemoved: MetadataConversion = { ], }, after: { + pages: [ + { + name: 'fv_task_desk', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-form', + properties: { + objectName: 'fv_task', + sections: [ + { label: 'Task', fields: ['title'] }, + { label: 'Schedule', fields: ['due_date'] }, + ], + }, + }, + { type: 'object-form', properties: { objectName: 'fv_task', formType: 'drawer', drawerSide: 'right' } }, + { + type: 'object-master-detail-form', + properties: { objectName: 'fv_task', sections: [{ label: 'Task', fields: ['title'] }] }, + }, + { type: 'element:custom', properties: { formType: 'split', splitSize: 40 } }, + ], + }, + ], + }, + ], views: [ { object: 'fv_task', form: { type: 'tabbed', sections: [{ label: 'Main', fields: ['title'] }] }, formViews: { beside: { - splitDirection: 'vertical', sections: [ { label: 'Task', fields: ['title'] }, { label: 'Schedule', fields: ['due_date'] }, @@ -9035,10 +9145,13 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { name: 'fv_task.side', object: 'fv_task', viewKind: 'form' }, ], }, - // `beside`: the member, `splitSize`, `splitResizable` and two panes (5); + // The split block: the member, three keys and two panes (6). The + // master-detail block: its one pane (1). `beside`: the member, + // `splitDirection`, `splitSize`, `splitResizable` and two panes (6); // `quick`, `dialog`, the item record and the assembled overlay: one member - // each (4). The tabbed form and the list emit none. - expectedNotices: 9, + // each (4). The drawer block, the non-form component, the tabbed form and + // the list emit none. + expectedNotices: 17, }, }; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__splitDirection.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__splitDirection.ts new file mode 100644 index 00000000000..eee307917ae --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__splitDirection.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #27159 — the split form's pane orientation. It outlived `type: 'split'` +// (#27155) only because the React `ObjectForm` block read it, and that reader +// left with `SplitForm` (ADR-0050). Tombstoned with `retiredKey()`. D2: +// `form-view-presentation-types-removed`; D3: `ui-form-view-type-layout-only`. +export const entry = 'ui/FormView:splitDirection'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitDirection.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitDirection.ts new file mode 100644 index 00000000000..db1c5226ed1 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitDirection.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #27159 — the `object-form` page block's split form setting: the pane orientation, read +// only behind `formType: 'split'`, which left the block with `SplitForm` +// (ADR-0050). Tombstoned with `retiredKey()`. D2: +// `form-view-presentation-types-removed`; D3: `ui-form-view-type-layout-only`. +export const entry = 'ui/ObjectFormProps:splitDirection'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitResizable.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitResizable.ts new file mode 100644 index 00000000000..579eb57fb1f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitResizable.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #27159 — the `object-form` page block's split form setting: whether the panes resize, read +// only behind `formType: 'split'`, which left the block with `SplitForm` +// (ADR-0050). Tombstoned with `retiredKey()`. D2: +// `form-view-presentation-types-removed`; D3: `ui-form-view-type-layout-only`. +export const entry = 'ui/ObjectFormProps:splitResizable'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitSize.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitSize.ts new file mode 100644 index 00000000000..d71e4fa2105 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitSize.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #27159 — the `object-form` page block's split form setting: the primary pane's size, read +// only behind `formType: 'split'`, which left the block with `SplitForm` +// (ADR-0050). Tombstoned with `retiredKey()`. D2: +// `form-view-presentation-types-removed`; D3: `ui-form-view-type-layout-only`. +export const entry = 'ui/ObjectFormProps:splitSize'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-type-layout-only.ts b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-type-layout-only.ts index 842d63ab50d..5b64679fa57 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-type-layout-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-type-layout-only.ts @@ -9,33 +9,44 @@ import type { SemanticMigration } from '../../types.js'; // form's layout, and where a form opens belongs to whoever opens it. The keys // (`ui/FormSection:pane`, `ui/FormView:splitSize`, `ui/FormView:splitResizable`) // are registered in RETIRED_KEYS_BY_MAJOR; the members are values, so they are -// not. +// not. #27159 (same ruling, the renderer half) widened it before 18.3.0 shipped: +// `ui/FormView:splitDirection` and the `object-form` page block's `split` value, +// `splitDirection`, `splitSize`, `splitResizable` and section `pane` left too, +// while the block keeps `drawer` and `modal` — the block renderer is the caller +// that opens its form in a drawer or dialog. export const entry: SemanticMigration = { id: 'ui-form-view-type-layout-only', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. surface: "view.form.type and view.formViews..type — the values 'split', 'drawer' and 'modal' left " + "FormViewSchema.type ('simple', 'tabbed' and 'wizard' are unchanged, 'simple' stays the default); " - + 'view.form.sections[].pane, view.form.splitSize and view.form.splitResizable were removed with them', + + 'view.form.sections[].pane, view.form.splitDirection, view.form.splitSize and view.form.splitResizable ' + + 'were removed with them; on an object-form page block the value \'split\' and the props ' + + 'splitDirection, splitSize, splitResizable and sections[].pane left too (its \'drawer\' and ' + + '\'modal\' stay), and so did sections[].pane on an object-master-detail-form block', replacement: 'the form\'s layout: delete the retired value for the `simple` default, or write `tabbed` (one tab ' + 'per section) or `wizard`. Where the form opens is the caller\'s: a list opens a clicked record in ' + 'the drawer by default (`navigation.mode`), the console\'s New and Edit dialogs open the record form ' + 'themselves (sized by `modalSize`), and an action `type: \'modal\'` opens the page it targets. A ' + 'record beside its list is a reader\'s own preference in the list\'s Display menu; child records ' - + 'edited beside the parent are `subforms`', + + 'edited beside the parent are `subforms`, and on a page that is an `object-master-detail-form` ' + + 'block. An `object-form` block that opened in a drawer or dialog keeps its `drawer` or `modal` ' + + '`formType` — the block is the caller that opens it', reason: 'ADR-0050: `type` held two orthogonal dimensions — the form\'s internal layout and the container it ' + 'opens in — plus a view mode, so a dialog holding a tabbed form was inexpressible and the three ' + 'presentation values duplicated the open modes the callers already declare. The D2 conversion ' + '`form-view-presentation-types-removed` makes each stored or existing source parse: it DELETES a ' - + 'retired member, which lands the form on the `simple` default, and strips `pane`, `splitSize` and ' - + '`splitResizable`. What it cannot decide is the layout a former `split` form should take (`tabbed` ' + + 'retired member, which lands the form on the `simple` default, and strips `pane`, `splitDirection`, ' + + '`splitSize` and `splitResizable` (on an `object-form` page block it deletes `formType: \'split\'` ' + + 'and strips the same keys, and on an `object-master-detail-form` block it strips a section `pane`). What it cannot decide is the layout a former `split` form should take (`tabbed` ' + 'keeps its sections apart, `simple` stacks them), and how the caller of a former `drawer` or `modal` ' + 'form should now open it', acceptanceCriteria: 'No form view declares `type` `split`, `drawer` or `modal`, and no form view section declares `pane`, ' - + 'nor a form view `splitSize` or `splitResizable`; each is refused at parse with its prescription, and ' + + 'nor a form view `splitDirection`, `splitSize` or `splitResizable`; no `object-form` page block ' + + 'declares `formType` `split`, `splitDirection`, `splitSize`, `splitResizable` or a section `pane`, and no `object-master-detail-form` block a section `pane`; each is refused at parse with its prescription, and ' + 'TypeScript rejects each at a `FormView` position. Every form the conversion changed renders in the ' + 'layout its author chose, and every entry point that opened it as a drawer or dialog still opens it ' + 'that way through its own open mode.', 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 c7b523f5dde..97e9e64f1ef 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 @@ -108,7 +108,9 @@ import { ElementRecordPickerPropsSchema, ElementButtonPropsSchema, ElementRepeaterPropsSchema, + ObjectFormPropsSchema, ObjectGridPropsSchema, + ObjectMasterDetailFormPropsSchema, ObjectKanbanPropsSchema, PageHeaderProps, } from '../ui/component.zod'; @@ -419,6 +421,27 @@ const JOIN: Readonly> = { tombstone(FormSectionSchema, 'view.form.sections[]', 'pane'), tombstone(FormViewSchema, 'view.form', 'splitSize'), tombstone(FormViewSchema, 'view.form', 'splitResizable'), + // #27159 (same family, before 18.3.0 shipped): the form view's last split + // key, and the `object-form` block's `split` value with its split-only keys. + tombstone(FormViewSchema, 'view.form', 'splitDirection'), + refusal( + "object-form formType 'split'", + () => ObjectFormPropsSchema.safeParse({ objectName: 'crm_contact', formType: 'split' }), + 'formType', + ), + tombstone(ObjectFormPropsSchema, 'object-form', 'splitDirection'), + tombstone(ObjectFormPropsSchema, 'object-form', 'splitSize'), + tombstone(ObjectFormPropsSchema, 'object-form', 'splitResizable'), + refusal( + 'object-form sections[].pane', + () => ObjectFormPropsSchema.safeParse({ objectName: 'crm_contact', sections: [{ pane: 'primary', fields: ['name'] }] }), + 'sections', + ), + refusal( + 'object-master-detail-form sections[].pane', + () => ObjectMasterDetailFormPropsSchema.safeParse({ objectName: 'crm_contact', sections: [{ pane: 'primary', fields: ['name'] }] }), + 'sections', + ), ], // #25050: retired after 18.0.0, so the v18 train's major. Partial (unset // values and remote urls only), so the prescription's migrate sentence is diff --git a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts index 90744739fa1..ec8634ab7ab 100644 --- a/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts +++ b/packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts @@ -205,11 +205,12 @@ describe('§1 each member accepts every shape a measured writer authors', () => ['the form view\'s `{ field }` entry', 'object-form', { sections: [{ label: 'Owner', fields: ['name', { field: 'owner', required: true }] }] }], // `__tests__/formSectionGroupReference-7051.test.tsx` — the group-reference form, with its layout keys. ['a group section', 'object-form', { sections: [{ group: 'contact_info', columns: 2 }, { label: 'Other', fields: ['note'] }] }], - // `__tests__/collapseResolution-9849.test.tsx`, `sectionColumns.test.tsx`, the split form's `pane`. - ['the collapse pair, columns and pane', 'object-form', { + // `__tests__/collapseResolution-9849.test.tsx`, `sectionColumns.test.tsx`. (`pane` left with the + // split form, #27159 — refused in §2.) + ['the collapse pair and columns', 'object-form', { sections: [ - { name: 'money', label: 'Money', collapsible: true, collapsed: true, columns: 2, pane: 'primary', fields: ['amount'] }, - { name: 'rest', label: 'Rest', columns: 1, pane: 'secondary', fields: ['note'] }, + { name: 'money', label: 'Money', collapsible: true, collapsed: true, columns: 2, fields: ['amount'] }, + { name: 'rest', label: 'Rest', columns: 1, fields: ['note'] }, ], }], // §5 — objectui's schema-catalog grid example (recorded in `liveness/page.json`, row `regions`): @@ -355,7 +356,8 @@ describe('§2 off-shape values are refused with the code and the path', () => { { code: 'custom', path: 'sections.0.label' }, { code: 'custom', path: 'sections.0.collapsible' }, ]], - ['an unknown pane', 'object-form', { sections: [{ fields: ['a'], pane: 'left' }] }, [{ code: 'invalid_value', path: 'sections.0.pane' }]], + // #27159: `pane` is a `retiredKey()` tombstone — every value is refused. + ['a retired pane', 'object-form', { sections: [{ fields: ['a'], pane: 'primary' }] }, [{ code: 'invalid_type', path: 'sections.0.pane' }]], ['a numeric entry', 'object-form', { sections: [{ fields: [5] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], ['a `{ field }` entry\'s `visibleOn`', 'object-form', { sections: [{ fields: [{ field: 'a', visibleOn: 'record.b == 1' }] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], ['an inline entry\'s unknown key', 'object-form', { sections: [{ fields: [{ name: 'a', bogus: 1 }] }] }, [{ code: 'invalid_union', path: 'sections.0.fields.0' }]], diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index dfc03591efa..e713b4f7e81 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6295,6 +6295,51 @@ const OBJECT_FORM_LAYOUT_RETIRED: ReadonlyMap = new Map([ + '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.'], ]); +// [#27159] The `object-form` block's split presentation, retired with the +// split form renderer — ADR-0050 Accepted by ruling 6464210455 on #27032 +// (letter A′): `ObjectForm` lays a form out and never chooses where it opens, +// and `split` was neither a layout nor a container. The form view lost the +// same member in #27155 (`FORM_VIEW_TYPE_RETIRED`, ui/view.zod.ts); these are +// the block's own copies. `drawer` and `modal` STAY on the block: a page has +// no other door to a built-in overlay form, so the block renderer supplies +// those containers itself. Declared with `//` on purpose, as above. The +// `form-view-presentation-types-removed` conversion (ADR-0087 D2) deletes the +// member and strips the keys from every `object-form` page component. +const OBJECT_FORM_SPLIT_FIX = + '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.'; + +const OBJECT_FORM_TYPE_RETIRED = { + split: + "'split' was removed from the `object-form` `formType` enum in @objectstack/spec 18.3.0 (ADR-0050) — " + + 'the split form renderer retired with it, so nothing draws two panes: `formType` is the form\'s ' + + 'layout (`simple`, `tabbed`, `wizard`) or the container the block opens it in (`drawer`, `modal`). ' + + 'Write `tabbed` (one tab per section) or delete the value for the `simple` default, and delete each ' + + 'section\'s `pane` and the block\'s `splitDirection` / `splitSize` / `splitResizable`; to edit child ' + + 'records beside the parent, use an `object-master-detail-form`. ' + + OBJECT_FORM_SPLIT_FIX, +} as const; + +/** One prescription per split-only key: each was read only by the split form. */ +const objectFormSplitKeyRetired = (key: string, what: string): string => + `\`object-form\` property \`${key}\` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it ${what} ` + + "of a `formType: 'split'` form, and `split` left the block in the same release, so no form draws " + + 'panes. Delete the key. ' + + OBJECT_FORM_SPLIT_FIX; + +const OBJECT_FORM_SPLIT_DIRECTION_RETIRED = objectFormSplitKeyRetired('splitDirection', 'set the pane orientation'); +const OBJECT_FORM_SPLIT_SIZE_RETIRED = objectFormSplitKeyRetired('splitSize', 'sized the primary pane'); +const OBJECT_FORM_SPLIT_RESIZABLE_RETIRED = objectFormSplitKeyRetired('splitResizable', 'made the panes resizable'); +/** + * One section shape serves the `object-form` block and the parent half of the + * `object-master-detail-form` block (`objectFormSection()`), so this + * prescription names both. + */ +const OBJECT_FORM_SECTION_PANE_RETIRED = + 'Section property `pane` (on `object-form` and `object-master-detail-form` `sections[]`) was removed in ' + + "@objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one pane of an `object-form` `formType: 'split'` " + + 'form, and `split` left that block in the same release, so no form draws panes. Delete the key. ' + + OBJECT_FORM_SPLIT_FIX; + /** * [#21464] An `object-form`'s `mobile` block — the five members the form reads * (the flat arm only; read points recorded in `liveness/page.json`, row @@ -6842,9 +6887,9 @@ function objectFormSectionColumnsRefusal(input: unknown): string | undefined { * * `name` and `label` (the heading, and the per-`formType` maps), * `description`, `collapsible` / `collapsed` (`resolveSectionCollapse`), - * `visibleWhen` (the divider row's predicate), `columns`, `pane` (the split - * form), `group` (resolved into the field group's own section above the - * routing) and `fields` (read points recorded in `liveness/page.json`, row + * `visibleWhen` (the divider row's predicate), `columns`, `pane` (a + * tombstone since 18.3.0: its reader, the split form, retired), `group` + * (resolved into the field group's own section above the routing) and `fields` (read points recorded in `liveness/page.json`, row * `regions`). That is exactly the form view's section's key set, and objectui * declares the same (`ObjectFormSection`). * @@ -6896,7 +6941,8 @@ function buildObjectFormSection() { visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the whole section, heading and fields, is drawn only when TRUE'), columns: z.number({ error: (issue) => objectFormSectionColumnsRefusal(issue.input) }).int().min(1).max(4).optional() .describe('Field-grid columns for this section (1-4), a number'), - pane: z.enum(['primary', 'secondary']).optional().describe("The split form's panel this section renders in; omitted → the first section 'primary', the others 'secondary'"), + /** [#27159] RETIRED with `formType: 'split'` — {@link OBJECT_FORM_SECTION_PANE_RETIRED}. */ + pane: retiredKey(OBJECT_FORM_SECTION_PANE_RETIRED), group: SectionGroupKeySchema.optional().describe('Field group key (snake_case) whose members and presentation this section inherits, from the object\'s `fieldGroups`. Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `visibleWhen`, and a `true` `collapsible` / `collapsed`)'), fields: z.array(z.union([ z.string(), @@ -6916,13 +6962,13 @@ let objectFormSectionOnce: ReturnType | undefined const objectFormSection = () => (objectFormSectionOnce ??= buildObjectFormSection()); /** - * `object-form` (objectui's `ObjectForm` renderer, plus the sub-forms it - * forwards the whole bag into; read points recorded in `liveness/page.json`, - * row `regions`: `TabbedForm`, `WizardForm`, - * `SplitForm`, `DrawerForm`, `ModalForm` — the declared set is the UNION of - * their `schema.*` reads, which is how `description` earns its place: the - * top-level key is read by the drawer/modal presentations, not by the simple - * form). Enum values are declared only where renderer, registry `inputs` and + * `object-form` (objectui's `object-form` block renderer: `ObjectForm` and the + * layouts it forwards the whole bag into, plus the drawer / modal container + * the block supplies; read points recorded in `liveness/page.json`, row + * `regions`: `TabbedForm`, `WizardForm`, `DrawerForm`, `ModalForm` — the + * declared set is the UNION of their `schema.*` reads, which is how + * `description` earns its place: the top-level key is read by the drawer/modal + * containers, not by the simple form. `SplitForm` retired with ADR-0050). Enum values are declared only where renderer, registry `inputs` and * designer palette agree. Callbacks (`onSuccess`, `submitHandler`, …) and the * controlled `open` state are React-tier props, not authorable metadata — the * react tier publishes those separately (`react-blocks.ts`). @@ -6943,8 +6989,16 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ .describe('Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead'), recordId: z.union([z.string(), z.number()]).optional().describe('Record to load (edit/view modes)'), mode: z.enum(['create', 'edit', 'view']).optional().describe('Form mode'), - formType: z.enum(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']).optional() - .describe('Form presentation'), + /** + * [#27159] The form's layout, or the container the BLOCK opens it in. The + * two dimensions share one key here, unlike the form view (whose `type` is + * layout only since #27155), because a page has no other door to a + * built-in overlay form: the block renderer supplies `drawer` / `modal` + * itself, and `ObjectForm` draws the layout inside. `split` retired — + * {@link OBJECT_FORM_TYPE_RETIRED}. + */ + formType: enumWithRetiredValues(['simple', 'tabbed', 'wizard', 'drawer', 'modal'], OBJECT_FORM_TYPE_RETIRED).optional() + .describe("The form's layout — 'simple', 'tabbed' or 'wizard' — or the container the block opens it in — 'drawer' or 'modal', which draw the form stacked ('contentLayout: tabbed' puts a modal's sections on tabs)"), layout: z.enum(['vertical', 'horizontal'], { error: (issue) => typeof issue.input === 'string' ? OBJECT_FORM_LAYOUT_RETIRED.get(issue.input) : undefined, @@ -6984,20 +7038,23 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ * view's `FormSectionSchema` is unchanged. */ sections: z.array(objectFormSection()).optional() - .describe('Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }`, or `{ group, columns?, pane? }` to inherit an object field group. Each `fields` entry is a field name, the form view\'s `{ field, … }` entry, or an inline form field `{ name, type, … }`'), + .describe('Form sections — wizard steps, tab panes or stacked groups: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, fields }`, or `{ group, columns? }` to inherit an object field group. Each `fields` entry is a field name, the form view\'s `{ field, … }` entry, or an inline form field `{ name, type, … }`'), title: I18nLabelSchema.optional().describe('Form title'), description: I18nLabelSchema.optional().describe('Form description (rendered by the drawer/modal presentations)'), defaultTab: z.string().optional().describe('Initially active tab (tabbed)'), tabPosition: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Tab strip position (tabbed)'), allowSkip: z.boolean().optional().describe('Allow skipping steps (wizard)'), showStepIndicator: z.boolean().optional().describe('Show the step indicator (wizard)'), - splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split direction (split)'), - splitSize: z.number().optional().describe('Split panel size in percent (split)'), - splitResizable: z.boolean().optional().describe('Allow resizing the split (split)'), - drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer side (drawer)'), - drawerWidth: z.union([z.string(), z.number()]).optional().describe('Drawer width (drawer)'), - modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal size (modal)'), - modalCloseButton: z.boolean().optional().describe('Show the modal close button (modal)'), + /** [#27159] RETIRED with `formType: 'split'` — {@link OBJECT_FORM_SPLIT_DIRECTION_RETIRED}. */ + splitDirection: retiredKey(OBJECT_FORM_SPLIT_DIRECTION_RETIRED), + /** [#27159] RETIRED with `formType: 'split'` — {@link OBJECT_FORM_SPLIT_SIZE_RETIRED}. */ + splitSize: retiredKey(OBJECT_FORM_SPLIT_SIZE_RETIRED), + /** [#27159] RETIRED with `formType: 'split'` — {@link OBJECT_FORM_SPLIT_RESIZABLE_RETIRED}. */ + splitResizable: retiredKey(OBJECT_FORM_SPLIT_RESIZABLE_RETIRED), + drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe("Edge the block's drawer container opens from (formType 'drawer')"), + drawerWidth: z.union([z.string(), z.number()]).optional().describe("Width of the block's drawer container (formType 'drawer')"), + modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe("Size bucket of the block's modal container (formType 'modal')"), + modalCloseButton: z.boolean().optional().describe("Show the close button of the block's modal container (formType 'modal')"), /** * [#21464] Typed to the read: the modal presentation is its only reader, * and it tests `schema.contentLayout === 'tabbed' && groups.length > 1` @@ -7088,8 +7145,8 @@ const MASTER_DETAIL_FORM_TYPE_RETIRED: ReadonlyMap = new Map([ ['split', "'split' is not part of `object-master-detail-form` `formType` — the renderer " + 'was measured on both repos: the parent half renders inline but persists via `dataSource.create`, ' + "bypassing the atomic parent+details batch (ADR-0001, the block's whole contract). Write " - + "'simple' or 'tabbed'; for a split presentation without inline details author an " - + "`object-form`, where 'split' is honoured."], + + "'simple' or 'tabbed'; the split presentation itself was removed from `object-form` in " + + '@objectstack/spec 18.3.0 (ADR-0050).'], ['drawer', "'drawer' is not part of `object-master-detail-form` `formType` — the renderer " + 'was measured on both repos: the parent half renders in a portal dialog outside the master-detail ' + "container, so the block's Save bar has no form to submit. Write 'simple' or 'tabbed'; for a " @@ -7262,7 +7319,7 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ * one instance {@link buildObjectFormSection} builds (S-forms stage). */ sections: z.array(objectFormSection()).optional() - .describe('Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields }` or `{ group, columns?, pane? }`'), + .describe('Parent form sections — the same section shape `object-form` takes: `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, fields }` or `{ group, columns? }`'), fields: formFieldNameList().optional() .describe('Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused'), details: z.array(masterDetailDetailEntry()).optional() 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 abd8d91b922..c41150d5a7b 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 @@ -14,15 +14,21 @@ * schema, a container's `form` / `formViews.*`, a ViewItem form record, a * flattened form overlay, and `defineStack`. * 2. The split-only keys `sections[].pane` (and the legacy `groups[].pane`), - * `splitSize` and `splitResizable` are `retiredKey()` tombstones, refused - * on every layout. `splitDirection`, `drawerSide`, `drawerWidth` and - * `modalSize` stay declared — a stated boundary, pinned as one. - * 3. One D2 conversion, retired from the load path: + * `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. + * 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` + * and `sections[].pane` are tombstones. + * 4. One D2 conversion, retired from the load path: * `form-view-presentation-types-removed` deletes a retired member (the - * form lands on `simple`) and strips the three keys from stored rows and - * existing sources; its D3 entry is `ui-form-view-type-layout-only`. - * 4. Tree-scoped absence: no form view inside the declared radius still - * authors a retired member. + * form lands on `simple`) and strips the split keys from stored rows and + * existing sources, form views and `object-form` blocks alike; its D3 + * entry is `ui-form-view-type-layout-only`. + * 5. Tree-scoped absence: no form view inside the declared radius still + * authors a retired member, and nothing authors a `formType: 'split'`. * * 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 @@ -45,6 +51,7 @@ 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, ObjectMasterDetailFormPropsSchema } from './component.zod'; import { FormSectionSchema, FormViewSchema, VIEW_METADATA_MEMBERS, ViewItemSchema, ViewSchema } from './view.zod'; const CONVERSION_ID = 'form-view-presentation-types-removed'; @@ -76,6 +83,9 @@ const KEY_FIRST_SENTENCE = { splitSize: "`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.", + splitDirection: + "`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.', splitResizable: "`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 " @@ -175,7 +185,7 @@ describe('FormView.type — `simple | tabbed | wizard`, default `simple`', () => }); }); -describe('the split-only keys — `pane`, `splitSize`, `splitResizable` — are tombstones on every layout', () => { +describe('the split-only keys — `pane`, `splitSize`, `splitResizable`, `splitDirection` — are tombstones on every layout', () => { it.each(LIVE_TYPES)('`pane` is refused with its prescription on a %s form, in `sections` and in the legacy `groups`', (type) => { for (const bucket of ['sections', 'groups'] as const) { const r = FormViewSchema.safeParse({ type, [bucket]: [{ label: 'Main', pane: 'primary', fields: ['name'] }] }) as Parse; @@ -196,8 +206,8 @@ describe('the split-only keys — `pane`, `splitSize`, `splitResizable` — are } }); - it.each(['splitSize', 'splitResizable'] as const)('`%s` is refused with its prescription', (key) => { - const value = key === 'splitSize' ? 40 : true; + it.each(['splitSize', 'splitResizable', 'splitDirection'] as const)('`%s` is refused with its prescription', (key) => { + const value = { splitSize: 40, splitResizable: true, splitDirection: 'vertical' }[key]; for (const type of LIVE_TYPES) { const r = FormViewSchema.safeParse({ type, sections: SECTIONS, [key]: value }) as Parse; expect(r.success, type).toBe(false); @@ -207,23 +217,23 @@ describe('the split-only keys — `pane`, `splitSize`, `splitResizable` — are } }); - it('the three keys are registered in RETIRED_KEYS_BY_MAJOR[18] under their exact def keys', () => { + it('the four keys are registered in RETIRED_KEYS_BY_MAJOR[18] under their exact def keys', () => { expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining([ 'ui/FormSection:pane', 'ui/FormView:splitSize', 'ui/FormView:splitResizable', + 'ui/FormView:splitDirection', ])); }); - it('STATED BOUNDARY: `splitDirection`, `drawerSide`, `drawerWidth` and `modalSize` stay declared', () => { + it('STATED BOUNDARY: `drawerSide`, `drawerWidth` and `modalSize` stay declared', () => { // `modalSize` is read by the console's record dialog on every layout; the - // other three feed the React `ObjectForm` block contract (`react-blocks.ts`), - // whose `formType='split' | 'drawer'` still reads them. They move with the - // renderer card, never silently here. + // 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). const r = FormViewSchema.safeParse({ type: 'simple', sections: SECTIONS, - splitDirection: 'vertical', drawerSide: 'left', drawerWidth: '480px', modalSize: 'lg', @@ -247,6 +257,152 @@ 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; +/** The `object-form` block's prescriptions (#27159) → their first sentences. */ +const BLOCK_SPLIT_FIRST_SENTENCE = + "'split' was removed from the `object-form` `formType` enum in @objectstack/spec 18.3.0 (ADR-0050) — the split " + + 'form renderer retired with it, so nothing draws two panes:'; +const blockKeyFirstSentence = (key: string, what: string) => + `\`object-form\` property \`${key}\` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it ${what} of a ` + + "`formType: 'split'` form, and `split` left the block in the same release, so no form draws panes."; +const BLOCK_KEYS = { + splitDirection: ['set the pane orientation', 'vertical'], + splitSize: ['sized the primary pane', 40], + splitResizable: ['made the panes resizable', true], +} as const; +const BLOCK = { objectName: 'crm_contact' }; + +describe('the `object-form` block — `split` leaves `formType`, `drawer` / `modal` stay (#27159)', () => { + it('the vocabulary is the three layouts plus the two containers the block opens', () => { + const formType = ObjectFormPropsSchema.shape.formType as unknown as { unwrap: () => { options: readonly string[] } }; + expect(formType.unwrap().options).toEqual(['simple', 'tabbed', 'wizard', 'drawer', 'modal']); + }); + + it("refuses `formType: 'split'` by name, with its prescription", () => { + const r = ObjectFormPropsSchema.safeParse({ ...BLOCK, formType: 'split' }) as Parse; + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toEqual(['formType']); + expect(r.error!.issues[0]!.code).toBe('invalid_value'); + expect(r.error!.issues[0]!.message.startsWith(BLOCK_SPLIT_FIRST_SENTENCE), r.error!.issues[0]!.message).toBe(true); + expect(r.error!.issues[0]!.message).toContain('object-master-detail-form'); + expect(r.error!.issues[0]!.message).toContain(MIGRATE_SENTENCE); + }); + + it('CONTROL: `drawer` and `modal` still parse on the block, with the keys their containers read', () => { + expect(ObjectFormPropsSchema.safeParse({ ...BLOCK, formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' }).success).toBe(true); + expect(ObjectFormPropsSchema.safeParse({ ...BLOCK, formType: 'modal', modalSize: 'lg', modalCloseButton: false }).success).toBe(true); + for (const formType of LIVE_TYPES) { + expect(ObjectFormPropsSchema.safeParse({ ...BLOCK, formType }).success, formType).toBe(true); + } + }); + + it.each(Object.keys(BLOCK_KEYS) as (keyof typeof BLOCK_KEYS)[])('`%s` is a tombstone on the block, whatever its `formType`', (key) => { + const [what, value] = BLOCK_KEYS[key]; + for (const formType of ['simple', 'tabbed', 'drawer', 'modal'] as const) { + const r = ObjectFormPropsSchema.safeParse({ ...BLOCK, formType, [key]: value }) as Parse; + expect(r.success, formType).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toEqual([key]); + expect(r.error!.issues[0]!.message.startsWith(blockKeyFirstSentence(key, what)), r.error!.issues[0]!.message).toBe(true); + expect(r.error!.issues[0]!.message).toContain(MIGRATE_SENTENCE); + } + }); + + // One section shape serves both blocks (`objectFormSection()`), so the + // tombstone and its prescription are the same on each. + it.each([ + ['object-form', ObjectFormPropsSchema, { ...BLOCK, formType: 'tabbed' }], + ['object-master-detail-form', ObjectMasterDetailFormPropsSchema, { ...BLOCK, formType: 'tabbed' }], + ] as const)('a section `pane` is a tombstone on the `%s` block', (_block, schema, base) => { + const r = schema.safeParse({ ...base, sections: [{ label: 'Main', pane: 'primary', fields: ['name'] }] }) as Parse; + expect(r.success).toBe(false); + const issues = r.error!.issues.filter((i) => i.path.join('.') === 'sections.0.pane'); + expect(issues).toHaveLength(1); + expect(issues[0]!.message.startsWith( + 'Section property `pane` (on `object-form` and `object-master-detail-form` `sections[]`) was removed in ' + + "@objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one pane of an `object-form` `formType: 'split'` form", + ), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + // CONTROL: the same section without `pane` parses on this block. + expect(schema.safeParse({ ...base, sections: [{ label: 'Main', fields: ['name'] }] }).success).toBe(true); + }); + + it('the three block keys are registered in RETIRED_KEYS_BY_MAJOR[18]', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining([ + 'ui/ObjectFormProps:splitDirection', + 'ui/ObjectFormProps:splitSize', + 'ui/ObjectFormProps:splitResizable', + ])); + }); + + it('fails tsc at the authoring site: `split` and the split keys are not in the block input type', () => { + const props: z.input = { + ...BLOCK, + // @ts-expect-error — `split` left the block's `formType`. + formType: 'split', + // @ts-expect-error — `splitSize` is a `retiredKey()` tombstone, typed `never`. + splitSize: 40, + }; + expect(ObjectFormPropsSchema.safeParse(props).success).toBe(false); + }); + + it('a STORED page converts its `object-form` blocks: `split` and the split keys go, `drawer` stays', () => { + const row = { + name: 'desk', + regions: [{ + name: 'main', + components: [ + { type: 'object-form', properties: { ...BLOCK, formType: 'split', splitDirection: 'vertical', sections: [{ label: 'A', pane: 'primary', fields: ['name'] }] } }, + { type: 'object-form', properties: { ...BLOCK, formType: 'drawer', drawerSide: 'left' } }, + ], + }], + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => [n.from, n.path.replace(/^.*?properties/, 'properties')])).toEqual([ + ['split', 'properties.formType'], + ['splitDirection', 'properties.splitDirection'], + ['pane', 'properties.sections[0].pane'], + ]); + expect(out.regions[0]!.components[0]!.properties).toEqual({ ...BLOCK, sections: [{ label: 'A', fields: ['name'] }] }); + expect(out.regions[0]!.components[1]).toBe(row.regions[0]!.components[1]); + expect(ObjectFormPropsSchema.safeParse(out.regions[0]!.components[0]!.properties).success).toBe(true); + }); + + it('a STORED page converts its `object-master-detail-form` blocks: a section `pane` goes, nothing else', () => { + // Through 18.2.0 the shared section shape parsed `pane` on this block too. + const details = [{ childObject: 'crm_contact_note', title: 'Notes' }]; + const row = { + name: 'desk', + regions: [{ + name: 'main', + components: [ + { + type: 'object-master-detail-form', + properties: { ...BLOCK, formType: 'tabbed', sections: [{ label: 'A', pane: 'primary', fields: ['name'] }, { label: 'B', fields: ['email'] }], details }, + }, + { type: 'object-master-detail-form', properties: { ...BLOCK, sections: [{ label: 'C', fields: ['name'] }], details } }, + ], + }], + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => [n.from, n.path.replace(/^.*?properties/, 'properties')])).toEqual([ + ['pane', 'properties.sections[0].pane'], + ]); + expect(out.regions[0]!.components[0]!.properties).toEqual({ + ...BLOCK, + formType: 'tabbed', + sections: [{ label: 'A', fields: ['name'] }, { label: 'B', fields: ['email'] }], + details, + }); + // A block without a `pane` keeps its identity. + expect(out.regions[0]!.components[1]).toBe(row.regions[0]!.components[1]); + expect(ObjectMasterDetailFormPropsSchema.safeParse(out.regions[0]!.components[0]!.properties).success).toBe(true); + // Before the conversion the stored block is refused at the pane. + expect(ObjectMasterDetailFormPropsSchema.safeParse(row.regions[0]!.components[0]!.properties).success).toBe(false); + }); +}); + + describe(`the D2 conversion ${CONVERSION_ID}`, () => { it('a STORED view container replays to a body the strict door accepts, landing on `simple`', () => { const row = { @@ -266,6 +422,7 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { 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.beside.type', from: 'split', to: '(removed)' }, + { conversionId: CONVERSION_ID, path: 'views[0].formViews.beside.splitDirection', from: 'splitDirection', to: '(removed)' }, { conversionId: CONVERSION_ID, path: 'views[0].formViews.beside.splitSize', from: 'splitSize', to: '(removed)' }, { conversionId: CONVERSION_ID, path: 'views[0].formViews.beside.sections[0].pane', from: 'pane', to: '(removed)' }, { conversionId: CONVERSION_ID, path: 'views[0].formViews.beside.sections[1].pane', from: 'pane', to: '(removed)' }, @@ -273,7 +430,7 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { ]); const parsed = ViewSchema.parse(out); expect(parsed.formViews?.beside?.type).toBe('simple'); - expect(parsed.formViews?.beside?.splitDirection).toBe('vertical'); + expect(parsed.formViews?.beside).not.toHaveProperty('splitDirection'); expect(parsed.formViews?.quick?.type).toBe('simple'); expect(parsed.formViews?.quick?.drawerSide).toBe('left'); // A live layout is the author's and is never touched. @@ -335,13 +492,15 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { // own `src/` trees. It judges the AUTHORING SHAPE of a FORM VIEW: a `form` // object literal whose own braces hold a retired `type`, a `formViews` entry // whose own braces do, and an object literal naming `viewKind: 'form'` beside -// one. The bound, stated: `type: 'modal'` on its own is an ACTION type and -// `formType` is the React / page-block prop (#27159), so neither is judged -// bare; `pane`, `splitSize` and `splitResizable` are not judged either — the -// `object-form` page block still declares all three, so a bare key is not a -// form view's. A form view assembled by spread or a computed key, YAML, and +// one, and — since #27159 — any `formType: 'split'`: `split` left the +// `object-form` block and the `object-master-detail-form` block alike, so no +// `formType` anywhere admits it. The bound, stated: `type: 'modal'` on its own +// is an ACTION type, and `formType: 'drawer' | 'modal'` is a live `object-form` +// container, so neither is judged bare; the bare keys `pane`, `splitDirection`, +// `splitSize` and `splitResizable` are not judged either — a key name alone is +// too generic to read as a form's, and each tombstone refuses it at parse. A form view 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 a retired form view type', () => { +describe('tree-scoped absence: nothing inside the declared radius still authors a retired form view type or `formType: \'split\'`', () => { 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('/'); @@ -353,6 +512,8 @@ describe('tree-scoped absence: nothing inside the declared radius still authors const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); const RETIRED = String.raw`["']?type["']?\s*:\s*["'](?:split|drawer|modal)["']`; + /** A `formType` naming the retired `split` — no block admits it (#27159). */ + const FORM_TYPE_SPLIT = /["']?\bformType["']?\s*:\s*["']split["']/; /** A `form` slot whose own braces hold a retired `type`. */ const FORM_SLOT = new RegExp(String.raw`["']?\bform["']?\s*:\s*\{[^{}]*?` + RETIRED); /** An object literal naming `viewKind: 'form'` with a retired `type` in the same braces. */ @@ -388,7 +549,7 @@ describe('tree-scoped absence: nothing inside the declared radius still authors const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); const judge = (raw: string): boolean => { const text = stripInlineCode(raw); - return FORM_SLOT.test(text) || FORM_ITEM.some((re) => re.test(text)) || formViewsEntries(text); + return FORM_SLOT.test(text) || FORM_TYPE_SPLIT.test(text) || FORM_ITEM.some((re) => re.test(text)) || formViewsEntries(text); }; /** @@ -426,6 +587,8 @@ describe('tree-scoped absence: nothing inside the declared radius still authors expect(judge('{ "name": "crm_lead.peek", "viewKind": "form", "type": "modal" }')).toBe(true); expect(judge("{ type: 'split', object: 'crm_lead', viewKind: 'form' }")).toBe(true); expect(judge("Prose.\n\n```ts\nform: { type: 'split' }\n```\n")).toBe(true); + expect(judge("properties: { formType: 'split', objectName: 'crm_lead' }")).toBe(true); + expect(judge('{ "type": "object-form", "properties": { "formType": "split" } }')).toBe(true); // Neighbours that must NOT match. expect(judge("form: { type: 'tabbed', sections: [] },")).toBe(false); expect(judge("{ name: 'open_detail', type: 'modal', target: 'detail_page' }")).toBe(false); @@ -433,6 +596,7 @@ describe('tree-scoped absence: nothing inside the declared radius still authors expect(judge("formViews: { quick: { type: 'simple', sections: [{ fields: [{ field: 'a', type: 'modal' }] }] } }")).toBe(false); expect(judge("{ viewKind: 'list', type: 'split' }")).toBe(false); expect(judge("a form once wrote `form: { type: 'modal' }`.")).toBe(false); + expect(judge("a block once wrote `formType: 'split'`.")).toBe(false); }); it('no form view authoring a retired member survives inside the declared radius', () => { diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 5c517e5b15d..f359bad97c4 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -238,11 +238,12 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ schemaType: 'object-form', 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, - dataProps: ['layout', 'columns', 'tabPosition', 'drawerSide', 'drawerWidth', 'modalSize', 'splitDirection', 'sections', 'subforms', 'submitBehavior'], + // [#27159] `splitDirection` left with the split form renderer (ADR-0050). + dataProps: ['layout', 'columns', 'tabPosition', 'drawerSide', 'drawerWidth', '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.' }, - { name: 'formType', type: "'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'", kind: 'binding', description: 'Form presentation; drawer/modal render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize).' }, + { name: 'formType', type: "'simple' | 'tabbed' | 'wizard' | 'drawer' | 'modal'", kind: 'binding', description: "The form's layout (simple/tabbed/wizard), or the built-in container the block opens it in: drawer/modal (use drawerSide/drawerWidth/modalSize; contentLayout 'tabbed' puts a modal's sections on tabs)." }, { name: 'recordId', type: 'string | number', kind: 'controlled', description: 'Which record to load (edit/view). The hook for master/detail.' }, { name: 'fields', type: 'string[]', kind: 'binding', description: 'Limit/order the fields shown (defaults to the object form fields).' }, { name: 'initialValues', type: 'Record', kind: 'binding', description: 'Prefill values in create mode.' }, @@ -262,7 +263,7 @@ 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.' }, - { name: 'modalCloseButton', type: 'boolean', kind: 'binding', description: "Show the modal presentation's close button (formType 'modal'; honoured by the modal form)." }, + { 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.' }, { name: 'submitText', type: 'string', kind: 'binding', description: 'Label of the submit button.' }, diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index 0f89c05ae54..e7791b6c7cf 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -380,10 +380,10 @@ 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. - // `splitDirection` is a FormView key nobody would think to allow-list by + // `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({ splitDirection: 'vertical', object: 'crm_lead', viewKind: 'form' }).success).toBe(true); + expect(ViewMetadataSchema.safeParse({ drawerSide: 'left', 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 b439764d14b..0703cc38c85 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4474,7 +4474,7 @@ const FORM_VIEW_TYPE_RETIRED = { + 'the form\'s layout, and a side-by-side split is not one: whether a record opens beside its list is ' + 'each reader\'s own preference in the list\'s Display menu, not metadata. Write `type: \'tabbed\'` (one ' + 'tab per section) or delete the value for the `simple` default, and delete each section\'s `pane` and ' - + 'the form\'s `splitSize` / `splitResizable`; to edit child records beside the parent, declare ' + + 'the form\'s `splitDirection` / `splitSize` / `splitResizable`; to edit child records beside the parent, declare ' + '`subforms`. ' + FORM_VIEW_TYPE_RETIRED_FIX, drawer: @@ -4494,16 +4494,22 @@ const FORM_VIEW_TYPE_RETIRED = { } as const; // [#27155] The split-only parameter keys, retired with `type: 'split'`. Each -// was read only by the split form's renderer, behind that type; `splitDirection` -// stays for now because the React `ObjectForm` block (`react-blocks.ts`) takes -// its contract from this schema and that tier still renders a split form — -// the renderer card that retires `SplitForm` (#27159) takes it. +// was read only by the split form's renderer, behind that type. [#27159] +// `splitDirection` followed once `SplitForm` retired: it had stayed only +// because the React `ObjectForm` block (`react-blocks.ts`) takes its contract +// from this schema, and that tier no longer draws a split form either. const FORM_VIEW_SPLIT_SIZE_RETIRED = '`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. ' + FORM_VIEW_TYPE_RETIRED_FIX; +const FORM_VIEW_SPLIT_DIRECTION_RETIRED = + '`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. ' + + FORM_VIEW_TYPE_RETIRED_FIX; + const FORM_VIEW_SPLIT_RESIZABLE_RETIRED = '`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 ' @@ -4667,32 +4673,29 @@ export const FormViewSchema = lazySchema(() => strictObject({ + '(completed/current/upcoming/invalid) is derived from the step gate — this boolean is the ' + 'only authorable progress surface.', ), - /** - * Split orientation. [#27155] Inert on a form view since `type: 'split'` was - * retired; it stays declared only because the React `ObjectForm` block takes - * its contract from this schema (`react-blocks.ts`) and that tier still draws - * a split form. It leaves with `SplitForm`, on the renderer card #27159. - */ - splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split orientation (split forms)'), + /** [#27159] RETIRED with the split form renderer — {@link FORM_VIEW_SPLIT_DIRECTION_RETIRED}. */ + splitDirection: retiredKey(FORM_VIEW_SPLIT_DIRECTION_RETIRED), /** [#27155] RETIRED with `type: 'split'` — {@link FORM_VIEW_SPLIT_SIZE_RETIRED}. */ splitSize: retiredKey(FORM_VIEW_SPLIT_SIZE_RETIRED), /** [#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; both stay declared for the same React `ObjectForm` reason as - * `splitDirection` (a React page's `formType='drawer'` reads them), and the - * drawer half of their enforce-or-remove question stays open on #27153. + * 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. */ - drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer side (drawer forms)'), + 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] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.'), + 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.'), /** * 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 * selects, whatever the form's `type` ([#27155] it outlived `type: 'modal'`). */ - modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal size (modal forms)'), + modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe("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 Source Configuration. NOT removed in the #3896 sweep despite the diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 840bca1d7a5..7fe1a7b312b 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -14,7 +14,7 @@ "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-10-29", - "sha256": "776bef14f20193239ad50cb76769e3dc366456005e907593041ebb5266c9f444", + "sha256": "d55576794739e7893f7ffdfeadfe8a0ebdd480469e95f431dc7bc80565f4e229", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index cff4842077a..fdd2963a716 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -477,10 +477,10 @@ "simple", "tabbed", "wizard", - "split", "drawer", "modal" - ] + ], + "description": "The form layout (`simple`, `tabbed`, `wizard`), or the container the block opens it in (`drawer`, `modal`, drawn as a stacked form; `contentLayout: \"tabbed\"` puts a modal's sections on tabs). `split` was retired with ADR-0050: use `tabbed` (one tab per section), or an `object-master-detail-form` block to edit child records beside the parent." }, { "name": "sections", @@ -537,22 +537,6 @@ "name": "showStepIndicator", "type": "boolean" }, - { - "name": "splitDirection", - "type": "enum", - "enum": [ - "horizontal", - "vertical" - ] - }, - { - "name": "splitSize", - "type": "number" - }, - { - "name": "splitResizable", - "type": "boolean" - }, { "name": "drawerSide", "type": "enum", @@ -581,7 +565,7 @@ { "name": "modalCloseButton", "type": "boolean", - "description": "Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by `ObjectForm`’s modal route and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown." + "description": "Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by the block’s modal container and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown." }, { "name": "contentLayout", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 4d5777847ba..5e5d0513044 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -18,12 +18,12 @@ Server-connected create/edit/view form for one object. Config props come from th | prop | type | kind | required | description | |------|------|------|:--------:|-------------| | `objectName` | `string` | binding | ✓ | The object this block binds to (server-connected). | -| `formType` | `'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'` | binding | | Form presentation; drawer/modal render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize). | +| `formType` | `'simple' \| 'tabbed' \| 'wizard' \| 'drawer' \| 'modal'` | binding | | The form's layout (simple/tabbed/wizard), or the built-in container the block opens it in: drawer/modal (use drawerSide/drawerWidth/modalSize; contentLayout 'tabbed' puts a modal's sections on tabs). | | `fields` | `string[]` | binding | | Limit/order the fields shown (defaults to the object form fields). | | `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. | -| `modalCloseButton` | `boolean` | binding | | Show the modal presentation's close button (formType 'modal'; honoured by the modal form). | +| `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. | | `submitText` | `string` | binding | | Label of the submit button. | @@ -44,10 +44,9 @@ 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 | | Drawer side (drawer forms) | -| `drawerWidth` | `string` | data | | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | -| `modalSize` | `'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'` | data | | Modal size (modal forms) | -| `splitDirection` | `'horizontal' \| 'vertical'` | data | | Split orientation (split 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 | | `submitBehavior` | `object` | data | | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | diff --git a/ui/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx b/ui/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx index 0550077c2bd..ffc1a498d17 100644 --- a/ui/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx +++ b/ui/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx @@ -38,8 +38,8 @@ * is working. `object-kanban` has two such rows: inline `data`, and lanes * that carry their own `cards`. `object-form` and `view:form` have two * kinds: inline `customFields`, and, for each sectioned `formType` - * (`tabbed`, `wizard`, `split`, `drawer`, `modal`), sections whose every - * field is inline. + * (`tabbed`, `wizard`, `drawer`, `modal`), sections whose every field is + * inline. `split` left the block's `formType` with ADR-0050 (#27159). */ import { describe, it, expect } from 'vitest'; @@ -175,8 +175,12 @@ async function mount(schema: Record) { */ const INLINE_SECTIONS = [{ label: 'Contact', fields: [{ name: 'email', label: 'Email address', type: 'text' }] }]; -/** The `formType`s `ObjectForm` dispatches to a sectioned renderer of its own. */ -const SECTIONED_VARIANTS = ['tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; +/** + * The `formType`s the block hands to a sectioned renderer of its own: the two + * layouts `ObjectForm` draws, and the two containers `ObjectFormContainer` + * opens. `split` is gone with its renderer (#27159). + */ +const SECTIONED_VARIANTS = ['tabbed', 'wizard', 'drawer', 'modal'] as const; const SECTIONED_ROWS = (['object-form', 'view:form'] as const).flatMap((type) => SECTIONED_VARIANTS.map((formType) => ({ type, formType })), diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index ffa956c8fa9..5700cfe1583 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -256,8 +256,12 @@ interface ObjectFieldDef { requiredWhen?: FieldRulePredicate; } -/** Visualization types the form renderer understands (FormViewSpec.type). */ -const FORM_SPEC_TYPES = new Set(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']); +/** + * The form layouts a form view declares (FormViewSpec.type) — `simple`, + * `tabbed` and `wizard` since ADR-0050 (#27155 / #27159): `split`, `drawer` + * and `modal` left the enum, and the spec refuses each at parse. + */ +const FORM_SPEC_TYPES = new Set(['simple', 'tabbed', 'wizard']); /** * The form CONFIG plus the view identity that travels beside it (objectui#5596). diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index 8d53e9aec6a..2c7e7d333e6 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -313,16 +313,16 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { } // Honour the object's form view layout (objectstack-ai/objectstack#1890): wire the declared `type` - // (simple/tabbed/wizard/split) + `sections` through to ObjectForm, which - // already renders each variant. Page-level layouts only — `drawer`/`modal` - // are presentation/open-modes, not record-page layouts, so they fall back to - // `simple` here (see the form-layout-vs-presentation modelling note in objectstack-ai/objectstack#1890). + // (simple/tabbed/wizard) + `sections` through to ObjectForm, which renders + // each layout. Those three are the form view `type`'s whole vocabulary + // (ADR-0050); anything else — a stored row not yet converted — falls back + // to `simple` here. // The form view is the one the record flow selects (objectui#13099): a // create uses the object's `create` form view when it declares one, an edit // the default form view — the same selection the New/Edit modal makes. const formDef: any = selectRecordFormView(objectDef as any, mode) ?? {}; - const pageFormType: 'simple' | 'tabbed' | 'wizard' | 'split' = - ['tabbed', 'wizard', 'split'].includes(formDef.type) ? formDef.type : 'simple'; + const pageFormType: 'simple' | 'tabbed' | 'wizard' = + ['tabbed', 'wizard'].includes(formDef.type) ? formDef.type : 'simple'; // Curated `sections` are wired through for EVERY layout family — a `simple` // form view's sections still carry the authored field selection, order, // grouping and per-field `visibleOn` predicates (#2212). Dropping them for @@ -336,9 +336,6 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { tabPosition: formDef.tabPosition, allowSkip: formDef.allowSkip, showStepIndicator: formDef.showStepIndicator, - splitDirection: formDef.splitDirection, - splitSize: formDef.splitSize, - splitResizable: formDef.splitResizable, } : {}; // objectstack-ai/objectstack#26843 — the form view's `buttons`, `layout` diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.test.tsx index d9136519335..29e6d00f770 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.test.tsx @@ -51,7 +51,7 @@ describe('toCanvasSchema — overlay form coercion', () => { }); it('leaves inline form types untouched', () => { - for (const ft of ['simple', 'tabbed', 'wizard', 'split']) { + for (const ft of ['simple', 'tabbed', 'wizard']) { const schema = toCanvasSchema({ type: 'object-form', properties: { formType: ft } }) as any; expect(schema.properties.formType).toBe(ft); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index b0d83b8494b..dbfd4d7f23a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -190,7 +190,6 @@ export const BLOCK_CONFIG: Record = { { value: 'simple', label: 'engine.inspector.pageBlock.option.formType.simple' }, { value: 'tabbed', label: 'engine.inspector.pageBlock.option.formType.tabbed' }, { value: 'wizard', label: 'engine.inspector.pageBlock.option.formType.wizard' }, - { value: 'split', label: 'engine.inspector.pageBlock.option.formType.split' }, { value: 'drawer', label: 'engine.inspector.pageBlock.option.formType.drawer' }, { value: 'modal', label: 'engine.inspector.pageBlock.option.formType.modal' }, ], diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.test.ts index cfdc522e468..c3126759deb 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.test.ts @@ -13,7 +13,7 @@ describe('form-preview — overlay/inline classifier', () => { it('isOverlayFormType flags only drawer/modal', () => { expect(isOverlayFormType('drawer')).toBe(true); expect(isOverlayFormType('modal')).toBe(true); - for (const t of ['simple', 'tabbed', 'wizard', 'split']) { + for (const t of ['simple', 'tabbed', 'wizard']) { expect(isOverlayFormType(t)).toBe(false); } expect(isOverlayFormType(undefined)).toBe(false); @@ -25,7 +25,9 @@ describe('form-preview — overlay/inline classifier', () => { expect(toInlineFormType('modal')).toBe('simple'); expect(toInlineFormType('wizard')).toBe('wizard'); expect(toInlineFormType('tabbed')).toBe('tabbed'); - expect(toInlineFormType('split')).toBe('split'); + // `split` left the `object-form` block (ADR-0050, #27159): a stored block + // not yet converted previews as the default layout. + expect(toInlineFormType('split')).toBe('simple'); expect(toInlineFormType('simple')).toBe('simple'); expect(toInlineFormType(undefined)).toBe('simple'); expect(toInlineFormType('nonsense')).toBe('simple'); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.ts index 08d19aae01c..207a34b832e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/form-preview.ts @@ -17,7 +17,7 @@ */ /** Form types that render inline (no portalled overlay). */ -export const INLINE_FORM_TYPES = new Set(['simple', 'tabbed', 'wizard', 'split']); +export const INLINE_FORM_TYPES = new Set(['simple', 'tabbed', 'wizard']); /** Form types that render as a portalled, page-locking overlay. */ export const OVERLAY_FORM_TYPES = new Set(['drawer', 'modal']); diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 459dc3ada24..d299ff61b4f 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -762,7 +762,6 @@ const STUDIO_EN: Record = { 'engine.inspector.pageBlock.option.formType.simple': 'Simple', 'engine.inspector.pageBlock.option.formType.tabbed': 'Tabbed', 'engine.inspector.pageBlock.option.formType.wizard': 'Wizard', - 'engine.inspector.pageBlock.option.formType.split': 'Split', 'engine.inspector.pageBlock.option.formType.drawer': 'Drawer', 'engine.inspector.pageBlock.option.formType.modal': 'Modal', 'engine.inspector.pageBlock.field.object-form.layout': 'Layout', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 1970b893645..558f5535d97 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -648,7 +648,6 @@ const STUDIO_ZH: Record = { 'engine.inspector.pageBlock.option.formType.simple': '简单', 'engine.inspector.pageBlock.option.formType.tabbed': '标签页', 'engine.inspector.pageBlock.option.formType.wizard': '向导', - 'engine.inspector.pageBlock.option.formType.split': '分栏', 'engine.inspector.pageBlock.option.formType.drawer': '抽屉', 'engine.inspector.pageBlock.option.formType.modal': '弹窗', 'engine.inspector.pageBlock.field.object-form.layout': '布局', diff --git a/ui/packages/plugin-form/README.md b/ui/packages/plugin-form/README.md index b34a862d1d0..e4178e88a66 100644 --- a/ui/packages/plugin-form/README.md +++ b/ui/packages/plugin-form/README.md @@ -44,7 +44,7 @@ are the schema types those calls claim, read off the calls themselves in | Namespaced type | Bare-name fallback | Component behind it | |---|---|---| -| `plugin-form:object-form` | `object-form` | `ObjectForm` — metadata-driven form over one record | +| `plugin-form:object-form` | `object-form` | `ObjectForm` — metadata-driven form over one record; with `formType: 'drawer'` or `'modal'` the block wraps it in that container (see [Where an `object-form` block opens](#where-an-object-form-block-opens)) | | `view:form` | none | the same renderer under the view protocol | | `plugin-form:embeddable-form` | `embeddable-form` | `EmbeddableForm` — standalone public form | | `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent + child line items in one submit | @@ -76,7 +76,6 @@ import { ObjectForm, TabbedForm, WizardForm, - SplitForm, DrawerForm, ModalForm, EmbeddableForm, @@ -115,8 +114,6 @@ import type { FormSectionConfig, WizardFormProps, WizardFormSchema, - SplitFormProps, - SplitFormSchema, DrawerFormProps, DrawerFormSchema, ModalFormProps, @@ -299,8 +296,7 @@ the form looks validated while validating nothing. A create form has no persisted record, so its opening values come from the object schema's declared defaults: a field's `defaultValue`, or the option its option list marks `default: true`. Every object-form container -(`ObjectForm`, `ModalForm`, `DrawerForm`, `TabbedForm`, `SplitForm`, -`WizardForm`) resolves them the same way, through `schemaDefaults`: +(`ObjectForm`, `ModalForm`, `DrawerForm`, `TabbedForm`, `WizardForm`) resolves them the same way, through `schemaDefaults`: | Declared | Create form opens with | Why | |---|---|---| @@ -529,8 +525,8 @@ A sectioned form renders as ONE grid, and two keys decide its shape: The view's `columns` wins; without it the grid takes the widest section's density, and without either it is single-column. Every sectioned host resolves it -the same way — `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm`, -`WizardForm` — so one piece of metadata lays out identically wherever it is +the same way — `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `WizardForm` — +so one piece of metadata lays out identically wherever it is rendered. (`WizardForm` has no widest-section fallback: its steps never share a viewport, so each step keeps its own authored width.) @@ -740,12 +736,23 @@ divider. - Fields no pane claims render above the panel group rather than disappearing. - Needs at least two panes; ignored when the form uses `children` or `fieldTabs`. -`SplitForm` is built on this. Each section declares its panel via `pane: -'primary' | 'secondary'` (spec `FormSection.pane`) — explicit per-section -placement, so reordering sections never silently moves them across the divider. -When omitted, the legacy positional rule applies: the first section becomes the -primary pane, the rest stack in the secondary one behind inline section headers. -(The spec rejects `pane` on non-split form types at parse.) +No `object-form` layout draws panes any more: `formType: 'split'`, the +`SplitForm` renderer built on this, and the section `pane` key were removed +together (ADR-0050, objectstack#27159). `fieldPanes` stays a `form` key for a +host that composes panes itself. + +### Where an `object-form` block opens + +An `object-form`'s `formType` is the form's layout — `simple` (the default), +`tabbed` or `wizard` — or, on the block, the container the block opens it in: +`drawer` or `modal` (ADR-0050 Decision 4). `ObjectForm` only lays the form out; +the block renderer is the caller that supplies the container, so a `drawer` or +`modal` block mounts `ObjectFormContainer`, which hands the node to +`DrawerForm` or `ModalForm` with its `drawerSide` / `drawerWidth` or +`modalSize` / `modalCloseButton`. A host that renders `` directly +gets the inline form whatever its `formType` says, and opens its own drawer or +dialog around it — the console's New and Edit dialogs mount `ModalForm` +themselves. ## Examples @@ -958,7 +965,7 @@ with no adapter to fetch it and no host seam to hand the values to — **refuses the submit with `DataSource is required for form submission (inline mode not configured)`. The error reaches `schema.onError` and is rethrown. -This is uniform across all six renderers (`simple`, `tabbed`, `wizard`, `split`, +This is uniform across all five renderers (`simple`, `tabbed`, `wizard`, `drawer`, `modal`). It used to be uniform in the other direction: the five variant renderers answered an adapter-less submit by calling `onSuccess` and returning, which reported a save that never happened and, through @@ -971,7 +978,7 @@ said it owns the write is never bypassed for want of an adapter it never needed. An `object-form` in `mode: 'edit'` with a `recordId` reads the record with `dataSource.findOne`, and its save writes **only the fields that differ from that read** (objectui#10156). Every layout does this: the simple form and the -`tabbed`, `wizard`, `split`, `modal` and `drawer` variants (objectui#10563). So +`tabbed`, `wizard`, `modal` and `drawer` variants (objectui#10563). So does a simple form whose mobile `stepper` option shows it one step at a time through the wizard, and the parent operation of a master-detail form, whose header is a simple or a `tabbed` form. A master-detail child row already worked @@ -998,7 +1005,7 @@ server's effective API operations deny (objectui#10612). While that lock holds, every layout shows one notice above the fields that names the object, by its translated label, and the missing permission (`form.noPermissionToCreate` or `form.noPermissionToEdit`, objectui#11000); a field locked on its own shows none. -The submit button of the simple, `tabbed`, `split`, `modal` and `drawer` layouts +The submit button of the simple, `tabbed`, `modal` and `drawer` layouts stays on screen and the lock does not disable it; the server's write guard is what refuses the save. A `wizard` disables Next and its final submit button, and its step indicator does diff --git a/ui/packages/plugin-form/src/FormSection.tsx b/ui/packages/plugin-form/src/FormSection.tsx index 152163aba18..bcc72a538f2 100644 --- a/ui/packages/plugin-form/src/FormSection.tsx +++ b/ui/packages/plugin-form/src/FormSection.tsx @@ -14,10 +14,11 @@ * * Renamed off `FormSection` (objectui#3161, objectstack#4115 ledger batch 7). * `@objectstack/spec/ui` owns `FormSection` for the authored section METADATA — - * `{ name?, label?, description?, collapsible, collapsed, columns, pane?, + * `{ name?, label?, description?, collapsible, collapsed, columns, * visibleWhen?, fields }` — and this repo already uses that word for that * meaning in its own prose: `plugin-form/README.md` and the plugin docs both - * write "spec `FormSection.pane`", `@object-ui/types` re-exports the spec type + * wrote "spec `FormSection.pane`" (retired with the split form, ADR-0050), + * `@object-ui/types` re-exports the spec type * as `SpecFormSection`, and its own authored dialect is `ObjectFormSection`. So * the name was taken twice over, and the file header said this component * "aligns with @objectstack/spec FormSection" — the canonical-claim comment the @@ -28,7 +29,7 @@ * ONE prop, so that renderer restates nothing. This one takes the metadata * FLATTENED into individual props (`label`, `description`, `collapsible`, * `collapsed`, `columns`) — a second vocabulary for the same keys, minus - * `fields` (children instead), minus `name`, minus `pane`, minus `visibleWhen`, + * `fields` (children instead), minus `name`, minus `visibleWhen`, * plus render-only knobs. A declaration like that is exactly what the next * session would read as the authoritative shape of a form section. * diff --git a/ui/packages/plugin-form/src/MasterDetailForm.tsx b/ui/packages/plugin-form/src/MasterDetailForm.tsx index 88308eb4f9b..a26b5fb86dc 100644 --- a/ui/packages/plugin-form/src/MasterDetailForm.tsx +++ b/ui/packages/plugin-form/src/MasterDetailForm.tsx @@ -126,7 +126,7 @@ export interface MasterDetailFormSchema { objectName: string; mode?: 'create' | 'edit'; /** - * `string | number` to match `ObjectFormSchema` and the drawer/modal/split/ + * `string | number` to match `ObjectFormSchema` and the drawer/modal/ * tabbed/wizard envelopes that hand a record straight through to this form — * a numeric primary key is a real backend shape. Narrowed to a string only at * the batch-transaction boundary, whose `BatchTransactionOperation.id` is a diff --git a/ui/packages/plugin-form/src/ObjectForm.i18nLabels.test.tsx b/ui/packages/plugin-form/src/ObjectForm.i18nLabels.test.tsx index af21b44d064..5abb684962c 100644 --- a/ui/packages/plugin-form/src/ObjectForm.i18nLabels.test.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.i18nLabels.test.tsx @@ -202,7 +202,7 @@ describe('object-form — the seven I18nLabel members resolve against the UI lan expect(container.innerHTML).not.toContain('[object Object]'); }); - it.each(['tabbed', 'split'] as const)( + it.each(['tabbed'] as const)( '%s: a locale-map `submitText` labels the Save button', async (formType) => { const { container } = await mount({ formType, sections: STEPS, submitText: SUBMIT_MAP }); diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index e8a26d773f8..ed9f095c294 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -38,9 +38,6 @@ import { import { TabbedForm } from './TabbedForm'; import { WizardForm } from './WizardForm'; import { useFormChromeTranslation } from './formChrome'; -import { SplitForm } from './SplitForm'; -import { DrawerForm } from './DrawerForm'; -import { ModalForm } from './ModalForm'; import { MasterDetailForm } from './MasterDetailForm'; import { applyAutoLayout, @@ -224,10 +221,23 @@ export const ObjectForm: React.FC = ({ schema, ...rest ); -const ObjectFormBody: React.FC = ({ - schema: rawSchema, - dataSource, -}) => { +/** + * The schema every layout reads, prepared ONCE above the layout fork: the + * structured `buttons` / `defaults` folded onto the flat keys, the seven + * `I18nLabel` members resolved, legacy `groups` normalised to `sections`, + * field-level permissions applied and `{ group }` sections resolved — plus the + * section-label translator every section map uses. + * + * A hook of its own because two renderers prepare the same schema: this file's + * `ObjectForm` (the `simple` / `tabbed` / `wizard` layouts) and + * `ObjectFormContainer`, which supplies the drawer or modal container an + * `object-form` block asks for with `formType` (ADR-0050 Decision 4 — the + * container is the caller's, never the form's). + */ +export function usePreparedFormSchema( + rawSchema: ObjectFormComponentProps['schema'], + dataSource: ObjectFormComponentProps['dataSource'], +) { const perms = usePermissions(); // ── `sections[].group` — the ADR-0085 §5 REFERENCE form (objectui#7051) ─── @@ -235,7 +245,7 @@ const ObjectFormBody: React.FC = ({ // A section may point `group` at one of the object's declared `fieldGroups` // instead of enumerating members (`@objectstack/spec` 17.3.0, // objectstack#13855). Resolving it needs the OBJECT definition, and this - // dispatcher — unlike the six bodies below it — has never loaded one. + // preparation — unlike the layout bodies it feeds — has never loaded one. // // It is resolved HERE, above the routing, on purpose: every variant's // `sections` prop is rebuilt key by key in the maps below, so a `group` left @@ -272,7 +282,8 @@ const ObjectFormBody: React.FC = ({ // Apply field-level permissions to the entire schema (sections + flat // fields) BEFORE dispatching to any variant. This way all variants - // (Tabbed/Wizard/Split/Drawer/Modal/Simple) transparently honour FLS. + // (Tabbed/Wizard/Simple here, Drawer/Modal through `ObjectFormContainer`) + // transparently honour FLS. // Fail-open when no provider mounted (perms.isLoaded false). const schema = useMemo(() => { // framework#1894 / objectstack-ai/objectstack#2998 (ADR-0078): the authored @objectstack/spec @@ -339,15 +350,23 @@ const ObjectFormBody: React.FC = ({ const { sectionLabel } = useSafeFieldLabel(); const tSec = (s: any) => s?.name ? sectionLabel(schema.objectName, s.name, s.label || s.name) : s?.label; + return { schema, tSec }; +} + +const ObjectFormBody: React.FC = ({ + schema: rawSchema, + dataSource, +}) => { + const { schema, tSec } = usePreparedFormSchema(rawSchema, dataSource); // Master-detail: when the schema declares inline child collections, render as // a master-detail form (parent fields + child grids, persisted atomically). // This lets a plain form view become master-detail by config — no bespoke // page. Skipped in view mode (read-only detail uses related lists instead). - // For drawer/modal formTypes we fall through to DrawerForm/ModalForm, which - // host the master-detail form INSIDE their envelope. - const routesToMasterDetail = !!(schema as any).subforms?.length && schema.mode !== 'view' - && schema.formType !== 'drawer' && schema.formType !== 'modal'; + // A drawer or modal container never reaches here: `ObjectFormContainer` + // hands it to DrawerForm/ModalForm, which host the master-detail form INSIDE + // their envelope. + const routesToMasterDetail = !!(schema as any).subforms?.length && schema.mode !== 'view'; // ── objectui#6237 diagnostic, now scoped to the ONE arm still inert ──────── // The interim diagnostic ruled on 2026-08-29 covered `tabbed` AND `wizard`, @@ -445,10 +464,10 @@ const ObjectFormBody: React.FC = ({ // ADR-0089 section predicate (objectui#6237) — key-by-key rebuild, // so an uncopied key is silently dropped before TabbedForm ever // sees it, exactly as it was on this route until this card. The - // split/drawer/modal maps below have carried it since #6111; this - // is the tabbed arm joining them. + // drawer/modal maps (`ObjectFormContainer`) have carried it since + // #6111; this is the tabbed arm joining them. // - // Read WITHOUT an `as any` cast on purpose, unlike those three: + // Read WITHOUT an `as any` cast on purpose, unlike those two: // `ObjectFormSection.visibleWhen` is declared, so the compiler is // able to catch a rename here. Through a cast it would keep // compiling and silently copy `undefined` — the exact silent-drop @@ -530,115 +549,6 @@ const ObjectFormBody: React.FC = ({ ); } - if (schema.formType === 'split' && schema.sections?.length) { - return ( - ({ - name: s.name, - label: tSec(s), - description: s.description, - columns: s.columns, - // `?? []` — see the tabbed arm above (objectui#7051). - fields: s.fields ?? [], - // Explicit pane placement (spec FormSection.pane). This mapping - // rebuilds each section key by key, so a key it doesn't copy is - // silently dropped — exactly how `visibleOn` once vanished here. - pane: s.pane, - // ADR-0089 section predicate (#6111) — same reason as `pane` above: - // a key this map does not copy never reaches the layout at all. - visibleWhen: (s as any).visibleWhen, - visibleWhenFault: s.visibleWhenFault, - // `className` / `gridClassName`: deliberately not copied — see the - // tabbed arm above (objectstack#13626, ruled 2026-09-01). - })), - splitDirection: schema.splitDirection, - splitSize: schema.splitSize, - splitResizable: schema.splitResizable, - }} - dataSource={dataSource} - className={schema.className} - /> - ); - } - - if (schema.formType === 'drawer') { - // `layout` passes through unfolded: it is `vertical | horizontal` on both - // faces since objectui#11168 slice 3 (objectui#7759 group C), and the fold - // that mapped `inline` / `grid` away here is retired with them. - return ( - ({ - name: s.name, - label: tSec(s), - description: s.description, - columns: s.columns, - // `?? []` — see the tabbed arm above (objectui#7051). - fields: s.fields ?? [], - collapsible: (s as any).collapsible, - collapsed: (s as any).collapsed, - // ADR-0089 section predicate (#6111) — key-by-key rebuild, so an - // uncopied key is silently dropped before DrawerForm ever sees it. - visibleWhen: (s as any).visibleWhen, - visibleWhenFault: s.visibleWhenFault, - // `className`: deliberately not copied — see the tabbed arm above - // (objectstack#13626, ruled 2026-09-01). DrawerForm's own divider - // site stops reading it in the same pass. - })), - open: schema.open, - onOpenChange: schema.onOpenChange, - drawerSide: schema.drawerSide, - drawerWidth: schema.drawerWidth, - }} - dataSource={dataSource} - className={schema.className} - /> - ); - } - - if (schema.formType === 'modal') { - // `layout` passes through unfolded — see the drawer branch above. - return ( - ({ - name: s.name, - label: tSec(s), - description: s.description, - columns: s.columns, - // `?? []` — see the tabbed arm above (objectui#7051). - fields: s.fields ?? [], - // The collapse pair (objectui#9849 step two — director ruling - // letter E, item 1: group semantics attach 「on every arm」). The - // spec declares both on `sections[]`; this map used to copy - // neither, so the modal silently ignored a declared collapse. - collapsible: s.collapsible, - collapsed: s.collapsed, - // ADR-0089 section predicate (#6111) — key-by-key rebuild, so an - // uncopied key is silently dropped before ModalForm ever sees it. - visibleWhen: (s as any).visibleWhen, - visibleWhenFault: s.visibleWhenFault, - // `className` / `gridClassName`: deliberately not copied — see the - // tabbed arm above (objectstack#13626, ruled 2026-09-01). - })), - open: schema.open, - onOpenChange: schema.onOpenChange, - modalSize: schema.modalSize, - modalCloseButton: schema.modalCloseButton, - }} - dataSource={dataSource} - className={schema.className} - /> - ); - } - // Default: simple form return ; }; @@ -1340,7 +1250,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource } // What this save writes — the ONE outbound sequence, shared with the - // `tabbed`, `split` and `wizard` layouts (objectui#10563): strip what a form never + // `tabbed` and `wizard` layouts (objectui#10563): strip what a form never // writes (server-owned, computed, read-only, unknown to the object, and // refused by the caller's field-level security through `fieldWriteGate`, // objectui#10108 / `80c54122e`), omit the producer-owned defaults on a diff --git a/ui/packages/plugin-form/src/ObjectFormContainer.tsx b/ui/packages/plugin-form/src/ObjectFormContainer.tsx new file mode 100644 index 00000000000..0b40e3dd3ee --- /dev/null +++ b/ui/packages/plugin-form/src/ObjectFormContainer.tsx @@ -0,0 +1,140 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ObjectFormContainer — the drawer and modal containers an `object-form` + * block asks for with `formType: 'drawer' | 'modal'`. + * + * ADR-0050 Decision 4 (Accepted by ruling 6464210455 on objectstack#27032): + * `ObjectForm` lays a form out (`simple` / `tabbed` / `wizard`) and never + * chooses where it opens; `DrawerForm` / `ModalForm` are containers the CALLER + * supplies. A host that owns the container mounts `ModalForm` itself (the + * console's New / Edit dialog, an action modal). A page author has no such + * host: the `object-form` block (`` on a React + * page) is the only door to a built-in overlay form, so the block renderer is + * the caller, and this is the container it supplies. The form view's own + * `type` never reaches here: `@objectstack/spec` refuses `drawer` / `modal` + * there since #27155. + * + * Both maps prepare the schema through `usePreparedFormSchema`, the one + * preparation `ObjectForm` uses, so the labels, field-level security and + * `{ group }` sections a container draws are the ones the layouts draw. + */ + +import React from 'react'; +import type { ObjectFormSchema } from '@object-ui/types'; +import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm'; +import { DrawerForm } from './DrawerForm'; +import { ModalForm } from './ModalForm'; +import { RecordFieldAccessForForm } from './recordFieldAccess'; + +/** The `formType` values that name a container rather than a layout. */ +export type ObjectFormContainerType = 'drawer' | 'modal'; + +/** True when an `object-form` block's `formType` names a container. */ +export function isObjectFormContainerType( + formType: ObjectFormSchema['formType'] | undefined, +): formType is ObjectFormContainerType { + return formType === 'drawer' || formType === 'modal'; +} + +/** + * The container route of the `object-form` block. Wrapped in the same record + * field-lock scope as `ObjectForm` (objectstack#26594). + */ +export const ObjectFormContainer: React.FC = ({ schema, ...rest }) => ( + + {/* `schema=` by name, so the handler-key census follows the document into the body. */} + + +); + +const ObjectFormContainerBody: React.FC = ({ + schema: rawSchema, + dataSource, +}) => { + const { schema, tSec } = usePreparedFormSchema(rawSchema, dataSource); + + if (schema.formType === 'drawer') { + // `layout` passes through unfolded: it is `vertical | horizontal` on both + // faces since objectui#11168 slice 3 (objectui#7759 group C), and the fold + // that mapped `inline` / `grid` away here is retired with them. + return ( + ({ + name: s.name, + label: tSec(s), + description: s.description, + columns: s.columns, + // `?? []` — see `ObjectForm`'s tabbed arm (objectui#7051). + fields: s.fields ?? [], + collapsible: (s as any).collapsible, + collapsed: (s as any).collapsed, + // ADR-0089 section predicate (#6111) — key-by-key rebuild, so an + // uncopied key is silently dropped before DrawerForm ever sees it. + visibleWhen: (s as any).visibleWhen, + visibleWhenFault: s.visibleWhenFault, + // `className`: deliberately not copied — see `ObjectForm`'s tabbed arm + // (objectstack#13626, ruled 2026-09-01). DrawerForm's own divider + // site stops reading it in the same pass. + })), + open: schema.open, + onOpenChange: schema.onOpenChange, + drawerSide: schema.drawerSide, + drawerWidth: schema.drawerWidth, + }} + dataSource={dataSource} + className={schema.className} + /> + ); + } + + if (schema.formType === 'modal') { + // `layout` passes through unfolded — see the drawer branch above. + return ( + ({ + name: s.name, + label: tSec(s), + description: s.description, + columns: s.columns, + // `?? []` — see `ObjectForm`'s tabbed arm (objectui#7051). + fields: s.fields ?? [], + // The collapse pair (objectui#9849 step two — director ruling + // letter E, item 1: group semantics attach 「on every arm」). The + // spec declares both on `sections[]`; this map used to copy + // neither, so the modal silently ignored a declared collapse. + collapsible: s.collapsible, + collapsed: s.collapsed, + // ADR-0089 section predicate (#6111) — key-by-key rebuild, so an + // uncopied key is silently dropped before ModalForm ever sees it. + visibleWhen: (s as any).visibleWhen, + visibleWhenFault: s.visibleWhenFault, + // `className` / `gridClassName`: deliberately not copied — see + // `ObjectForm`'s tabbed arm (objectstack#13626, ruled 2026-09-01). + })), + open: schema.open, + onOpenChange: schema.onOpenChange, + modalSize: schema.modalSize, + modalCloseButton: schema.modalCloseButton, + }} + dataSource={dataSource} + className={schema.className} + /> + ); + } + + // Not a container: the layout is `ObjectForm`'s to draw. + return ; +}; diff --git a/ui/packages/plugin-form/src/PreviewMode.test.tsx b/ui/packages/plugin-form/src/PreviewMode.test.tsx index ec5e32e10c8..023739bc3f4 100644 --- a/ui/packages/plugin-form/src/PreviewMode.test.tsx +++ b/ui/packages/plugin-form/src/PreviewMode.test.tsx @@ -1,7 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import React from 'react'; -import { ObjectForm } from './ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from './ObjectFormContainer'; import { registerAllFields } from '@object-ui/fields'; import { PreviewModeProvider } from '@object-ui/react'; @@ -24,7 +26,7 @@ describe('Preview mode — overlay forms render inline', () => { for (const formType of ['drawer', 'modal'] as const) { it(`${formType}: mounts a real overlay dialog at runtime (control)`, async () => { render( - , @@ -35,7 +37,7 @@ describe('Preview mode — overlay forms render inline', () => { it(`${formType}: renders inline with no dialog under PreviewModeProvider`, async () => { render( - diff --git a/ui/packages/plugin-form/src/SplitForm.tsx b/ui/packages/plugin-form/src/SplitForm.tsx deleted file mode 100644 index 2154cf5f163..00000000000 --- a/ui/packages/plugin-form/src/SplitForm.tsx +++ /dev/null @@ -1,620 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * SplitForm Component - * - * A form variant that displays sections in a resizable split-panel layout. - * Each section declares its panel via `pane` (spec FormSection.pane); when - * omitted the legacy positional rule applies — the first section renders in the - * left/top panel, every other section in the right/bottom one. - * Aligns with @objectstack/spec FormView type: 'split' - * - * Both panels are ONE form (#2153). The panel group is a layout the form - * renderer owns via `FormSchema.fieldPanes`, so a single `
` / - * react-hook-form instance spans the divider. Rendering a form per panel — per - * SECTION, in fact — meant an action bar submitted only its own section and - * silently dropped everything typed on the other side, and a field condition - * could not see across the split at all. - */ - -import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types'; -import { cn, toast } from '@object-ui/components'; -import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; -import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; -import { seedCreateValues } from './schemaDefaults'; -import { resolveInitialRecord } from './initialRecord'; -import { usePermissions } from '@object-ui/permissions'; -import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; -import { ClosedAffordanceNotice } from './closedAffordanceNotice'; -import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize'; -import { formWritePayload } from './writePayload'; -import { sectionedGridLayout } from './autoLayout'; -import { useOccSave } from './occSave'; -import { - NO_LOAD_FAILURES, - beginLoadRun, - shownLoadFailure, - type LoadFailures, - type LoadRunSeq, -} from './loadFailure'; -import { hasInlineFieldSource, noSubmitTargetError } from './submitTarget'; -import { useRecordInvalidation } from './recordInvalidation'; -import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate'; -import { useFormChromeTranslation } from './formChrome'; - -export interface SplitFormSectionConfig { - name?: string; - label?: string; - description?: string; - columns?: 1 | 2 | 3 | 4; - /** - * Which panel this section renders in. Aligns with @objectstack/spec - * FormSection.pane — explicit per-section placement, so reordering sections - * never silently moves them across the divider. Omitted → the legacy - * positional rule (first section 'primary', every other 'secondary'). - */ - pane?: 'primary' | 'secondary'; - /** - * The same three entry shapes as `ObjectFormSection.fields`, by reference: - * a field name, the form view's `{ field, … }` entry, or an inline - * `FormField` (objectui#11615). - */ - fields: NonNullable; - /** - * ADR-0089 `FormSection.visibleWhen` — conditional visibility for the - * section's divider HEADER, evaluated by the form renderer with the canonical - * engine and the host predicate scope (#6010/#6111). Fails OPEN. - */ - visibleWhen?: string | { dialect?: string; source: string }; - /** - * The fault direction of {@link visibleWhen}, by reference to - * `ObjectFormSection.visibleWhenFault` (objectui#13055): `'hidden'` (a field - * group's predicate) hides the section when it cannot be evaluated. - */ - visibleWhenFault?: ObjectFormSection['visibleWhenFault']; - /** Custom CSS class for the section's header row. */ - className?: string; - /** - * Custom CSS class for the section's field grid. Applied to the panel's grid - * when that panel holds this section alone (the common case — the first - * section owns the left/top panel); a panel stacking several sections shares - * one grid inside the single form (#2153), so there is none to override. - */ - gridClassName?: string; -} - -export interface SplitFormSchema { - type: 'object-form'; - formType: 'split'; - objectName: string; - mode: 'create' | 'edit' | 'view'; - /** Record ID (for edit/view modes). A string, per the one record-id rule on `DataSource` (objectui#9511) — `ObjectForm` builds this schema from the authorable `ObjectFormSchema.recordId`, which is a string, and `findOne` takes a string. */ - recordId?: string; - sections: SplitFormSectionConfig[]; - /** - * Inline field definitions (`object-form.customFields`). A member naming a - * field a section lists is that field's definition, as on every other - * `formType` (objectui#10254); `ObjectForm` hands the key over in its - * `{...schema}` spread. - */ - customFields?: FormField[]; - - /** - * Split direction. - * @default 'horizontal' - */ - splitDirection?: 'horizontal' | 'vertical'; - - /** - * Size of the first panel (percentage 1-99). - * @default 50 - */ - splitSize?: number; - - /** - * Whether panels can be resized. - * @default true - */ - splitResizable?: boolean; - - /** - * Grid width for the whole form (1–4). Aligns with @objectstack/spec - * FormView.columns and OUTRANKS the per-section `columns`, which say how a - * section fills the grid rather than how wide it is. Omitted = the widest - * section's density. - */ - columns?: number; - - // Common form props - showSubmit?: boolean; - submitText?: string; - showCancel?: boolean; - cancelText?: string; - initialValues?: Record; - initialData?: Record; - readOnly?: boolean; - /** - * Override persistence — the seam a host uses to own the write. Declared as - * `ObjectFormSchema['submitHandler']` rather than restated, so this variant - * and the canonical key `ObjectForm` forwards can never drift apart. - * When supplied, the form validates and hands the collected values - * to this handler INSTEAD of calling `dataSource.create` / - * `dataSource.update`; the returned record is passed on to `onSuccess`. - * In `edit` mode, for a record this form read itself, it hands over what it - * would have written: the fields that differ from that read, or the full - * sanitized payload when nothing changed (objectui#10156, objectui#10563; - * the whole rule is on `ObjectFormSchema['submitHandler']`). - * - * `MasterDetailForm` supplies it to route the parent AND its child - * collections through one atomic `batchTransaction` (#2679 / ADR-0034 - * item 4). A renderer that does not read it writes the parent on its own and - * escapes that transaction — objectui#6176. - */ - submitHandler?: ObjectFormSchema['submitHandler']; - - onSuccess?: (data: any) => void | Promise; - onError?: (error: Error) => void; - onCancel?: () => void; - className?: string; -} - -export interface SplitFormProps { - schema: SplitFormSchema; - dataSource?: DataSource; - className?: string; -} - -export const SplitForm: React.FC = ({ - schema, - dataSource, - className, -}) => { - const { fieldLabel } = useSafeFieldLabel(); - // The loading line, the load-failure heading and the default submit label, - // in the session locale (objectui#11039). See `formChrome.ts`. - const { t } = useFormChromeTranslation(); - const perms = usePermissions(); - const { userId: currentUserId } = perms; - // Upload-in-flight gate (objectui#10166): a `file`/`image` value is only its - // fileId once the presigned upload settles, so a save during that window - // stored the record WITHOUT the attachment and reported success. - const uploadGate = useUploadGate(); - const [objectSchema, setObjectSchema] = useState(null); - const [formData, setFormData] = useState>({}); - // OCC-guarded edit save + its conflict dialog (see occSave.tsx). - const { saveWithOcc, conflictDialog } = useOccSave(); - const [loading, setLoading] = useState(true); - // objectui#10682 — the load error, kept per read (the object schema and the - // record), each written only by the current run of its read and cleared when - // a later run of that read commits: see `loadFailure.ts`. `error` is what - // the error screen reports. - const [loadFailures, setLoadFailures] = useState(NO_LOAD_FAILURES); - const loadRunSeqRef = useRef({ schema: 0, record: 0 }); - const error = shownLoadFailure(loadFailures); - - // Fetch object schema - useEffect(() => { - // objectui#10712 — a read an `objectName` or data-source change has - // superseded commits nothing: not the schema, and not the `loading` release - // the current read owns (the record read's `cancelled` below, applied here). - let cancelled = false; - // objectui#10682 — this run's writes to the schema read's failure; a newer - // run of this effect makes them no-ops. - const run = beginLoadRun(loadRunSeqRef, setLoadFailures, 'schema'); - const fetchSchema = async () => { - if (!dataSource) { - setLoading(false); - return; - } - try { - const data = await dataSource.getObjectSchema(schema.objectName); - if (cancelled) return; - setObjectSchema(data); - run.commit(); - } catch (err) { - if (cancelled) return; - run.fail(err); - setLoading(false); - } - }; - fetchSchema(); - return () => { cancelled = true; }; - }, [schema.objectName, dataSource]); - - // The record whose data `formData` currently holds. The fetch effect reads it - // to tell a genuine record SWAP from a re-run of its own making — - // `initialData`/`initialValues` are objects callers commonly rebuild every - // render, and flashing the loading state for those would thrash. - const loadedRecordIdRef = useRef(undefined); - // The record itself as read — the baseline an edit save diffs against, so - // only the fields that changed are written (objectui#10156, objectui#10563). - // Kept apart from `formData`, which seeds the form and supplies the OCC - // token: advancing it after a save would reseed the one and move the other. - // Set by the `findOne` below and nowhere else, so a caller-supplied record is - // never a baseline. - const loadedRecordRef = useRef(null); - // objectui#10715 — the data-invalidation bus, read the way the default arm - // reads it (the rule is stated once in `recordInvalidation.ts`): a change to - // this record, to its object or to `'*'` re-reads the record IN PLACE through - // the effect below while the form is pristine, and is held while it is dirty. - // Dirtiness is the form renderer's `onDirtyChange`, wired below. - const { refetch: recordRefetch, onDirtyChange: handleDirtyChange, saved: recordSaved } = - useRecordInvalidation(schema.objectName, schema.recordId, !!schema.recordId && schema.mode !== 'create'); - - // Fetch initial data - useEffect(() => { - // A `recordId` change re-enters this effect with the form still MOUNTED on - // the previous record, which needs handling on two fronts (pinned by - // recordSwapLoading.test.tsx): - // - go back to the loading state, so record A's values are not left on - // screen AND EDITABLE while B is in flight, to be swapped underneath in - // place when it lands. Anything typed there read as A's on screen but - // would have been submitted against B. - // - ignore a response that is no longer the one being awaited, so two - // overlapping reads land in REQUEST order, not completion order. - let cancelled = false; - // objectui#10682 — this run's writes to the record read's failure; a newer - // run of this effect makes them no-ops. - const run = beginLoadRun(loadRunSeqRef, setLoadFailures, 'record'); - const fetchData = async () => { - if (schema.mode === 'create' || !schema.recordId) { - // Seeded from something other than a read: no baseline to diff against. - loadedRecordRef.current = null; - // Declared static defaults are this form's opening values (#4047) — - // see `schemaDefaults` for the create-only boundary and for why - // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, resolveInitialRecord(schema), { currentUserId })); - // Not a read, so no earlier record read's failure describes the form. - run.commit(); - setLoading(false); - return; - } - - if (!dataSource) { - loadedRecordRef.current = null; - setFormData(resolveInitialRecord(schema)); - // Not a read either. - run.commit(); - setLoading(false); - return; - } - - // Only a change of RECORD hides the form. - if (loadedRecordIdRef.current !== schema.recordId) setLoading(true); - - try { - const data = await dataSource.findOne(schema.objectName, schema.recordId); - if (cancelled) return; - loadedRecordIdRef.current = schema.recordId; - loadedRecordRef.current = snapshotLoadedRecord(schema, data); - setFormData(data || {}); - // The record on screen is the one this run read, so an earlier record - // read's failure no longer describes it. A schema failure stays: this - // read says nothing about the object's fields. - run.commit(); - } catch (err) { - if (cancelled) return; - run.fail(err); - } finally { - if (!cancelled) setLoading(false); - } - }; - - // A bus re-read (objectui#10715, `recordRefetch` below) re-runs this - // effect for the record already on screen: it lands IN PLACE, since the - // loading branch above is keyed on a change of record, and a re-read a - // later run has superseded commits nothing, as above. - if (objectSchema || !dataSource) { - fetchData(); - } - return () => { cancelled = true; }; - }, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName, recordRefetch]); - - // Build form fields from section config - const buildSectionFields = useCallback( - (section: SplitFormSectionConfig): FormField[] => { - const fields = buildSectionFieldsShared(section as any, { - objectSchema, - objectName: schema.objectName, - readOnly: schema.readOnly, - mode: schema.mode, - // Feeds the "no persisted record" test that decides whether a runtime - // `defaultValue` excuses a field from `required` (#4069). - recordId: schema.recordId, - fieldLabel, - // A member naming a section's field is that field's definition, as it - // is on every other arm (objectui#10254). - customFields: schema.customFields, - }); - // The ONE field-gate step every layout draws through (`gateFormFields`, - // objectui#10612), applied to the RESOLVED fields. Its field-level half - // (`80c54122e`): `ObjectForm` gates a section's field OBJECTS before - // routing here, but a field named by a bare string has no `name` to ask - // about until it is resolved just above. Without this pass a field the - // caller may read but not edit rendered as a live input, and with the - // save's strip in place (`formWritePayload`, objectui#10563) whatever the - // user typed there would be dropped behind a 200. A field already gated - // upstream is gated again to the same answer. Its managed-object half - // (ADR-0092 D4): every field is disabled when the object's affordance for - // the mode is closed — before objectui#10612 only the default arm drew - // that lock, and this layout drew live inputs on a managed object. - return (gateFormFields(fields, { - perms, - objectName: schema.objectName, - mode: schema.mode, - objectSchema, - // The persisted record a `state_machine` select narrows by (#25933). - previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, - }) ?? fields) as FormField[]; - }, - [objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel, perms, formData], - ); - - // Handle form submission - const handleSubmit = useCallback(async (data: Record) => { - // Refuse while an upload is in flight (objectui#10166). Save is relabelled - // and the notice beside the form says why. This arm also covers a keyboard - // submit, and it is the only guard on this host: its button is rendered by - // the `form` node renderer, which exposes no per-button disable. - if (uploadGate.uploading) { - toast.error(uploadGate.reason); - return; - } - // No submit TARGET: a declared `submitHandler` owns the write and needs no - // adapter of its own (objectui#6176's seam), so only a form with NEITHER it - // nor a `dataSource` is target-less. The one target-less form that is still - // legitimate is the inline-fields collector, whose `onSuccess` IS the write. - // This arm used to be `if (!dataSource)` alone: it confirmed EVERY - // adapter-less submit, bypassing a declared host seam and persisting - // nothing (objectui#6300). See `submitTarget.ts` for the whole rule. - if (!dataSource && !schema.submitHandler && hasInlineFieldSource(schema)) { - if (schema.onSuccess) { - await schema.onSuccess(data); - } - return data; - } - - try { - let result; - // What this save writes, through the ONE outbound sequence the simple - // form uses (objectui#10563; `formWritePayload` holds the rule): nothing - // a form never writes — server-owned, computed, read-only, unknown, or - // refused by the caller's field-level security — and on an EDIT only the - // fields that differ from the record this form read. Computed ONCE so - // every persistence route below — the host-owned seam included — writes - // the identical payload. - const { writePayload } = formWritePayload(data, schema, { - objectSchema, - canEdit: fieldWriteGate(perms, schema.objectName, schema.mode), - snapshot: loadedRecordRef.current, - // On a create, `formData` is the seed this form opened with and - // nothing else, so a picked owner is written and a seeded one left - // alone is not (objectui#12359). - createBaseline: formData, - }); - - if (schema.submitHandler) { - // The host owns persistence (e.g. MasterDetailForm batching the parent - // + its child collections into ONE atomic transaction). The form - // validates and hands the values over; it does NOT create/update - // itself. Same seam and same precedence as SimpleObjectForm — every - // renderer `ObjectForm` routes to must check it FIRST, or a declared - // host-owned write silently becomes an independent one (objectui#6176). - result = await schema.submitHandler(writePayload); - } else if (!dataSource) { - // No route left: no host seam and no adapter. Refuse instead of reporting - // success — the `catch` below hands this to `schema.onError` and rethrows. - throw noSubmitTargetError(); - } else if (schema.mode === 'create') { - result = await dataSource.create(schema.objectName, writePayload); - } else if (schema.mode === 'edit' && schema.recordId) { - // OCC-guarded: sends `ifMatch` from the record we read; a 409 asks the - // user to keep editing (skip the success path) or overwrite. - const outcome = await saveWithOcc({ - dataSource, - objectName: schema.objectName, - recordId: schema.recordId, - payload: writePayload, - baseRecord: formData, - }); - if (outcome.status === 'cancelled') return; - result = outcome.result; - } - // The write landed: the next save from this still-mounted form diffs - // against the record as it now stands, not as first read. - loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload); - // objectui#10715 — and what the form shows is what the server holds: a - // bus change held while it was dirty (its own write's echo included) is - // replayed now rather than behind input that is no longer unsaved. - recordSaved(); - if (schema.onSuccess) { - await schema.onSuccess(result); - } - return result; - } catch (err) { - if (schema.onError) { - schema.onError(err as Error); - } - throw err; - } - }, [schema, dataSource, objectSchema, perms, saveWithOcc, formData, uploadGate.uploading, uploadGate.reason, recordSaved]); - - // Handle cancel - const handleCancel = useCallback(() => { - if (schema.onCancel) { - schema.onCancel(); - } - }, [schema]); - - // Which panel each section renders in: explicit `section.pane` first (spec - // FormSection.pane), else the legacy positional rule — first section primary, - // rest secondary — so keyless metadata keeps its exact layout. Placement - // follows the KEY, not the array position: reordering sections with panes - // declared never moves them across the divider. - const paneOf = (section: SplitFormSectionConfig, index: number): 'primary' | 'secondary' => - section.pane === 'primary' || section.pane === 'secondary' - ? section.pane - : index === 0 ? 'primary' : 'secondary'; - const leftSections = useMemo( - () => schema.sections.filter((s, i) => paneOf(s, i) === 'primary'), - [schema.sections], - ); - const rightSections = useMemo( - () => schema.sections.filter((s, i) => paneOf(s, i) === 'secondary'), - [schema.sections], - ); - - const direction = schema.splitDirection || 'horizontal'; - const panelSize = schema.splitSize || 50; - - if (error) { - return ( -
-

{t('form.errorLoading')}

-

{error.message}

-
- ); - } - - if (loading) { - return ( -
-
-

{t('publicForm.loading')}

-
- ); - } - - // The form is ONE grid; each section then lays ITS fields out at its own - // declared density within that grid via colSpan (objectstack-ai/objectstack#2578) — the same arrangement - // the stacked/tabbed sectioned forms use. The grid lives on the FIELD - // container inside the form, never wrapped around the form (that leaves the - // extra columns permanently empty, #2128). - // - // The one sectioned-form rule (objectui#12548), handed the AUTHORED counts: - // grid width = the form view's own `columns` (spec FormView.columns), else - // the widest section, an undeclared section drawing two per row from `@md` - // (objectui#12592); each section at its own count on the shared grid — 12 - // tracks wide when the counts do not all fit. ModalForm, ObjectForm, - // TabbedForm and WizardForm call the same rule, so one piece of metadata - // lays out the same in every host. - const sectionGrid = sectionedGridLayout(schema.columns, schema.sections.map((s) => s.columns)); - const formColumns = sectionGrid.columns; - const containerFieldClass = sectionGrid.fieldContainerClass; - - /** - * One panel's field list: each section contributes an inline `section-divider` - * header row followed by its fields. A per-section Card would need a - * per-section form, which is the defect above. - */ - const paneFields = (sections: SplitFormSectionConfig[], paneKey: string): FormField[] => { - const out: FormField[] = []; - sections.forEach((section, index) => { - const body = buildSectionFields(section); - if (!body.length) return; - if (section.label || section.description) { - out.push({ - name: `__section_${section.name || `${paneKey}_${index}`}`, - label: section.label, - description: section.description, - type: 'section-divider', - // ADR-0089 section predicate (#6111) — the renderer evaluates it on - // this pseudo-field with the host predicate scope bound (#6010). - visibleWhen: section.visibleWhen, - visibleWhenFault: section.visibleWhenFault, - // The membership claim (#6236): resolved member names, so the - // predicate gates the whole group. - fields: body.map(f => f.name), - colSpan: 4, - className: section.className, - } as any); - } - out.push(...sectionGrid.layOut(body, section.columns)); - }); - return out; - }; - - // A pane holding exactly one section can still honour that section's - // `gridClassName` — it owns the panel's grid outright. - const panes = [ - { key: 'primary', sections: leftSections, defaultSize: panelSize }, - { key: 'secondary', sections: rightSections, defaultSize: 100 - panelSize }, - ] - .map((pane) => ({ - ...pane, - fields: paneFields(pane.sections, pane.key), - containerClass: pane.sections.length === 1 ? pane.sections[0].gridClassName : undefined, - })) - .filter((pane) => pane.fields.length > 0); - - // The child `form` node, typed as the node it is (objectui#11354). - // `SchemaRenderer`'s `schema` slot is `BaseSchema`, so a literal written - // inline there was checked against `BaseSchema`, not against the - // `FormSchema` the `form` renderer reads. - const splitFormSchema: FormSchema = { - type: 'form', - objectName: schema.objectName, - // Every pane's fields, in one list, for the one form instance. The - // panes below claim them back by name for layout only. - fields: panes.flatMap((pane) => pane.fields), - columns: formColumns, - ...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}), - layout: 'vertical', - defaultValues: formData, - // Persisted record → `previous` binding + read-only submit strip (#3484). - previousValues: schema.mode === 'edit' && schema.recordId ? formData : undefined, - // While an upload is in flight the button says so, the same answer - // ActionParamDialog gives its Confirm (objectui#10166). - submitLabel: uploadGate.uploading - ? uploadGate.busyLabel - : schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update')), - cancelLabel: schema.cancelText, - showSubmit: schema.showSubmit !== false && schema.mode !== 'view', - showCancel: schema.showCancel !== false, - onSubmit: handleSubmit, - onCancel: handleCancel, - // The renderer's dirtiness feeds the bus gate above (objectui#10715). - onDirtyChange: handleDirtyChange, - // A single pane is not a split — the renderer then falls back to a - // plain field list rather than a one-panel resizable group. - fieldPanes: panes.map((pane) => ({ - key: pane.key, - fields: pane.fields.map((f) => f.name), - defaultSize: pane.defaultSize, - ...(pane.containerClass ? { containerClass: pane.containerClass } : {}), - })), - fieldPanesOrientation: direction, - fieldPanesResizable: schema.splitResizable !== false, - }; - - return ( - -
- {/* Why every field below is locked, when the lock is the form-wide one - (objectui#11000). */} - - - - {conflictDialog} -
-
- ); -}; - -export default SplitForm; diff --git a/ui/packages/plugin-form/src/TabbedForm.tsx b/ui/packages/plugin-form/src/TabbedForm.tsx index 37f614368f1..924b30018df 100644 --- a/ui/packages/plugin-form/src/TabbedForm.tsx +++ b/ui/packages/plugin-form/src/TabbedForm.tsx @@ -93,10 +93,10 @@ export interface FormSectionConfig { * `Omit` — a derivation that defended the * one key it named while every FUTURE key added here still reached a wizard * step by default. `WizardStepConfig` is now declared independently, exactly - * as `SplitFormSectionConfig` / `ModalFormSectionConfig` / - * `DrawerFormSectionConfig` already are, so this type and the wizard's share - * nothing: a key is authorable on a wizard step only if someone writes it - * there, on the type whose renderer has to honour it. + * as `ModalFormSectionConfig` / `DrawerFormSectionConfig` already are, so + * this type and the wizard's share nothing: a key is authorable on a wizard + * step only if someone writes it there, on the type whose renderer has to + * honour it. */ visibleWhen?: string | { dialect?: string; source: string }; /** diff --git a/ui/packages/plugin-form/src/__tests__/collapseResolution-9849.test.tsx b/ui/packages/plugin-form/src/__tests__/collapseResolution-9849.test.tsx index 92702028576..269b88b0524 100644 --- a/ui/packages/plugin-form/src/__tests__/collapseResolution-9849.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/collapseResolution-9849.test.tsx @@ -67,7 +67,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor, fireEvent } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; import { ModalForm } from '../ModalForm'; import { resetHeadinglessCollapseWarnings } from '../fieldGroups'; import { DrawerForm } from '../DrawerForm'; @@ -164,7 +166,7 @@ const EXPLICIT: Route[] = [ label: 'ObjectForm — default layout, explicit `sections`', mount: (section) => mounted( - , @@ -178,7 +180,7 @@ const EXPLICIT: Route[] = [ label: 'ObjectForm — `formType: "drawer"`, explicit `sections` through the drawer map', mount: (section) => mounted( - mounted( - - mounted(), + mounted(), open: ['amount', 'customer'], closed: ['customer'], }, @@ -397,14 +399,14 @@ describe('the drawer trap rows — a headingless section declaring `collapsed` i label: 'ObjectForm — default layout (the objectui#9780 reference answer)', mount: (s) => mounted( - , + , ), }, { label: 'ObjectForm — `formType: "drawer"` through the drawer map', mount: (s) => mounted( - , @@ -434,7 +436,7 @@ describe('the drawer trap rows — a headingless section declaring `collapsed` i label: 'ObjectForm — `formType: "modal"` through the modal map', mount: (s) => mounted( - , diff --git a/ui/packages/plugin-form/src/__tests__/drawerFirstOpenStays-13238.test.tsx b/ui/packages/plugin-form/src/__tests__/drawerFirstOpenStays-13238.test.tsx index eaac4bdd3a1..c7dbbac72a3 100644 --- a/ui/packages/plugin-form/src/__tests__/drawerFirstOpenStays-13238.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/drawerFirstOpenStays-13238.test.tsx @@ -56,6 +56,9 @@ import { ComponentRegistry } from '@object-ui/core'; import { registerAllFields } from '@object-ui/fields'; import type { DataSource } from '@object-ui/types'; import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); afterEach(() => cleanup()); @@ -120,7 +123,7 @@ function mountTaskDesk() {
{editId != null ? ( - { /** Route A — the real `ObjectForm`, routed to the drawer by `formType`. */ const viaObjectForm = (schema: Record): Promise => drawerForm( - > = {}) { function renderDrawer(extra: Record, ds: DataSource = dataSource()) { return render( - async function mount(arm: Arm, schema: Record, adapter: any = makeDataSource()) { const utils = render( - { render( - { it('the no-sections fieldGroups fallback (simple form): parse fault and unknown field both hide', async () => { diff --git a/ui/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx b/ui/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx index 5887f1a80ad..f0950019d15 100644 --- a/ui/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx @@ -41,7 +41,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import { registerAllFields } from '@object-ui/fields'; import { FormSectionSchema, FormViewSchema } from '@objectstack/spec/ui'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; import { resolveSectionGroupReferences, resetSectionGroupReports, @@ -157,7 +159,7 @@ afterEach(() => { const renderForm = async (formType: string, sections: any[], ds = makeDS()) => { render( - { render( - = [ ['tabbed', [{ group: 'billing' }, ANCHOR]], - ['split', [{ group: 'billing' }, ANCHOR]], ['drawer', [{ group: 'billing' }, ANCHOR]], ['modal', [{ group: 'billing' }, ANCHOR]], ]; @@ -406,13 +407,15 @@ describe('objectui#7051 — resolveSectionGroupReferences', () => { }); }); - it('keeps the two layout keys the spec PERMITS beside `group`, and only those', () => { + it('keeps the one layout key the spec PERMITS beside `group`, and only that', () => { const [resolved] = resolveSectionGroupReferences( [{ group: 'billing', columns: 3, pane: 'secondary', label: 'ignored' }] as any, opts, )!; expect(resolved.columns).toBe(3); - expect(resolved.pane).toBe('secondary'); + // `pane` left with the split form (ADR-0050); the spec refuses it on every + // section, and the resolver no longer carries it. + expect((resolved as any).pane).toBeUndefined(); // The group's own label wins; the authored one is not merged in. expect(resolved.label).toBe('Billing Details'); expect((resolved as any).group).toBeUndefined(); diff --git a/ui/packages/plugin-form/src/__tests__/headinglessCollapseDiagnostic-9849.test.tsx b/ui/packages/plugin-form/src/__tests__/headinglessCollapseDiagnostic-9849.test.tsx index 907a830bd39..46e59df025c 100644 --- a/ui/packages/plugin-form/src/__tests__/headinglessCollapseDiagnostic-9849.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/headinglessCollapseDiagnostic-9849.test.tsx @@ -33,7 +33,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; import { ModalForm } from '../ModalForm'; import { DrawerForm } from '../DrawerForm'; import { @@ -83,14 +85,14 @@ const ROUTES: Array<{ label: string; arm: string; mount: (s: Record - mounted(), + mounted(), }, { label: 'ObjectForm — `formType: "drawer"`', arm: 'DrawerForm', mount: (s) => mounted( - , @@ -112,7 +114,7 @@ const ROUTES: Array<{ label: string; arm: string; mount: (s: Record mounted( - , diff --git a/ui/packages/plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx b/ui/packages/plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx index 935b89e0beb..36c4a8b33b2 100644 --- a/ui/packages/plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx @@ -85,7 +85,6 @@ import { ObjectForm } from '../ObjectForm'; import { ModalForm } from '../ModalForm'; import { DrawerForm } from '../DrawerForm'; import { TabbedForm } from '../TabbedForm'; -import { SplitForm } from '../SplitForm'; import { WizardForm } from '../WizardForm'; registerAllFields(); @@ -134,7 +133,6 @@ const SEEDING_ARMS: Array<[string, React.ComponentType, string]> = [ ['ModalForm', ModalForm as any, 'modal'], ['DrawerForm', DrawerForm as any, 'drawer'], ['TabbedForm', TabbedForm as any, 'tabbed'], - ['SplitForm', SplitForm as any, 'split'], ['WizardForm', WizardForm as any, 'wizard'], ]; diff --git a/ui/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx b/ui/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx index d2126680ab2..93e93d8fcb0 100644 --- a/ui/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx @@ -42,7 +42,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); afterEach(cleanup); @@ -64,7 +66,7 @@ const makeDataSource = () => update: vi.fn(), }) as any; -const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const; +const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard'] as const; type Arm = (typeof ARMS)[number]; /** The drawer and modal portal their content, so every read is off `document.body`. */ @@ -84,7 +86,7 @@ const TYPELESS = { name: 'typeless', label: 'Typeless' }; async function mount(arm: Arm, schema: Record) { const adapter = makeDataSource(); render( - [ { name: 'classification', label: 'Classification', columns, fields: ['q1', 'q2', 'q3'] }, ]; -type Host = 'modal' | 'object' | 'split' | 'tabbed' | 'wizard'; +type Host = 'modal' | 'object' | 'tabbed' | 'wizard'; function mount(host: Host, sections: Section[], extra: Record = {}) { const schema = { @@ -94,8 +93,6 @@ function mount(host: Host, sections: Section[], extra: Record = return render(); case 'object': return render(); - case 'split': - return render(); case 'tabbed': return render(); case 'wizard': @@ -138,7 +135,7 @@ function perRowAt(read: { grid: string[]; span: string[] }, tier: (typeof TIERS) return tracks / span; } -const HOSTS: Host[] = ['modal', 'object', 'split', 'tabbed']; +const HOSTS: Host[] = ['modal', 'object', 'tabbed']; describe('objectui#12548 — a 2 / 3 / 1 mixed form draws 2 / 3 / 1 per row', () => { for (const host of HOSTS) { diff --git a/ui/packages/plugin-form/src/__tests__/modalCloseButton-11061.test.tsx b/ui/packages/plugin-form/src/__tests__/modalCloseButton-11061.test.tsx index 7ecf42e1345..c5695f7bee9 100644 --- a/ui/packages/plugin-form/src/__tests__/modalCloseButton-11061.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/modalCloseButton-11061.test.tsx @@ -27,6 +27,9 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/re import { registerAllFields } from '@object-ui/fields'; import type { DataSource } from '@object-ui/types'; import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); afterEach(() => cleanup()); @@ -49,7 +52,7 @@ async function renderModal(extra: Record) { const onOpenChange = vi.fn(); const onCancel = vi.fn(); render( - > = {}) { function renderModal(extra: Record, ds: DataSource = dataSource()) { return render( - ` itself gets the + * inline form and owns its own container. + * + * Pinned here, through the registered renderer (`SchemaRenderer`), not a + * component import, because the block route is the claim: + * 1. a `drawer` / `modal` block opens a dialog holding the form; + * 2. a layout block (`tabbed`) renders inline, with no dialog; + * 3. a `drawer` / `modal` node handed straight to `` renders + * inline — the container is the caller's; + * 4. the #1890 pin: a tabbed layout inside a modal still renders its tabs. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module-scope side-effect imports: the registry must hold `object-form` and the +// field widgets it renders into before the first render (AGENTS.md §测试纪律). +import '@object-ui/components'; +import '@object-ui/fields'; +import '../index'; +import { ObjectForm } from '../ObjectForm'; +import { ObjectFormContainer } from '../ObjectFormContainer'; + +const TASK_SCHEMA = { + name: 'task', + label: 'Task', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + status: { name: 'status', type: 'text', label: 'Status' }, + }, +}; + +const SECTIONS = [ + { name: 'main', label: 'Main', fields: ['title'] }, + { name: 'more', label: 'More', fields: ['status'] }, +]; + +function makeAdapter() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: '1' }), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(TASK_SCHEMA), + }; +} + +function renderBlock(properties: Record) { + const adapter = makeAdapter(); + const utils = render( + + + , + ); + return { adapter, ...utils }; +} + +beforeEach(() => cleanup()); + +describe('objectstack#27159 — the `object-form` block opens its own container', () => { + it.each(['drawer', 'modal'] as const)('a `%s` block opens a dialog holding the form', async (formType) => { + renderBlock({ formType, title: 'New task' }); + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(dialog.textContent).toContain('Title')); + expect(dialog.textContent).toContain('New task'); + }); + + it('a `tabbed` block renders inline — no dialog', async () => { + const { container } = renderBlock({ formType: 'tabbed', sections: SECTIONS }); + await waitFor(() => expect(container.textContent).toContain('Main')); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Main', 'More']); + }); + + it.each(['drawer', 'modal'] as const)('a `%s` node handed straight to `` renders inline: the container is the caller\'s', async (formType) => { + const adapter = makeAdapter(); + const { container } = render( + , + ); + await waitFor(() => expect(container.textContent).toContain('Title')); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(container.querySelectorAll('form')).toHaveLength(1); + }); + + it('the #1890 pin: a tabbed layout inside the modal container still draws its tabs', async () => { + const adapter = makeAdapter(); + render( + , + ); + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(dialog.querySelectorAll('[role="tab"]')).toHaveLength(2)); + expect([...dialog.querySelectorAll('[role="tab"]')].map((t) => t.textContent)).toEqual(['Main', 'More']); + }); + + it('CONTROL: a layout `formType` handed to the container falls back to the inline form', async () => { + const adapter = makeAdapter(); + const { container } = render( + , + ); + await waitFor(() => expect(container.textContent).toContain('Title')); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx b/ui/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx index e4221a2ad86..a3b602b911c 100644 --- a/ui/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/objectFormSectionMembers-8071.test.tsx @@ -151,7 +151,9 @@ import { render, waitFor, cleanup, fireEvent } from '@testing-library/react'; import React from 'react'; import { PredicateScopeProvider } from '@object-ui/react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); @@ -175,7 +177,7 @@ const makeDataSource = () => /** Mount a create-mode `object-form` in its DEFAULT layout and wait for the form. */ async function mount(schema: Record): Promise { const { container } = render( - , @@ -239,7 +241,7 @@ async function mountWithScope( ): Promise { const { container } = render( - @@ -262,7 +264,7 @@ async function mountArm( sections: Array>, ): Promise { render( - { // `description`, so a hit is a reading on an instrument that can report // none. const ARMS: Array<{ formType: string; blurb: (c: HTMLElement) => string[] }> = [ - { formType: 'split', blurb: (c) => [...c.querySelectorAll('.border-b p')].map((e) => e.textContent ?? '') }, { formType: 'modal', blurb: (c) => [...c.querySelectorAll('.border-b p')].map((e) => e.textContent ?? '') }, { formType: 'wizard', diff --git a/ui/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx b/ui/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx index 31a872a188a..877d6c72fae 100644 --- a/ui/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx @@ -43,7 +43,9 @@ import { render, waitFor, fireEvent, cleanup } from '@testing-library/react'; import React from 'react'; import { MePermissionsProvider } from '@object-ui/permissions'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); afterEach(cleanup); @@ -66,7 +68,7 @@ const makeDataSource = (objectSchema: Record = OBJECT_SCHEMA) = update: vi.fn(), }) as any; -const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const; +const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard'] as const; type Arm = (typeof ARMS)[number]; /** The drawer and modal portal their content, so every read is off `document.body`. */ @@ -101,7 +103,7 @@ async function mount( ) { const adapter = makeDataSource(objectSchema); const node = ( - = { - 'packages/plugin-form/src/ObjectForm.tsx': 5, + 'packages/plugin-form/src/ObjectForm.tsx': 2, + 'packages/plugin-form/src/ObjectFormContainer.tsx': 2, 'packages/plugin-form/src/ModalForm.tsx': 4, 'packages/plugin-form/src/DrawerForm.tsx': 2, - 'packages/plugin-form/src/SplitForm.tsx': 1, 'packages/plugin-form/src/TabbedForm.tsx': 3, 'apps/console/src/components/FormPage.tsx': 1, }; diff --git a/ui/packages/plugin-form/src/__tests__/sectionLabelI18n.test.tsx b/ui/packages/plugin-form/src/__tests__/sectionLabelI18n.test.tsx index 4c51841fd40..ac984e7424c 100644 --- a/ui/packages/plugin-form/src/__tests__/sectionLabelI18n.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/sectionLabelI18n.test.tsx @@ -25,7 +25,9 @@ import { render, screen, waitFor, renderHook } from '@testing-library/react'; import React from 'react'; import { createI18n, I18nProvider, useObjectLabel } from '@object-ui/i18n'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); @@ -100,7 +102,7 @@ const NAMED_SECTIONS = [ const renderForm = (instance: any, schema: Record) => render( - { const view = render( - expect(diagnosticCalls().length).toBe(1)); - expect(diagnosticCalls()[0]).toContain("`formType: 'tabbed' | 'modal' | 'drawer' | 'split'`"); + expect(diagnosticCalls()[0]).toContain("`formType: 'tabbed' | 'modal' | 'drawer'`"); }); it('the report does not depend on the VERDICT — an admitting predicate is just as inert', async () => { @@ -190,11 +193,10 @@ describe('#6237 — the boundary: arms that HONOUR the predicate stay silent', ( // feature #6111 / #6619 already landed. const honouring: Array<[string, Record]> = [ ['simple (flat, section-divider pseudo-field)', { formType: 'simple' }], - ['modal (ObjectForm modal map copies it)', { formType: 'modal', open: true }], + ['modal (the block container\'s modal map copies it)', { formType: 'modal', open: true }], ['modal + contentLayout tabbed (#6619 FormFieldTab.visibleWhen)', { formType: 'modal', open: true, contentLayout: 'tabbed' }], - ['drawer (ObjectForm drawer map copies it)', { formType: 'drawer', open: true }], - ['split (ObjectForm split map copies it)', { formType: 'split' }], + ['drawer (the block container\'s drawer map copies it)', { formType: 'drawer', open: true }], // The row this card moved. `tabbed` honours the key through the very same // renderer machinery the `modal + contentLayout: tabbed` row above uses — // `ObjectForm`'s tabbed map copies the predicate, `TabbedForm` copies it @@ -229,7 +231,7 @@ describe('#6237 — the report is once per mount, not once per render', () => { // bury the console it is trying to speak into. view.rerender( - { /** Mount through `ObjectForm` — the entry `RecordFormPage` itself uses. */ const renderObjectForm = async (extra: Record) => { render( - Promise }[] = [ mount: () => renderObjectForm({ formType: 'wizard' }), }, { - label: "SplitForm — formType 'split' via ObjectForm delegation (split section map)", - mount: () => renderObjectForm({ formType: 'split' }), - }, - { - label: "ModalForm — formType 'modal' via ObjectForm delegation (modal section map)", + label: "ModalForm — formType 'modal' via the block container (modal section map)", mount: () => renderObjectForm({ formType: 'modal', open: true }), }, { - label: "DrawerForm — formType 'drawer' via ObjectForm delegation (drawer section map)", + label: "DrawerForm — formType 'drawer' via the block container (drawer section map)", mount: () => renderObjectForm({ formType: 'drawer', open: true }), }, { diff --git a/ui/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx b/ui/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx index eb2bf373ddc..63e7cdb8ad7 100644 --- a/ui/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/sectionVisibleWhen-6111.test.tsx @@ -126,7 +126,9 @@ import { render, screen, cleanup, waitFor } from '@testing-library/react'; import React from 'react'; import { PredicateScopeProvider } from '@object-ui/react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; import { ModalForm } from '../ModalForm'; registerAllFields(); @@ -194,7 +196,7 @@ const renderObjectForm = async ( ) => { render( - renderObjectForm(scope, { formType: 'simple' }, gate), }, { - label: 'ModalForm — via ObjectForm delegation (key-by-key remap + ModalForm groups map)', + label: 'ModalForm — via the block container (key-by-key remap + ModalForm groups map)', mount: (scope, gate) => renderObjectForm(scope, { formType: 'modal', open: true }, gate), }, { @@ -275,10 +277,10 @@ const LAYOUTS: { mount: (scope, gate) => renderModalFormDirect(scope, { contentLayout: 'tabbed' }, gate), }, { - // Same arm via ObjectForm delegation: `contentLayout` rides the spread and + // Same arm via the block container: `contentLayout` rides the spread and // the key-by-key section remap must have copied `visibleWhen` for the tab // to receive it — both hops, like the stacked modal row above. - label: "ModalForm — contentLayout 'tabbed', via ObjectForm delegation (#6237)", + label: "ModalForm — contentLayout 'tabbed', via the block container (#6237)", mount: (scope, gate) => renderObjectForm(scope, { formType: 'modal', open: true, contentLayout: 'tabbed' }, gate), }, @@ -294,13 +296,9 @@ const LAYOUTS: { mount: (scope, gate) => renderObjectForm(scope, { formType: 'tabbed' }, gate), }, { - label: 'DrawerForm — via ObjectForm delegation (key-by-key remap + explicit-sections divider)', + label: 'DrawerForm — via the block container (key-by-key remap + explicit-sections divider)', mount: (scope, gate) => renderObjectForm(scope, { formType: 'drawer', open: true }, gate), }, - { - label: 'SplitForm — via ObjectForm delegation (key-by-key remap + paneFields divider)', - mount: (scope, gate) => renderObjectForm(scope, { formType: 'split' }, gate), - }, ]; describe('#6111 — an authored section `visibleWhen` reaches an evaluator in every layout', () => { diff --git a/ui/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx b/ui/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx index 155b6abf467..4f513758b92 100644 --- a/ui/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx @@ -47,7 +47,9 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor, fireEvent } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from '../ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from '../ObjectFormContainer'; registerAllFields(); @@ -68,7 +70,7 @@ const makeDataSource = () => update: vi.fn(), }) as any; -const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const; +const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard'] as const; type Arm = (typeof ARMS)[number]; /** The drawer and modal portal their content, so every read is off `document.body`. */ @@ -88,7 +90,7 @@ const MAIN_SECTION = (fields: Array>) => [ async function mount(arm: Arm, schema: Record) { const adapter = makeDataSource(); const utils = render( - ]> = [ ['ModalForm', { formType: 'modal', open: true, sections: SECTIONS }], ['DrawerForm', { formType: 'drawer', open: true, sections: SECTIONS }], ['TabbedForm', { formType: 'tabbed', sections: SECTIONS }], - ['SplitForm', { formType: 'split', sections: SECTIONS }], ['WizardForm', { formType: 'wizard', sections: SECTIONS }], ]; diff --git a/ui/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx b/ui/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx index 54ee9ef7425..0a358f155a3 100644 --- a/ui/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx @@ -33,7 +33,6 @@ import { registerAllFields } from '@object-ui/fields'; import '@object-ui/components'; import { ModalForm } from '../ModalForm'; import { ObjectForm } from '../ObjectForm'; -import { SplitForm } from '../SplitForm'; import { TabbedForm } from '../TabbedForm'; import { WizardForm } from '../WizardForm'; @@ -57,9 +56,9 @@ const makeDataSource = (): any => ({ }); type Section = { name: string; label: string; columns?: number; fields: unknown[] }; -type Host = 'modal' | 'object' | 'split' | 'tabbed' | 'wizard'; +type Host = 'modal' | 'object' | 'tabbed' | 'wizard'; -const PAGE_HOSTS: Exclude[] = ['object', 'split', 'tabbed', 'wizard']; +const PAGE_HOSTS: Exclude[] = ['object', 'tabbed', 'wizard']; function mount(host: Host, sections: Section[], extra: Record = {}) { const schema = { @@ -76,8 +75,6 @@ function mount(host: Host, sections: Section[], extra: Record = return render(); case 'object': return render(); - case 'split': - return render(); case 'tabbed': return render(); case 'wizard': diff --git a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 0777a23d66e..a8612be09a5 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -830,8 +830,6 @@ const WRITE_SITES: Record = { 'packages/plugin-form/src/ModalForm.tsx :: ModalFormBody > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), - 'packages/plugin-form/src/SplitForm.tsx :: SplitForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), - 'packages/plugin-form/src/SplitForm.tsx :: SplitForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/TabbedForm.tsx :: TabbedForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/TabbedForm.tsx :: TabbedForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), 'packages/plugin-form/src/WizardForm.tsx :: WizardForm > handleStepSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), diff --git a/ui/packages/plugin-form/src/autoLayout.ts b/ui/packages/plugin-form/src/autoLayout.ts index d240f93c973..870ec56f1a1 100644 --- a/ui/packages/plugin-form/src/autoLayout.ts +++ b/ui/packages/plugin-form/src/autoLayout.ts @@ -309,7 +309,7 @@ export function filterAutoGeneratedFields( /** * Container-query-based grid classes for laying out form FIELDS inside a * section. Uses `@container` / `@md:` / `@2xl:` / `@4xl:` variants so the grid - * responds to the form's actual width (modal / drawer / split panel) instead of + * responds to the form's actual width (modal / drawer) instead of * the viewport — a narrow modal stays single-column regardless of viewport size. * * `1` maps to `undefined` so the caller falls back to the form renderer's @@ -403,7 +403,7 @@ export interface SectionedGridLayout { /** * The ONE layout rule of a sectioned form — `ModalForm`, `ObjectForm`, - * `SplitForm`, `TabbedForm` and `WizardForm` all call it (objectui#12548). + * `TabbedForm` and `WizardForm` all call it (objectui#12548). * * Every section shares the form's single field grid (one form, one * react-hook-form instance — #2153/#2959), and every authored section must diff --git a/ui/packages/plugin-form/src/closedAffordanceNotice-11000.test.tsx b/ui/packages/plugin-form/src/closedAffordanceNotice-11000.test.tsx index 308e73f53f7..1bc015825e0 100644 --- a/ui/packages/plugin-form/src/closedAffordanceNotice-11000.test.tsx +++ b/ui/packages/plugin-form/src/closedAffordanceNotice-11000.test.tsx @@ -68,7 +68,6 @@ const LAYOUTS: Array<{ layout: string; schema: (names: string[]) => Record ({ formType: 'drawer', sections: sections(n) }) }, { layout: 'modal / sections (ModalForm)', schema: (n) => ({ formType: 'modal', sections: sections(n) }) }, { layout: 'tabbed / sections (TabbedForm)', schema: (n) => ({ formType: 'tabbed', sections: sections(n) }) }, - { layout: 'split / sections (SplitForm)', schema: (n) => ({ formType: 'split', sections: sections(n) }) }, { layout: 'wizard / sections (WizardForm)', schema: (n) => ({ formType: 'wizard', sections: sections(n) }) }, { layout: 'simple / flat (default arm)', schema: (n) => ({ fields: n }) }, { layout: 'drawer / flat (DrawerForm)', schema: (n) => ({ formType: 'drawer', fields: n }) }, diff --git a/ui/packages/plugin-form/src/createDefaults.test.tsx b/ui/packages/plugin-form/src/createDefaults.test.tsx index ef76a140757..b9880da385b 100644 --- a/ui/packages/plugin-form/src/createDefaults.test.tsx +++ b/ui/packages/plugin-form/src/createDefaults.test.tsx @@ -48,7 +48,6 @@ import { ObjectForm } from './ObjectForm'; import { ModalForm } from './ModalForm'; import { DrawerForm } from './DrawerForm'; import { TabbedForm } from './TabbedForm'; -import { SplitForm } from './SplitForm'; registerAllFields(); @@ -102,7 +101,6 @@ const CONTAINERS: Array<[string, React.ComponentType, string]> = [ ['ModalForm', ModalForm as any, 'modal'], ['DrawerForm', DrawerForm as any, 'drawer'], ['TabbedForm', TabbedForm as any, 'tabbed'], - ['SplitForm', SplitForm as any, 'split'], ]; describe.each(CONTAINERS)('%s — create-mode default seeding (#4047)', (_name, Container, formType) => { diff --git a/ui/packages/plugin-form/src/createFormGrant-12082.test.tsx b/ui/packages/plugin-form/src/createFormGrant-12082.test.tsx index ec08d199e4c..78478b9ebf6 100644 --- a/ui/packages/plugin-form/src/createFormGrant-12082.test.tsx +++ b/ui/packages/plugin-form/src/createFormGrant-12082.test.tsx @@ -85,7 +85,6 @@ const LAYOUTS: Array<{ layout: string; schema: (names: string[]) => Record ({ formType: 'drawer', sections: sections(n) }) }, { layout: 'modal / sections (ModalForm)', schema: (n) => ({ formType: 'modal', sections: sections(n) }) }, { layout: 'tabbed / sections (TabbedForm)', schema: (n) => ({ formType: 'tabbed', sections: sections(n) }) }, - { layout: 'split / sections (SplitForm)', schema: (n) => ({ formType: 'split', sections: sections(n) }) }, { layout: 'wizard / sections (WizardForm)', schema: (n) => ({ formType: 'wizard', sections: sections(n) }) }, { layout: 'simple / flat (default arm)', schema: (n) => ({ fields: n }) }, { layout: 'drawer / flat (DrawerForm)', schema: (n) => ({ formType: 'drawer', fields: n }) }, diff --git a/ui/packages/plugin-form/src/createOwnerPick-12359.test.tsx b/ui/packages/plugin-form/src/createOwnerPick-12359.test.tsx index c280f0bd575..d499321f0b8 100644 --- a/ui/packages/plugin-form/src/createOwnerPick-12359.test.tsx +++ b/ui/packages/plugin-form/src/createOwnerPick-12359.test.tsx @@ -313,7 +313,6 @@ describe.each([ const OBJECT_FORM_ROUTES: Array<{ route: string; layout: Record }> = [ { route: 'simple (ObjectForm, finalDefaultValues)', layout: { sections: SECTIONS } }, { route: 'tabbed (TabbedForm, formData)', layout: { formType: 'tabbed', sections: SECTIONS } }, - { route: 'split (SplitForm, formData)', layout: { formType: 'split', sections: SECTIONS } }, { route: 'wizard (WizardForm, its seed ref)', layout: { formType: 'wizard', sections: SECTIONS } }, ]; diff --git a/ui/packages/plugin-form/src/fieldWriteGate.ts b/ui/packages/plugin-form/src/fieldWriteGate.ts index adc5bd83ec5..72e2acdc8d4 100644 --- a/ui/packages/plugin-form/src/fieldWriteGate.ts +++ b/ui/packages/plugin-form/src/fieldWriteGate.ts @@ -333,8 +333,8 @@ export function closedFormAffordance({ /** * The ONE field-gate step every `ObjectForm` layout draws its RESOLVED fields * through (objectui#10612): the default arm (flat and sectioned), `DrawerForm` - * and `ModalForm` (sections, derived field groups and flat), and `TabbedForm`, - * `SplitForm` and `WizardForm` (sections). + * and `ModalForm` (sections, derived field groups and flat), and `TabbedForm` + * and `WizardForm` (sections). * * Two gates, in one pass, so no layout can apply one and skip the other: * diff --git a/ui/packages/plugin-form/src/formArmsFieldGate-10612.test.tsx b/ui/packages/plugin-form/src/formArmsFieldGate-10612.test.tsx index 68b1c7f63c3..3717ad22e04 100644 --- a/ui/packages/plugin-form/src/formArmsFieldGate-10612.test.tsx +++ b/ui/packages/plugin-form/src/formArmsFieldGate-10612.test.tsx @@ -91,7 +91,6 @@ const LAYOUTS: Array<{ layout: string; schema: (names: string[]) => Record ({ formType: 'drawer', sections: sections(n) }) }, { layout: 'modal / sections (ModalForm)', schema: (n) => ({ formType: 'modal', sections: sections(n) }) }, { layout: 'tabbed / sections (TabbedForm)', schema: (n) => ({ formType: 'tabbed', sections: sections(n) }) }, - { layout: 'split / sections (SplitForm)', schema: (n) => ({ formType: 'split', sections: sections(n) }) }, { layout: 'wizard / sections (WizardForm)', schema: (n) => ({ formType: 'wizard', sections: sections(n) }) }, { layout: 'simple / flat (default arm)', schema: (n) => ({ fields: n }) }, { layout: 'drawer / flat (DrawerForm)', schema: (n) => ({ formType: 'drawer', fields: n }) }, diff --git a/ui/packages/plugin-form/src/formArmsInvalidation-10715.test.tsx b/ui/packages/plugin-form/src/formArmsInvalidation-10715.test.tsx index 497e22d9cad..ea68e6ff1fd 100644 --- a/ui/packages/plugin-form/src/formArmsInvalidation-10715.test.tsx +++ b/ui/packages/plugin-form/src/formArmsInvalidation-10715.test.tsx @@ -90,7 +90,6 @@ const ARMS: ReadonlyArray<{ form: string; formType: string | undefined }> = [ { form: 'ObjectForm (default arm, the lit control)', formType: undefined }, { form: 'DrawerForm', formType: 'drawer' }, { form: 'ModalForm', formType: 'modal' }, - { form: 'SplitForm', formType: 'split' }, { form: 'TabbedForm', formType: 'tabbed' }, { form: 'WizardForm', formType: 'wizard' }, ]; diff --git a/ui/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx b/ui/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx index 9e1594f0de2..9dbb51bf696 100644 --- a/ui/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx +++ b/ui/packages/plugin-form/src/formArmsWritePayload-10563.test.tsx @@ -127,7 +127,6 @@ const sections = (extra: string[]) => [ const ROUTES: Array<{ route: string; layout: (extra?: string[]) => Record; steps: number }> = [ { route: 'simple (control)', layout: (extra = []) => ({ fields: members(extra) }), steps: 1 }, { route: 'tabbed (TabbedForm)', layout: (extra = []) => ({ formType: 'tabbed', sections: sections(extra) }), steps: 1 }, - { route: 'split (SplitForm)', layout: (extra = []) => ({ formType: 'split', sections: sections(extra) }), steps: 1 }, { route: 'wizard (WizardForm)', layout: (extra = []) => ({ formType: 'wizard', sections: sections(extra) }), steps: 2 }, { route: 'simple + mobile.stepper (WizardForm)', diff --git a/ui/packages/plugin-form/src/formChrome.i18n-11039.test.tsx b/ui/packages/plugin-form/src/formChrome.i18n-11039.test.tsx index 45a976faf4c..a5c2660cc7a 100644 --- a/ui/packages/plugin-form/src/formChrome.i18n-11039.test.tsx +++ b/ui/packages/plugin-form/src/formChrome.i18n-11039.test.tsx @@ -116,7 +116,6 @@ const SECTIONS = [{ name: 'main', label: 'Main', fields: ['name'] }]; const LAYOUTS = { simple: {}, tabbed: { formType: 'tabbed', sections: SECTIONS }, - split: { formType: 'split', sections: SECTIONS }, wizard: { formType: 'wizard', sections: SECTIONS }, drawer: { formType: 'drawer' }, modal: { formType: 'modal' }, @@ -209,7 +208,7 @@ afterEach(() => { }); describe('form chrome resolves through the i18n catalogue (objectui#11039)', () => { - it.each(['simple', 'tabbed', 'split', 'wizard', 'drawer'] as const)( + it.each(['simple', 'tabbed', 'wizard', 'drawer'] as const)( 'zh %s: the loading line is the zh pack\'s', async (layout: Layout) => { mount('zh', LAYOUTS[layout], pendingDataSource()); @@ -220,7 +219,7 @@ describe('form chrome resolves through the i18n catalogue (objectui#11039)', () }, ); - it.each(['simple', 'tabbed', 'split', 'wizard', 'drawer', 'modal'] as const)( + it.each(['simple', 'tabbed', 'wizard', 'drawer', 'modal'] as const)( 'zh %s: the load-failure heading is the zh pack\'s, above the error itself', async (layout: Layout) => { mount('zh', LAYOUTS[layout], failingDataSource()); @@ -232,7 +231,7 @@ describe('form chrome resolves through the i18n catalogue (objectui#11039)', () }, ); - it.each(['simple', 'tabbed', 'split', 'drawer', 'modal'] as const)( + it.each(['simple', 'tabbed', 'drawer', 'modal'] as const)( 'zh %s: the default submit label is 创建 on a create and 更新 on an edit', async (layout: Layout) => { const create = mount('zh', LAYOUTS[layout], okDataSource()); diff --git a/ui/packages/plugin-form/src/index.tsx b/ui/packages/plugin-form/src/index.tsx index f0966cebed0..facb24699d1 100644 --- a/ui/packages/plugin-form/src/index.tsx +++ b/ui/packages/plugin-form/src/index.tsx @@ -18,6 +18,7 @@ import { import { useObjectTranslation } from '@object-ui/i18n'; import type { DataSource } from '@object-ui/types'; import { ObjectForm } from './ObjectForm'; +import { ObjectFormContainer, isObjectFormContainerType } from './ObjectFormContainer'; import { hasInlineFieldSource } from './submitTarget'; export { ObjectForm }; @@ -49,8 +50,6 @@ export { TabbedForm } from './TabbedForm'; export type { TabbedFormProps, TabbedFormSchema, FormSectionConfig } from './TabbedForm'; export { WizardForm } from './WizardForm'; export type { WizardFormProps, WizardFormSchema, WizardStepConfig } from './WizardForm'; -export { SplitForm } from './SplitForm'; -export type { SplitFormProps, SplitFormSchema } from './SplitForm'; export { DrawerForm } from './DrawerForm'; export type { DrawerFormProps, DrawerFormSchema } from './DrawerForm'; export { ModalForm } from './ModalForm'; @@ -263,7 +262,12 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = elem // `sections` (objectui#10254). requiresObject={!hasInlineFieldSource(schema)} > - {(bound) => } + {/* ADR-0050 Decision 4: a `formType` of `drawer` / `modal` names a + container, and the block is the caller that supplies it — + `ObjectForm` itself only lays the form out. */} + {(bound) => isObjectFormContainerType(bound?.formType) + ? + : } ); }); @@ -292,7 +296,7 @@ ComponentRegistry.register('object-form', ObjectFormRenderer, { { ...OBJECT_FORM_OBJECT_NAME_INPUT }, { name: 'fields', type: 'array', description: 'Bare field names to show, in order (each looked up in the object schema). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: "note", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (SimpleObjectForm in ObjectForm.tsx; buildFlatFields in flatFields.ts for the drawer/modal presentations).' }, { name: 'mode', type: 'enum', enum: ['create', 'edit', 'view'] }, - { name: 'formType', type: 'enum', enum: ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'] }, + { name: 'formType', type: 'enum', enum: ['simple', 'tabbed', 'wizard', 'drawer', 'modal'], description: 'The form layout (`simple`, `tabbed`, `wizard`), or the container the block opens it in (`drawer`, `modal`, drawn as a stacked form; `contentLayout: "tabbed"` puts a modal\'s sections on tabs). `split` was retired with ADR-0050: use `tabbed` (one tab per section), or an `object-master-detail-form` block to edit child records beside the parent.' }, { name: 'sections', type: 'array' }, // The row's seven `I18nLabel` members (`ComponentPropsMap['object-form']`): // `title`, `description` here, and `submitText`, `cancelText`, `nextText`, @@ -328,16 +332,12 @@ ComponentRegistry.register('object-form', ObjectFormRenderer, { // Wizard { name: 'allowSkip', type: 'boolean' }, { name: 'showStepIndicator', type: 'boolean' }, - // Split - { name: 'splitDirection', type: 'enum', enum: ['horizontal', 'vertical'] }, - { name: 'splitSize', type: 'number' }, - { name: 'splitResizable', type: 'boolean' }, // Drawer { name: 'drawerSide', type: 'enum', enum: ['top', 'bottom', 'left', 'right'] }, { name: 'drawerWidth', type: 'string' }, // Modal { name: 'modalSize', type: 'enum', enum: ['sm', 'default', 'lg', 'xl', 'full'] }, - { name: 'modalCloseButton', type: 'boolean', description: 'Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by `ObjectForm`’s modal route and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown.' }, + { name: 'modalCloseButton', type: 'boolean', description: 'Show the modal presentation’s close (X) button. Default `true`; `false` hides it. Forwarded by the block’s modal container and honoured by `ModalForm` on both of its dialog arms. With the button hidden the modal still closes on Escape, and on the Cancel action when that is shown.' }, { name: 'contentLayout', type: 'enum', enum: ['simple', 'tabbed'], description: 'How the modal presentation lays out sections. `tabbed` differs from `simple` only when more than one section has a field to show (`ModalForm` tests `schema.contentLayout === "tabbed" && groups.length > 1`).' }, { name: 'confirmOnDiscard', type: 'boolean', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately.' }, // Record binding @@ -587,7 +587,7 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer 'The child collections, one OBJECT per entry, judged by the spec\'s closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child\'s metadata when omitted; `columns` are the line grid\'s columns as the spec\'s inline grid column objects (`{ name, … }`, never a bare field name), derived from the child\'s fields when omitted; `formFields` names the per-row form\'s fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. `sortField` is not a member: the line-position field is derived from the child object (objectui#11070), and since `@objectstack/spec` 17.7.0 the entry refuses an authored `sortField` with the spec\'s retired-key message (objectstack-ai/objectstack#21589).', }, { name: 'recordId', type: 'string', description: 'The parent record to load in `edit` mode. Leave unset for `create`.' }, - // TWO values, not the six `object-form` declares (objectui#5939). A bare + // TWO values, not the five `object-form` declares (objectui#5939). A bare // `string` here let an out-of-vocabulary value match NO renderer branch and // fall through to the flat field list with no diagnostic — measured: a // `formType` of `'wizzard'` renders the parent half with its section headers @@ -596,12 +596,11 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer // `MasterDetailFormSchema.formType?: 'simple' | 'tabbed'` // (MasterDetailForm.tsx) and the coercion `formType === 'tabbed' ? 'tabbed' // : 'simple'` ObjectForm.tsx uses when it routes a `subforms` schema in here. - // The other four are reachable branches that BREAK this composition, each - // observed rather than assumed: `drawer`/`modal` host the parent half in a - // portal dialog outside the master-detail container, so the single bottom - // Save bar finds no `` to submit; `wizard` mounts only the current - // step's fields and turns that Save bar into a `Next`; `split` renders two - // panels but persists through `dataSource.create` instead of the batch. + // The other three are each observed rather than assumed: `drawer`/`modal` + // name a CONTAINER, which only the `object-form` block supplies (ADR-0050 + // Decision 4), so the header form draws its flat layout and the value is + // inert here; `wizard` mounts only the current step's fields and turns the + // single bottom Save bar into a `Next`. (`split` retired with ADR-0050.) // Declaring them would mint choices an authoring UI offers and this block // cannot honour. { name: 'formType', type: 'enum', enum: ['simple', 'tabbed'], description: 'How the PARENT half of the form is presented. The detail grids below it are unaffected.' }, diff --git a/ui/packages/plugin-form/src/loadFailure.ts b/ui/packages/plugin-form/src/loadFailure.ts index d854a66b6c4..e2089d106c3 100644 --- a/ui/packages/plugin-form/src/loadFailure.ts +++ b/ui/packages/plugin-form/src/loadFailure.ts @@ -9,8 +9,8 @@ /** * The load error a record form shows, kept PER READ (objectui#10682). * - * Each layout `ObjectForm` routes to (its default arm, `DrawerForm`, - * `ModalForm`, `SplitForm`, `TabbedForm`, `WizardForm`) reads two things + * Each layout an `object-form` routes to (`ObjectForm`'s default arm, + * `DrawerForm`, `ModalForm`, `TabbedForm`, `WizardForm`) reads two things * before it can draw: the object schema (`getObjectSchema`) and the record * (`findOne`). Each used to keep ONE `error` that both reads wrote and nothing * cleared, and each renders its error screen ahead of the form, so one failed diff --git a/ui/packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx b/ui/packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx index 83eafcdc34a..c4f424144cb 100644 --- a/ui/packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx +++ b/ui/packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx @@ -23,26 +23,24 @@ * outside a prop's vocabulary routes AROUND the block instead of exercising it — * here, around a real crash, for as long as anyone had looked. * - * ## Why TWO values and not the sibling's six + * ## Why TWO values and not the sibling's five * - * Copying the six across would have minted a declared-but-inert vocabulary: an + * Copying the five across would have minted a declared-but-inert vocabulary: an * authoring UI offering choices this composition cannot honour. So the set was - * measured rather than assumed, and the four exclusions are each pinned below by - * the way they BREAK the master-detail composition, not by assertion: + * measured rather than assumed, and the three exclusions are each pinned below + * by what they do to the master-detail composition, not by assertion: * * `simple` → ObjectForm.tsx:1134 (SimpleObjectForm sections path). Honoured. * `tabbed` → ObjectForm.tsx:236 (TabbedForm). Presentation honoured. * `wizard` → ObjectForm.tsx:260 (WizardForm) — mounts only the CURRENT * step's fields, and the master-detail's single bottom Save bar * drives the wizard's `Next` instead of saving. - * `split` → ObjectForm.tsx:287 (SplitForm) — renders two panels inline. - * (It ALSO escaped the atomic batch when this file was written; - * objectui#6176 fixed that, so only the presentation difference - * is left. The set below is unchanged regardless.) - * `drawer` → ObjectForm.tsx:316 (DrawerForm) — hosts the parent half in a - * PORTAL dialog, outside the master-detail container, so the Save - * bar has no `` to submit. - * `modal` → ObjectForm.tsx:346 (ModalForm) — same portal shape. + * `drawer`, `modal` → name a CONTAINER, which only the `object-form` block + * supplies (ADR-0050 Decision 4); the header `ObjectForm` draws + * its flat layout instead, so the value is inert here. (Before + * the cutover `ObjectForm` opened a portal dialog for them, and + * the Save bar found no form to submit.) + * (`split` left `object-form` with ADR-0050.) * * The repo already stated the same two independently, which is the corroboration * this file is derived against rather than a second guess: @@ -78,7 +76,7 @@ vi.mock('@object-ui/components', async (orig) => { registerAllFields(); /** The vocabulary `object-form` declares — the set this block was measured against. */ -const OBJECT_FORM_SIX = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; +const OBJECT_FORM_FIVE = ['simple', 'tabbed', 'wizard', 'drawer', 'modal'] as const; /** The subset the master-detail composition honours. */ const HONOURED = ['simple', 'tabbed'] as const; @@ -154,7 +152,7 @@ describe('object-master-detail-form `formType` — a closed, measured vocabulary it('every declared value is one `object-form` also declares (no private dialect)', () => { for (const value of declaredInput().enum) { - expect(OBJECT_FORM_SIX).toContain(value); + expect(OBJECT_FORM_FIVE).toContain(value); } }); @@ -189,17 +187,17 @@ describe('the measurement the vocabulary is derived from', () => { }); it.each(['drawer', 'modal'] as const)( - '`%s` is excluded: the parent half lands in a PORTAL dialog, leaving the host with no form to save', + '`%s` is excluded: it names a container, and the parent form draws none (ADR-0050)', async (formType) => { const { container } = mountWith(formType); - await waitFor(() => - expect(document.querySelectorAll('[role="dialog"]').length).toBeGreaterThan(0), - ); - // Nothing of the parent half is inside the master-detail container, so the - // bottom Save bar's `formHostRef.querySelector('form')` returns null and - // the click is a no-op. - expect(container.querySelectorAll('form').length).toBe(0); - expect(container.querySelectorAll('input[name="ref"]').length).toBe(0); + await waitFor(() => expect(container.querySelector('input[name="ref"]')).toBeTruthy()); + // `ObjectForm` lays a form out and never opens one: the drawer and modal + // containers are supplied by the `object-form` BLOCK (ADR-0050 Decision + // 4). Handed one of those words, the header form draws its flat layout + // inline — no portal dialog — so the value buys nothing here, and an + // authoring UI offering it would offer a choice this block ignores. + expect(document.querySelectorAll('[role="dialog"]').length).toBe(0); + expect(container.querySelectorAll('form').length).toBe(1); }, ); @@ -215,26 +213,6 @@ describe('the measurement the vocabulary is derived from', () => { expect(create).not.toHaveBeenCalled(); }); - it('`split` renders inline and now saves through the ATOMIC batch — its persistence rationale is spent (objectui#6176)', async () => { - const { container, batchTransaction, create } = mountWith('split'); - await waitFor(() => expect(container.querySelector('input[name="ref"]')).toBeTruthy()); - expect(await clickHostSave(container)).toBe(true); - // This row used to read `create 1 / batchTransaction 0`. `submitHandler` — - // the hook MasterDetailForm hands the parent form so the save routes - // through `batchTransaction` — was read only by SimpleObjectForm, and - // SplitForm wrote the parent directly instead. objectui#6176 made every - // renderer honour the declared seam, so this row now reads like `simple`. - await waitFor(() => expect(batchTransaction).toHaveBeenCalledTimes(1)); - expect(create).not.toHaveBeenCalled(); - // ⚠️ The VOCABULARY is deliberately unchanged and stays `simple | tabbed` - // (asserted above). Admitting `split` would be a contract change, and - // objectui#6176 — a declared-vs-enforced restoration — did not make it. - // What this case now records is narrower than before: the PERSISTENCE - // reason for excluding `split` no longer holds, so whatever keeps it out is - // presentational and is an open question for triage, not a fact this file - // may assert. - }); - it('the harm the closed vocabulary guards: an out-of-vocabulary value silently drops the sections', async () => { const { container } = mountWith('wizzard'); await waitFor(() => expect(container.querySelector('input[name="ref"]')).toBeTruthy()); diff --git a/ui/packages/plugin-form/src/occSave.tsx b/ui/packages/plugin-form/src/occSave.tsx index fc90d61f0f6..67dfe5cd767 100644 --- a/ui/packages/plugin-form/src/occSave.tsx +++ b/ui/packages/plugin-form/src/occSave.tsx @@ -8,7 +8,7 @@ * occSave — Optimistic Concurrency Control for the record edit forms. * * Every form variant (ObjectForm / ModalForm / DrawerForm / TabbedForm / - * SplitForm / WizardForm) reads the record with `findOne` and later writes it + * WizardForm) reads the record with `findOne` and later writes it * back with `dataSource.update`. Without a version token that write is * last-write-wins: two users editing the same record both get a 200 and the * later save silently erases the earlier one. The server already supports diff --git a/ui/packages/plugin-form/src/recordFormsErrorClears-10682.test.tsx b/ui/packages/plugin-form/src/recordFormsErrorClears-10682.test.tsx index 11ed0628226..2413d6104d2 100644 --- a/ui/packages/plugin-form/src/recordFormsErrorClears-10682.test.tsx +++ b/ui/packages/plugin-form/src/recordFormsErrorClears-10682.test.tsx @@ -139,7 +139,6 @@ const FORMS: ReadonlyArray<{ form: string; formType: string | undefined }> = [ { form: 'ObjectForm (default arm)', formType: undefined }, { form: 'DrawerForm', formType: 'drawer' }, { form: 'ModalForm', formType: 'modal' }, - { form: 'SplitForm', formType: 'split' }, { form: 'TabbedForm', formType: 'tabbed' }, { form: 'WizardForm', formType: 'wizard' }, ]; diff --git a/ui/packages/plugin-form/src/recordInvalidation.ts b/ui/packages/plugin-form/src/recordInvalidation.ts index e56d9b210f0..63c7d3c9c0c 100644 --- a/ui/packages/plugin-form/src/recordInvalidation.ts +++ b/ui/packages/plugin-form/src/recordInvalidation.ts @@ -10,7 +10,7 @@ * recordInvalidation — how a record form reads the data-invalidation bus * (`notifyDataChanged` / `useDataInvalidation` from `@object-ui/react`), stated * ONCE for every layout `ObjectForm` routes to: its default arm (objectui#10572) - * and `DrawerForm`, `ModalForm`, `SplitForm`, `TabbedForm` and `WizardForm` + * and `DrawerForm`, `ModalForm`, `TabbedForm` and `WizardForm` * (objectui#10715). * * The nonce moves on a change to the record the form reads, to its object as a diff --git a/ui/packages/plugin-form/src/recordSwapLoading.test.tsx b/ui/packages/plugin-form/src/recordSwapLoading.test.tsx index 7c0ef1b4fa0..b56f0d4066c 100644 --- a/ui/packages/plugin-form/src/recordSwapLoading.test.tsx +++ b/ui/packages/plugin-form/src/recordSwapLoading.test.tsx @@ -40,7 +40,6 @@ import type { DataSource } from '@object-ui/types'; import { ModalForm } from './ModalForm'; import { DrawerForm } from './DrawerForm'; import { TabbedForm } from './TabbedForm'; -import { SplitForm } from './SplitForm'; import { WizardForm, type WizardFormSchema } from './WizardForm'; registerAllFields(); @@ -115,13 +114,12 @@ type SectionedFormContainer = React.ComponentType<{ }>; /** The `formType` discriminants these containers are pinned to, spelled once. */ -type SectionedFormType = 'modal' | 'drawer' | 'tabbed' | 'split' | 'wizard'; +type SectionedFormType = 'modal' | 'drawer' | 'tabbed' | 'wizard'; const CONTAINERS: ReadonlyArray = [ ['ModalForm', ModalForm, 'modal'], ['DrawerForm', DrawerForm, 'drawer'], ['TabbedForm', TabbedForm, 'tabbed'], - ['SplitForm', SplitForm, 'split'], // objectui#10726. A wizard step is a section, so `SECTIONS` is its one step. ['WizardForm', WizardForm, 'wizard'], ]; diff --git a/ui/packages/plugin-form/src/sectionColumns.test.tsx b/ui/packages/plugin-form/src/sectionColumns.test.tsx index 8122efd4b39..82104188d2e 100644 --- a/ui/packages/plugin-form/src/sectionColumns.test.tsx +++ b/ui/packages/plugin-form/src/sectionColumns.test.tsx @@ -28,7 +28,6 @@ import React from 'react'; import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from './ModalForm'; import { TabbedForm } from './TabbedForm'; -import { SplitForm } from './SplitForm'; import { sectionFormLayout } from './autoLayout'; registerAllFields(); @@ -79,10 +78,10 @@ afterEach(() => { * which describe how a section fills the grid rather than how wide the grid is. * * `ObjectForm` (simple path) and `ModalForm` already resolved it as - * `explicit form columns ?? widest section ?? 1`. `TabbedForm` and `SplitForm` - * read only the sections, so a view that declared `columns: 3` silently - * rendered single-column in a tabbed or split form while the same metadata gave - * 3 columns in a modal. + * `explicit form columns ?? widest section ?? 1`. `TabbedForm` (and the split + * form, since retired with ADR-0050) read only the sections, so a view that + * declared `columns: 3` silently rendered single-column in a tabbed form while + * the same metadata gave 3 columns in a modal. */ describe('view-level `columns` outranks per-section columns', () => { const SECTIONS = [ @@ -109,31 +108,12 @@ describe('view-level `columns` outranks per-section columns', () => { expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull(); }); - it('SplitForm honours the form view’s columns', async () => { - render( - , - ); - - await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); - expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull(); - }); - it('falls back to the widest section when the view declares no columns', async () => { render( - (); diff --git a/ui/packages/plugin-form/src/sectionPredicateDiagnostic.ts b/ui/packages/plugin-form/src/sectionPredicateDiagnostic.ts index 19db29c91a0..53df6dc6dcc 100644 --- a/ui/packages/plugin-form/src/sectionPredicateDiagnostic.ts +++ b/ui/packages/plugin-form/src/sectionPredicateDiagnostic.ts @@ -10,9 +10,9 @@ * The one layout arm that still DROPS an authored `FormSection.visibleWhen` * (objectui#6237). * - * Of the six layout routes in `ObjectForm`, five honour the predicate: - * `split` / `drawer` / `modal` rebuild each section key by key and copy it - * (#6111), the flat arm carries it on the `section-divider` pseudo-field + * Of the layout routes an `object-form` block reaches, all but one honour the + * predicate: the `drawer` / `modal` containers rebuild each section key by key + * and copy it (#6111), the flat arm carries it on the `section-divider` pseudo-field * (#6236), and `tabbed` joined them by copying it onto the tab `TabbedForm` * synthesises, where the renderer evaluates it (#6237). `wizard` is the * remainder. @@ -44,7 +44,7 @@ export function sectionPredicateUnsupportedWarning( + `${sectionNames} render unconditionally. A wizard STEP predicate is a ` + 'separate contract still being designed (objectui#6237) — it is not the ' + 'tab predicate with a different name. Today, use ' - + "`formType: 'tabbed' | 'modal' | 'drawer' | 'split'` or the flat layout — " + + "`formType: 'tabbed' | 'modal' | 'drawer'` or the flat layout — " + 'each honours a section `visibleWhen` — or move the predicate onto the ' + 'individual fields, whose own `visibleWhen` is evaluated on every layout.'; } diff --git a/ui/packages/plugin-form/src/sectionedFormValues.test.tsx b/ui/packages/plugin-form/src/sectionedFormValues.test.tsx index 2bd0afc1300..2f436892c5d 100644 --- a/ui/packages/plugin-form/src/sectionedFormValues.test.tsx +++ b/ui/packages/plugin-form/src/sectionedFormValues.test.tsx @@ -27,7 +27,7 @@ * `split` is the same defect in a different layout: its two resizable panels * each held their own ``, so submitting from one panel's action bar * dropped everything typed in the other and no condition could see across the - * divider. Its panels are covered at the bottom of this file. + * divider. (The split form retired with ADR-0050, and its pins with it.) */ import React from 'react'; @@ -36,8 +36,6 @@ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/re import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from './ModalForm'; import { TabbedForm } from './TabbedForm'; -import { SplitForm } from './SplitForm'; -import { ObjectForm } from './ObjectForm'; registerAllFields(); @@ -257,209 +255,6 @@ describe('ModalForm stacked sections — one form for all sections (#2153)', () }); }); -describe('SplitForm — one form across BOTH panels (#2153)', () => { - it('submits the values typed in both panels', async () => { - const dataSource = makeDataSource(); - render( - , - ); - - await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); - - fill('subject', 'Printer on fire'); // left panel - fill('status', 'open'); // right panel - fill('priority', 'high'); // right panel - fill('description', 'Smoke coming out of tray 2'); // right panel - - submitForm(); - - await waitFor(() => expect(dataSource.create).toHaveBeenCalledTimes(1)); - expect(dataSource.create.mock.calls[0][1]).toMatchObject({ - subject: 'Printer on fire', - status: 'open', - priority: 'high', - description: 'Smoke coming out of tray 2', - }); - - // ONE form spanning both panels. A `` per panel (per SECTION, in - // fact) is what stranded the other panel's input outside the submit: each - // one owned an isolated react-hook-form instance, so the payload above - // arrived holding only the section whose action bar was clicked. - expect(document.body.querySelectorAll('form')).toHaveLength(1); - }); - - it('keeps every section’s header, in the panel that owns it', async () => { - render( - , - ); - - // Headers survive the move to inline `section-divider` rows... - expect(await screen.findByText('Basics')).toBeTruthy(); - expect(screen.getByText('Who is asking')).toBeTruthy(); - expect(screen.getByText('Triage')).toBeTruthy(); - - // ...and each stays on its own side of the split. - const panes = document.body.querySelectorAll('[data-testid^="form-pane:"]'); - expect(panes).toHaveLength(2); - expect(panes[0].textContent).toContain('Basics'); - expect(panes[0].querySelector('[data-field="subject"]')).toBeTruthy(); - expect(panes[1].textContent).toContain('Triage'); - expect(panes[1].querySelector('[data-field="status"]')).toBeTruthy(); - }); - - it('evaluates a right-panel field’s condition against a left-panel field', async () => { - const dataSource = { - ...makeDataSource(), - getObjectSchema: vi.fn().mockResolvedValue({ - name: 'case', - fields: { - subject: { type: 'text', label: 'Subject' }, - status: { type: 'text', label: 'Status' }, - // Cross-panel condition: it watches a field in the OTHER panel, which - // only one shared react-hook-form instance can see. - escalation: { - type: 'text', - label: 'Escalation', - visibleWhen: "record.subject == 'urgent'", - }, - }, - }), - }; - - render( - , - ); - - await waitFor(() => expect(valueOf('subject')).toBe('')); - expect(document.body.querySelector('[data-field="escalation"]')).toBeNull(); - - fill('subject', 'urgent'); - - await waitFor(() => - expect(document.body.querySelector('[data-field="escalation"]')).toBeTruthy(), - ); - }); -}); - -describe('SplitForm — explicit `section.pane` placement (spec FormSection.pane)', () => { - const paneEl = (key: string) => - document.body.querySelector(`[data-testid="form-pane:${key}"]`)!; - - const PANED_SECTIONS = [ - // Declared order deliberately disagrees with the panes: the SECOND and - // THIRD sections are the primary pane. Impossible to express before — - // the renderer hardcoded first-section-left / rest-right. - { name: 'triage', label: 'Triage', pane: 'secondary' as const, fields: ['status'] }, - { name: 'basics', label: 'Basics', pane: 'primary' as const, fields: ['subject'] }, - { name: 'detail', label: 'Detail', pane: 'primary' as const, fields: ['description'] }, - ]; - - it('groups sections by their declared pane, not by array position', async () => { - render( - , - ); - - await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); - - // Two sections side by side in the primary pane… - expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); - expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy(); - // …and the first-declared section sits in the secondary pane. - expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); - expect(paneEl('secondary').querySelector('[data-field="subject"]')).toBeNull(); - }); - - it('reordering sections does not move them across the divider', async () => { - render( - , - ); - - await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); - - expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); - expect(paneEl('primary').querySelector('[data-field="description"]')).toBeTruthy(); - expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); - }); - - it('ObjectForm forwards `pane` through its split mapping', async () => { - // The dispatch mapping rebuilds sections key by key — a key it does not - // copy is silently dropped (how `visibleOn` once vanished). Pin the copy. - render( - , - ); - - await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); - expect(paneEl('primary').querySelector('[data-field="subject"]')).toBeTruthy(); - expect(paneEl('secondary').querySelector('[data-field="status"]')).toBeTruthy(); - }); -}); - describe('TabbedForm — one form for all tabs (#2959)', () => { it('submits every tab’s values after the user moves between tabs', async () => { const dataSource = makeDataSource(); diff --git a/ui/packages/plugin-form/src/submitHandlerSeam.test.tsx b/ui/packages/plugin-form/src/submitHandlerSeam.test.tsx index 648c152115d..c159aacad02 100644 --- a/ui/packages/plugin-form/src/submitHandlerSeam.test.tsx +++ b/ui/packages/plugin-form/src/submitHandlerSeam.test.tsx @@ -52,7 +52,7 @@ import { render, waitFor, fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; import { MasterDetailForm } from './MasterDetailForm'; -import { ObjectForm } from './ObjectForm'; +import { ObjectFormContainer } from './ObjectFormContainer'; import './index'; const toastSuccess = vi.fn(); @@ -82,8 +82,8 @@ vi.mock('@object-ui/components/ui/sonner', async (orig) => { registerAllFields(); -/** The full vocabulary `object-form` declares — every renderer `ObjectForm` routes to. */ -const OBJECT_FORM_SIX = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; +/** The full vocabulary `object-form` declares — every renderer the block routes to. */ +const OBJECT_FORM_FIVE = ['simple', 'tabbed', 'wizard', 'drawer', 'modal'] as const; /** * The variants that render the parent half INLINE, so the master-detail host's @@ -92,7 +92,7 @@ const OBJECT_FORM_SIX = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal * lands in a portal dialog) are excluded by LAYOUT, not by the seam — that * exclusion is measured in `masterDetailFormTypeVocabulary.test.tsx`. */ -const INLINE_PARENT_VARIANTS = ['simple', 'tabbed', 'split'] as const; +const INLINE_PARENT_VARIANTS = ['simple', 'tabbed'] as const; const parentObject = { name: 'po', @@ -189,11 +189,12 @@ describe('a failing child leg leaves NO committed parent, whichever variant rend ); }); -describe('every renderer ObjectForm routes to honours the declared `submitHandler` seam', () => { - // Mounted through `ObjectForm` itself — the component that OWNS the routing — - // so this exercises the same forwarding path a real host uses, not each - // variant in isolation. - it.each(OBJECT_FORM_SIX)( +describe('every renderer an `object-form` block routes to honours the declared `submitHandler` seam', () => { + // Mounted through `ObjectFormContainer` — the block's own route: a layout + // goes to `ObjectForm`, a `drawer` / `modal` to the container it supplies + // (ADR-0050) — so this exercises the same forwarding path a real block uses, + // not each variant in isolation. + it.each(OBJECT_FORM_FIVE)( 'formType `%s`: hands the values to the host and does NOT persist on its own', async (formType) => { const submitHandler = vi.fn().mockResolvedValue({ id: 'p1' }); @@ -209,7 +210,7 @@ describe('every renderer ObjectForm routes to honours the declared `submitHandle } as any; render( - { // The card's own measurement, re-expressed as a pin. On `main` the `tabbed` - // and `split` rows read `batchTransaction 0 / create 1`. + // row (and the since-retired `split` one) read `batchTransaction 0 / create 1`. it.each(INLINE_PARENT_VARIANTS)( 'formType `%s`: one batchTransaction carrying BOTH legs, and zero independent creates', async (formType) => { diff --git a/ui/packages/plugin-form/src/submitTargetRefusal.test.tsx b/ui/packages/plugin-form/src/submitTargetRefusal.test.tsx index 4ad7032cbb1..fe6fcd63c13 100644 --- a/ui/packages/plugin-form/src/submitTargetRefusal.test.tsx +++ b/ui/packages/plugin-form/src/submitTargetRefusal.test.tsx @@ -86,7 +86,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, fireEvent, screen, cleanup } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; -import { ObjectForm } from './ObjectForm'; +// objectstack#27159: `drawer` / `modal` are the block's containers, so these renders go +// through `ObjectFormContainer`, the block's own route (a layout falls through to `ObjectForm`). +import { ObjectFormContainer } from './ObjectFormContainer'; import { MasterDetailForm } from './MasterDetailForm'; import { NO_SUBMIT_TARGET_MESSAGE } from './submitTarget'; import './index'; @@ -123,7 +125,7 @@ vi.mock('@object-ui/components/ui/sonner', async (orig) => { registerAllFields(); /** The five renderers `ObjectForm` routes to that own an early-return submit. */ -const VARIANTS = ['tabbed', 'wizard', 'split', 'drawer', 'modal'] as const; +const VARIANTS = ['tabbed', 'wizard', 'drawer', 'modal'] as const; /** The two of them that implement an inline-`customFields` field source. */ const INLINE_RENDERERS = ['drawer', 'modal'] as const; /** @@ -198,7 +200,7 @@ describe('1. a declared `submitHandler` is consulted even with NO dataSource', ( const onError = vi.fn(); render( - , ); @@ -222,7 +224,7 @@ describe('1. a declared `submitHandler` is consulted even with NO dataSource', ( const onError = vi.fn(); render( - , + , ); await fillAndSubmit(); @@ -259,7 +261,7 @@ describe('1. a declared `submitHandler` is consulted even with NO dataSource', ( // the merged tree as well — the ordering defect was reachable only through // the `!dataSource` carve-out, and this case is what says so. render( - , @@ -281,7 +283,7 @@ describe('2. no seam, no adapter, no inline fields → refuse loudly', () => { const onSuccess = vi.fn(); const onError = vi.fn(); - render(); + render(); await fillAndSubmit(); // THE PIN — the false success is gone. @@ -298,7 +300,7 @@ describe('2. no seam, no adapter, no inline fields → refuse loudly', () => { const onError = vi.fn(); render( - , + , ); await fillAndSubmit(); @@ -322,7 +324,7 @@ describe('3. CARVE-OUT: a legitimate inline-fields form still works', () => { // and the one the `object-form` element gate exempts from // `requiresDataSource`. render( - { // published example throw. Measured before the guard existed: it rendered // and submitted. render( - { // a narrower dress. Without this case the carve-out could quietly become a // blanket escape the moment any form declared one inline field. render( - { // and after the fix, deliberately — it is what makes block 1's `simple` pin // attributable to the declared seam rather than to the carve-out having // been narrowed or removed. - render(); + render(); await fillAndSubmit(); await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); @@ -435,7 +437,7 @@ describe('3. CARVE-OUT: a legitimate inline-fields form still works', () => { // variants do — the `simple` row is the one that goes red without the // members-only seeding in `SimpleObjectForm`'s schema and record effects. render( - , diff --git a/ui/packages/plugin-form/src/supersededReads-10712.test.tsx b/ui/packages/plugin-form/src/supersededReads-10712.test.tsx index b623c4d47ac..51f7f255ab9 100644 --- a/ui/packages/plugin-form/src/supersededReads-10712.test.tsx +++ b/ui/packages/plugin-form/src/supersededReads-10712.test.tsx @@ -106,7 +106,6 @@ const FORMS: ReadonlyArray<{ form: string; formType: string | undefined }> = [ { form: 'ObjectForm (default arm)', formType: undefined }, { form: 'DrawerForm', formType: 'drawer' }, { form: 'ModalForm', formType: 'modal' }, - { form: 'SplitForm', formType: 'split' }, { form: 'TabbedForm', formType: 'tabbed' }, { form: 'WizardForm', formType: 'wizard' }, ]; diff --git a/ui/packages/plugin-form/src/uploadInFlightSave.test.tsx b/ui/packages/plugin-form/src/uploadInFlightSave.test.tsx index 1df5be5899b..142dafe5464 100644 --- a/ui/packages/plugin-form/src/uploadInFlightSave.test.tsx +++ b/ui/packages/plugin-form/src/uploadInFlightSave.test.tsx @@ -39,7 +39,6 @@ import { registerAllFields, useUploadingSignal } from '@object-ui/fields'; import { ObjectForm } from './ObjectForm'; import { ModalForm } from './ModalForm'; import { DrawerForm } from './DrawerForm'; -import { SplitForm } from './SplitForm'; import { TabbedForm } from './TabbedForm'; import { WizardForm } from './WizardForm'; import { MasterDetailForm } from './MasterDetailForm'; @@ -213,7 +212,6 @@ describe('record form — save while an upload is in flight (objectui#10166)', ( * A host wired without a pin would be a claim nothing re-derives. */ it.each([ - ['SplitForm', SplitForm as any, { type: 'object-split-form', sections: [{ name: 's1', label: 'Main', fields: ['name', 'attachment'] }] }], ['TabbedForm', TabbedForm as any, { type: 'object-tabbed-form', sections: [{ name: 's1', label: 'Main', fields: ['name', 'attachment'] }] }], ['DrawerForm', DrawerForm as any, { type: 'object-drawer-form', open: true, fields: ['name', 'attachment'] }], ])('stores nothing mid-upload and the attachment once it resolves (%s)', async (_name, Host, extra) => { diff --git a/ui/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts b/ui/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts index 03bd59f25cd..35b26098e70 100644 --- a/ui/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts +++ b/ui/packages/types/src/__tests__/object-form-section-style-keys-undeclared.test.ts @@ -45,8 +45,8 @@ * * ## Which types are NOT touched, deliberately * - * The five per-layout config types in @object-ui/plugin-form — - * `ModalFormSectionConfig`, `SplitFormSectionConfig`, TabbedForm's + * The four per-layout config types in @object-ui/plugin-form — + * `ModalFormSectionConfig`, TabbedForm's * `FormSectionConfig`, `WizardStepConfig`, `DrawerFormSectionConfig` — keep * their own `className` / `gridClassName`. Their renderers read them uncast for * programmatic React mounts, which the authorable boundary does not govern. @@ -84,7 +84,6 @@ const DECLARED_KEYS = [ 'collapsible', 'collapsed', 'columns', - 'pane', 'fields', 'group', 'visibleWhen', @@ -122,12 +121,10 @@ describe('objectui#7200 — `ObjectFormSection` does not declare the retired sec collapsible: true, collapsed: false, columns: 2, - pane: 'primary', visibleWhen: '${record.kind === "person"}', fields: ['name', { name: 'email', type: 'email', label: 'Email' }], }; expect(section.columns).toBe(2); - expect(section.pane).toBe('primary'); }); it('declared-key census: the full authored section vocabulary, so a re-addition is a deliberate edit here', () => { @@ -143,7 +140,7 @@ describe('objectui#7200 — `ObjectFormSection` does not declare the retired sec collapsible: true, collapsed: true, columns: true, - pane: true, + // `pane` left with the split form (objectstack#27159, ADR-0050). fields: true, // objectui#7051 — the group-reference form (`@objectstack/spec` 17.3.0, // objectstack#13855). Added to the census, not to the retired list: the 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 30abe5f176f..72f5ce1cf35 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 @@ -91,7 +91,7 @@ const ROWS: ReadonlyArray<{ key: string; valid: unknown; wrong: unknown }> = [ fields: ['name', { name: 'email', type: 'email' }], visibleWhen: { dialect: 'cel', source: 'record.kind == "b2b"' }, }, - { group: 'contact_info', pane: 'secondary' }, + { group: 'contact_info', columns: 1 }, ], wrong: 'basics', }, @@ -101,9 +101,9 @@ const ROWS: ReadonlyArray<{ key: string; valid: unknown; wrong: unknown }> = [ { key: 'showStepIndicator', valid: false, wrong: 'no' }, { key: 'nextText', valid: { en: 'Next', fr: 'Suivant' }, wrong: 42 }, { key: 'prevText', valid: 'Back', wrong: 42 }, - { key: 'splitDirection', valid: 'vertical', wrong: 'diagonal' }, - { key: 'splitSize', valid: 40, wrong: '40%' }, - { key: 'splitResizable', valid: false, wrong: 'false' }, + // `splitDirection` / `splitSize` / `splitResizable` left the block with + // `formType: 'split'` (objectstack#27159): tombstones in the spec row, which + // the mirror now declares by reference, so no authored value parses. { key: 'drawerSide', valid: 'left', wrong: 'center' }, { key: 'drawerWidth', valid: '40%', wrong: 40 }, { key: 'modalSize', valid: 'lg', wrong: 'huge' }, diff --git a/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts index 38292452fc3..946169d43f1 100644 --- a/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -294,7 +294,8 @@ describe('the source schemas still declare their full member sets', () => { // 71 since objectstack#25923 (work item 4) declared `contentLayout` and // `confirmOnDiscard` by reference to `ComponentPropsMap['object-form']`; the - // `form` slot carries both. + // `form` slot carries both. (objectstack#27159 kept the three split-only + // members as by-reference tombstones, so the count did not move.) it('ObjectFormSchema declares 71 members and no longer carries the #5155 index signature', () => { const form = exportedType('ObjectFormSchema'); expect(memberNames(form)).toHaveLength(71); diff --git a/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts b/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts index 7241c8a5b8d..7c9875343dc 100644 --- a/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts +++ b/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts @@ -279,7 +279,7 @@ describe('P1.1 ListView Spec Alignment', () => { describe('P1.2 FormView Spec Alignment', () => { it('should accept all formType variants', () => { const formTypes: Array = [ - 'simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal', + 'simple', 'tabbed', 'wizard', 'drawer', 'modal', ]; formTypes.forEach((formType) => { const schema: ObjectFormSchema = { @@ -326,10 +326,10 @@ describe('P1.2 FormView Spec Alignment', () => { // visibly if the shape is ever silently emptied. const sections: ObjectFormSection[] = [ { group: 'contact_info' }, - // `columns` and `pane` are the two keys the spec PERMITS beside `group`: - // they describe how this form lays the section out, not anything the - // group declares. - { group: 'billing', columns: 2, pane: 'secondary' }, + // `columns` is the key the spec PERMITS beside `group`: it describes how + // this form lays the section out, not anything the group declares + // (`pane` was the other until it left with the split form, objectstack#27159). + { group: 'billing', columns: 2 }, { name: 'audit', label: 'Audit', fields: ['created_at'] }, ]; expect(sections).toHaveLength(3); diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index e47ccfba241..a3406bac6dd 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -1607,16 +1607,6 @@ export interface ObjectFormSection { */ columns?: 1 | 2 | 3 | 4; - /** - * Which panel of a split form this section renders in. Aligns with - * @objectstack/spec FormSection.pane (split forms only — the spec rejects the - * key on other form types at parse). Explicit per-section placement, so - * reordering sections never silently moves them across the divider. - * Omitted → the legacy positional rule: first section 'primary', every - * other section 'secondary'. - */ - pane?: 'primary' | 'secondary'; - /** * The section's fields, in order. Each entry is one of three shapes, the * three `buildSectionFields` (`@object-ui/plugin-form`) draws on every @@ -1661,8 +1651,8 @@ export interface ObjectFormSection { * group itself declares (`name`, `label`, `description`, `collapsible`, * `collapsed`, `visibleWhen`) — the spec grants no override semantics, so a * restated key would be a second writable spelling of one fact. What a - * section keeps beside `group` is how THIS form lays it out: `columns` and - * `pane`. Refused outright on a wizard step (`formType: 'wizard'`), which + * section keeps beside `group` is how THIS form lays it out: `columns`. + * Refused outright on a wizard step (`formType: 'wizard'`), which * has no slot for a group's `collapse` or `visibleWhen`. * * The aliases `fieldGroup` / `groupKey` are NOT accepted spellings: the spec @@ -1741,7 +1731,7 @@ export interface ObjectFormSection { // now fails at the authoring site instead of type-checking into a no-op. // // The per-layout config types in @object-ui/plugin-form - // (`ModalFormSectionConfig`, `SplitFormSectionConfig`, TabbedForm's + // (`ModalFormSectionConfig`, TabbedForm's // `FormSectionConfig`, `WizardStepConfig`, `DrawerFormSectionConfig`) keep // their own members: those are read for programmatic React mounts, which the // authorable boundary does not govern. Pinned at the type level in @@ -1755,13 +1745,12 @@ export interface ObjectFormSection { * A smart form component that generates forms from ObjectQL object schemas. * It automatically creates form fields based on object metadata. * - * Supports multiple form variants aligned with @objectstack/spec FormView: - * - `simple` – Flat field list (default) - * - `tabbed` – Fields organized in tabs - * - `wizard` – Multi-step form with navigation - * - `split` – Side-by-side panels (reserved) - * - `drawer` – Slide-out form panel (reserved) - * - `modal` – Dialog-based form (reserved) + * Its `formType` is a layout aligned with @objectstack/spec FormView.type — + * `simple` (flat field list, the default), `tabbed` (fields organized in tabs) + * or `wizard` (multi-step form with navigation) — or, on the `object-form` + * block only, the container the block opens the form in: `drawer` (slide-out + * panel) or `modal` (dialog). ADR-0050: `ObjectForm` renders the layouts, and + * the block renderer supplies the two containers. */ /** @@ -1780,8 +1769,7 @@ export type SubmitBehavior = * * - **[spec-aligned]** — same name & semantics as `@objectstack/spec` * `FormViewSchema` (`title`, `description`, `layout`, `columns`, `sections`, - * `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, - * `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, + * `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `drawerSide`/`drawerWidth`, * `modalSize`, `subforms`, `submitBehavior`; `formType` ↔ spec `type`). * - **[ObjectUI extension]** — serializable renderer extras with no spec * backing yet (button visibility/labels, `className`, `initialValues`, @@ -1818,19 +1806,22 @@ export interface ObjectFormSchema extends BaseSchema { dataSource?: ElementDataSource; /** - * Form variant type. - * Aligns with @objectstack/spec FormView.type - * + * Form layout, or the container the `object-form` block opens it in. + * The layouts align with @objectstack/spec FormView.type: + * * - `simple` – Standard flat form (default) * - `tabbed` – Sections as tabs * - `wizard` – Multi-step wizard with progress indicator - * - `split` – Side-by-side panel layout (reserved) - * - `drawer` – Slide-out form (reserved) - * - `modal` – Dialog form (reserved) + * - `drawer` – The block opens the form in a slide-out drawer + * - `modal` – The block opens the form in a dialog + * + * `split` was removed with its renderer (ADR-0050, objectstack#27159). + * `ObjectForm` itself renders the three layouts; `drawer` / `modal` are + * read by the block renderer, which hands the node to `ObjectFormContainer`. * * @default 'simple' */ - formType?: 'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'; + formType?: 'simple' | 'tabbed' | 'wizard' | 'drawer' | 'modal'; /** * ObjectQL object name (e.g., 'users', 'accounts', 'contacts') @@ -2132,7 +2123,7 @@ export interface ObjectFormSchema extends BaseSchema { * returned record is passed on to `onSuccess`. * * In `edit` mode, for a record the form read itself, every layout — the - * simple form, `tabbed`, `wizard`, `split`, `modal` and `drawer`, and a + * simple form, `tabbed`, `wizard`, `modal` and `drawer`, and a * simple form that the mobile `stepper` option routes through the wizard — * hands over what the form would have written (objectui#10156, * objectui#10563): the fields that differ from the record it read, or the @@ -2206,27 +2197,6 @@ export interface ObjectFormSchema extends BaseSchema { */ className?: string; - // ─── Split Form Props ────────────────────────────────── - - /** - * Split panel direction. Only used when formType is 'split'. - * @default 'horizontal' - */ - splitDirection?: 'horizontal' | 'vertical'; - - /** - * Size of the left/top panel in the split layout (percentage 1-99). - * Only used when formType is 'split'. - * @default 50 - */ - splitSize?: number; - - /** - * Whether the split panels can be resized. Only used when formType is 'split'. - * @default true - */ - splitResizable?: boolean; - // ─── Drawer Form Props ───────────────────────────────── /** @@ -2242,13 +2212,29 @@ export interface ObjectFormSchema extends BaseSchema { * function, NOT authorable metadata: JSON has no function value, so the zod * twin refuses this key by name and points at the node-type spelling. Kept * callable here because it is READ and RUN by the registered renderer. - * The read: `ObjectForm` forwards it onto the drawer / modal node - * (`onOpenChange: schema.onOpenChange`). Supplied by `AppContent` + * The read: the block's container (`ObjectFormContainer`) forwards it onto + * the drawer / modal node (`onOpenChange: schema.onOpenChange`). Supplied by `AppContent` * (`if (!open) closeRecordForm()`) in `@object-ui/app-shell` and by * `ObjectManager` / `FieldDesigner` (`handleFormClose`) in * `@object-ui/plugin-designer`. */ onOpenChange?: (open: boolean) => void; + + // ─── Retired split props (objectstack#27159) ─────────── + + /** + * RETIRED with `formType: 'split'` (ADR-0050, objectstack#27159): the + * `object-form` row's own member BY REFERENCE, a retired-key tombstone that + * refuses every value by name, as `ObjectGridSchema.defaultFilters` is. The + * split renderer that read it was deleted with the value. + */ + splitDirection?: SpecObjectFormProps['splitDirection']; + + /** RETIRED with `formType: 'split'` — see {@link ObjectFormSchema.splitDirection}. */ + splitSize?: SpecObjectFormProps['splitSize']; + + /** RETIRED with `formType: 'split'` — see {@link ObjectFormSchema.splitDirection}. */ + splitResizable?: SpecObjectFormProps['splitResizable']; /** * Drawer slide-in side. Only used when formType is 'drawer'. diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index b5df2689188..99504c68388 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -902,7 +902,6 @@ const ObjectFormSectionEntrySchema = z.looseObject({ collapsible: z.boolean().optional().describe('Whether the section can be collapsed (dropped on a wizard step)'), collapsed: z.boolean().optional().describe('Whether the section starts collapsed (dropped on a wizard step)'), columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional().describe('Field columns in this section'), - pane: z.enum(['primary', 'secondary']).optional().describe('Split-form panel this section renders in (split forms only)'), fields: z.array(z.any()).optional().describe('Field names or inline field configurations; the other way to declare members is `group`'), group: z.string().optional().describe('Key of a declared object field group whose members and presentation this section inherits'), visibleWhen: z @@ -1014,8 +1013,8 @@ export const ObjectFormSchema = BaseSchema.extend({ // dialect out, so it is never mirrored as a string) and `open` (a boolean that // only in-code hosts write). Their routes are open on objectui#6152, and the // pair's `UnmirroredDeclared` entry keeps both. - formType: z.enum(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']).optional() - .describe('Form variant: simple (default), tabbed, wizard, split, drawer or modal'), + formType: z.enum(['simple', 'tabbed', 'wizard', 'drawer', 'modal']).optional() + .describe('Form layout — simple (default), tabbed or wizard — or the block container it opens in: drawer or modal'), sections: z.array(ObjectFormSectionEntrySchema).optional() .describe('Form sections: tabs of a tabbed form, steps of a wizard (array order is step order), groups of a simple form'), defaultTab: z.string().optional().describe('Initially active tab (section name); tabbed forms only'), @@ -1026,9 +1025,13 @@ export const ObjectFormSchema = BaseSchema.extend({ .describe('Next-step button label; wizard forms only. @objectstack/spec I18nLabel, a plain string or an inline locale map'), prevText: stripImportedDefaults(SpecI18nLabelSchema).optional() .describe('Previous-step button label; wizard forms only. @objectstack/spec I18nLabel, a plain string or an inline locale map'), - splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split panel direction; split forms only'), - splitSize: z.number().optional().describe('Size of the first panel as a percentage; split forms only'), - splitResizable: z.boolean().optional().describe('Whether the split panels can be resized; split forms only'), + // RETIRED with `formType: 'split'` (ADR-0050, objectstack#27159): the + // `object-form` row's own members BY REFERENCE — retired-key tombstones that + // refuse every value by name, as `ObjectGridSchema.defaultFilters` is. The + // split renderer that read them was deleted with the value. + splitDirection: stripImportedDefaults(SpecObjectFormPropsSchema).shape.splitDirection, + splitSize: stripImportedDefaults(SpecObjectFormPropsSchema).shape.splitSize, + splitResizable: stripImportedDefaults(SpecObjectFormPropsSchema).shape.splitResizable, drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer slide-in side; drawer forms only'), drawerWidth: z.string().optional().describe('Drawer width as a CSS value; drawer forms only'), modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal dialog size; modal forms only'),