From f7b1a6d5e83fd77c74d21cae0c52bc8039447535 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:01:28 +0000 Subject: [PATCH 01/14] wip(plugin-form): retire SplitForm; move drawer/modal containers to the object-form block --- ui/packages/plugin-form/src/FormSection.tsx | 7 +- .../plugin-form/src/MasterDetailForm.tsx | 2 +- ui/packages/plugin-form/src/ObjectForm.tsx | 162 +---- .../plugin-form/src/ObjectFormContainer.tsx | 140 ++++ ui/packages/plugin-form/src/SplitForm.tsx | 620 ------------------ ui/packages/plugin-form/src/TabbedForm.tsx | 6 +- ui/packages/plugin-form/src/autoLayout.ts | 4 +- ui/packages/plugin-form/src/fieldWriteGate.ts | 4 +- ui/packages/plugin-form/src/index.tsx | 32 +- ui/packages/plugin-form/src/loadFailure.ts | 4 +- ui/packages/plugin-form/src/occSave.tsx | 2 +- .../plugin-form/src/recordInvalidation.ts | 2 +- ui/packages/plugin-form/src/sectionFields.ts | 2 +- ui/packages/plugin-form/src/sectionGroups.ts | 5 +- .../src/sectionPredicateDiagnostic.ts | 8 +- 15 files changed, 216 insertions(+), 784 deletions(-) create mode 100644 ui/packages/plugin-form/src/ObjectFormContainer.tsx delete mode 100644 ui/packages/plugin-form/src/SplitForm.tsx 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.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index 275fce164d7..2f32be65401 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, @@ -226,10 +223,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) ─── @@ -237,7 +247,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 @@ -274,7 +284,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 @@ -341,15 +352,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`, @@ -447,10 +466,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 @@ -532,115 +551,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 ; }; @@ -1342,7 +1252,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/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..5aa5a5f39f1 100644 --- a/ui/packages/plugin-form/src/TabbedForm.tsx +++ b/ui/packages/plugin-form/src/TabbedForm.tsx @@ -93,9 +93,9 @@ 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 + * 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/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/fieldWriteGate.ts b/ui/packages/plugin-form/src/fieldWriteGate.ts index c4c409e1627..f2c11e1000f 100644 --- a/ui/packages/plugin-form/src/fieldWriteGate.ts +++ b/ui/packages/plugin-form/src/fieldWriteGate.ts @@ -295,8 +295,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/index.tsx b/ui/packages/plugin-form/src/index.tsx index f0966cebed0..d2ec0bdc3fd 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`, or `subforms` 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,12 @@ 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 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`, which persisted through + // `dataSource.create` instead of the batch, 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/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/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/sectionFields.ts b/ui/packages/plugin-form/src/sectionFields.ts index f4574d3110e..d3ec4a28597 100644 --- a/ui/packages/plugin-form/src/sectionFields.ts +++ b/ui/packages/plugin-form/src/sectionFields.ts @@ -8,7 +8,7 @@ /** * Shared section-field normalizer for every `object-form` arm that takes - * sections: Tabbed / Wizard / Split / Drawer / Modal, and — since + * sections: Tabbed / Wizard / Drawer / Modal, and — since * objectui#10475, through `SectionFieldsContext.pool` — the default arm * (`SimpleObjectForm`). * diff --git a/ui/packages/plugin-form/src/sectionGroups.ts b/ui/packages/plugin-form/src/sectionGroups.ts index 1576b8c487d..be91abe756c 100644 --- a/ui/packages/plugin-form/src/sectionGroups.ts +++ b/ui/packages/plugin-form/src/sectionGroups.ts @@ -83,9 +83,10 @@ function restatesGroupOwnedKey(section: ObjectFormSection, key: string): boolean /** * The keys the spec PERMITS beside `group`. They describe how THIS form lays * the section out, which is the form's business and not the group's, so the - * authored value wins over the derived section. + * authored value wins over the derived section. (`pane` left with the split + * form, ADR-0050: `@objectstack/spec` refuses it on every section since 18.3.0.) */ -export const SECTION_LAYOUT_KEYS = ['columns', 'pane'] as const; +export const SECTION_LAYOUT_KEYS = ['columns'] as const; /** Diagnostics already emitted, keyed so one typo is loud once, not per render. */ const reported = new Set(); 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.'; } From 83464070212a9acbd0508f21541a9b43292d6d0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:58:48 +0000 Subject: [PATCH 02/14] feat(plugin-form,spec)!: ObjectForm lays the form out; the object-form block opens drawer/modal; split retires ADR-0050 Decision 4, the renderer half of the form-view narrowing. ObjectForm keeps the simple, tabbed and wizard layouts; its drawer and modal arms move unchanged into ObjectFormContainer, which the registered object-form renderer uses for formType drawer/modal. SplitForm and the split-only keys retire: the object-form block refuses formType 'split' and tombstones splitDirection, splitSize, splitResizable and section pane, and FormView.splitDirection is tombstoned too. The protocol-18 form-view-presentation-types-removed conversion now also reaches object-form page blocks and splitDirection. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- .../27155-form-view-type-layout-only.md | 2 +- .changeset/27159-object-form-split-retired.md | 29 +++ content/docs/references/api/protocol.mdx | 16 +- content/docs/references/ui/component.mdx | 22 +- content/docs/references/ui/view.mdx | 40 ++-- packages/spec/authorable-surface/ui.json | 8 +- packages/spec/liveness/state-counts/view.md | 2 +- packages/spec/liveness/view.json | 8 +- packages/spec/src/conversions/registry.ts | 126 +++++++++-- .../18.ui__FormView__splitDirection.ts | 7 + .../18.ui__ObjectFormProps__splitDirection.ts | 7 + .../18.ui__ObjectFormProps__splitResizable.ts | 7 + .../18.ui__ObjectFormProps__splitSize.ts | 7 + .../18.ui-form-view-type-layout-only.ts | 23 +- .../retired-key-removal-version.pin.test.ts | 17 ++ ...m-custom-fields-sections-typed.pin.test.ts | 12 +- packages/spec/src/ui/component.zod.ts | 92 ++++++-- .../form-view-type-layout-retirement.test.ts | 172 ++++++++++++--- packages/spec/src/ui/react-blocks.ts | 7 +- .../spec/src/ui/view-metadata-schema.test.ts | 4 +- packages/spec/src/ui/view.zod.ts | 39 ++-- scripts/sdui-manifest.record.json | 2 +- sdui.manifest.json | 22 +- .../objectstack-ui/references/react-blocks.md | 11 +- ui/apps/console/src/components/FormPage.tsx | 8 +- ui/content/docs/api/schema-reference.md | 6 +- ui/content/docs/plugins/plugin-form.mdx | 38 ++-- .../app-shell/src/views/RecordFormPage.tsx | 15 +- .../previews/PageBlockCanvas.test.tsx | 2 +- .../metadata-admin/previews/block-config.ts | 1 - .../previews/form-preview.test.ts | 6 +- .../metadata-admin/previews/form-preview.ts | 2 +- ui/packages/i18n/src/namespaces/studio/en.ts | 1 - ui/packages/i18n/src/namespaces/studio/zh.ts | 1 - ui/packages/plugin-form/README.md | 41 ++-- .../src/ObjectForm.i18nLabels.test.tsx | 2 +- .../plugin-form/src/PreviewMode.test.tsx | 8 +- ui/packages/plugin-form/src/TabbedForm.tsx | 4 +- .../collapseResolution-9849.test.tsx | 18 +- .../drawerFirstOpenStays-13238.test.tsx | 5 +- ...drawerFormSectionDescription-9834.test.tsx | 6 +- .../drawerInitialFieldFocus-13219.test.tsx | 5 +- ...rawerModalCustomFieldsMerge-10073.test.tsx | 6 +- .../fieldGroupFaultClosed-13055.test.tsx | 8 +- .../formSectionGroupReference-7051.test.tsx | 15 +- ...eadinglessCollapseDiagnostic-9849.test.tsx | 10 +- .../initialRecordMerge-9760.test.tsx | 2 - .../inlineSectionEntry-11615.test.tsx | 8 +- .../mixedSectionColumns-12548.test.tsx | 7 +- .../__tests__/modalCloseButton-11061.test.tsx | 5 +- .../modalInitialFieldFocus-13130.test.tsx | 5 +- .../objectFormContainer-27159.test.tsx | 131 +++++++++++ .../objectFormSectionMembers-8071.test.tsx | 11 +- .../sectionEntryOverrides-10475.test.tsx | 8 +- .../__tests__/sectionFaultCarry-13055.test.ts | 6 +- .../src/__tests__/sectionLabelI18n.test.tsx | 6 +- ...ionPredicateLayoutDiagnostic-6237.test.tsx | 26 ++- .../sectionStyleKeysRetired-13626.test.tsx | 14 +- .../sectionVisibleWhen-6111.test.tsx | 18 +- .../sectionsCustomFields-10254.test.tsx | 8 +- .../serverDefaultHint-12108.test.tsx | 1 - .../undeclaredSectionDensity-12592.test.tsx | 7 +- .../src/affordanceGrantMap-12082.test.tsx | 2 - .../src/closedAffordanceNotice-11000.test.tsx | 1 - .../plugin-form/src/createDefaults.test.tsx | 2 - .../src/createFormGrant-12082.test.tsx | 1 - .../src/createOwnerPick-12359.test.tsx | 1 - .../src/formArmsFieldGate-10612.test.tsx | 1 - .../src/formArmsInvalidation-10715.test.tsx | 1 - .../src/formArmsWritePayload-10563.test.tsx | 1 - .../src/formChrome.i18n-11039.test.tsx | 7 +- ui/packages/plugin-form/src/index.tsx | 11 +- .../masterDetailFormTypeVocabulary.test.tsx | 64 ++---- .../src/recordFormsErrorClears-10682.test.tsx | 1 - .../src/recordSwapLoading.test.tsx | 4 +- .../plugin-form/src/sectionColumns.test.tsx | 32 +-- .../src/sectionedFormValues.test.tsx | 207 +----------------- .../src/submitHandlerSeam.test.tsx | 23 +- .../src/submitTargetRefusal.test.tsx | 28 +-- .../src/supersededReads-10712.test.tsx | 1 - .../src/uploadInFlightSave.test.tsx | 2 - ...form-section-style-keys-undeclared.test.ts | 8 +- ...bject-form-unmirrored-members-6152.test.ts | 8 +- .../object-view-slot-key-lists.test.ts | 3 +- .../src/__tests__/p1-spec-alignment.test.ts | 10 +- ui/packages/types/src/objectql.ts | 92 ++++---- ui/packages/types/src/zod/objectql.zod.ts | 15 +- 87 files changed, 960 insertions(+), 719 deletions(-) create mode 100644 .changeset/27159-object-form-split-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__splitDirection.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitDirection.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitResizable.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectFormProps__splitSize.ts create mode 100644 ui/packages/plugin-form/src/__tests__/objectFormContainer-27159.test.tsx diff --git a/.changeset/27155-form-view-type-layout-only.md b/.changeset/27155-form-view-type-layout-only.md index e7e508f7734..7f2402ff322 100644 --- a/.changeset/27155-form-view-type-layout-only.md +++ b/.changeset/27155-form-view-type-layout-only.md @@ -18,6 +18,6 @@ The model: `type` says how the form lays out its sections. Where a form opens is - **FROM → TO.** `type: 'split'` → `type: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the form's `splitSize` / `splitResizable`; declare `subforms` to edit child records beside the parent. `type: 'drawer'` or `type: 'modal'` → delete the value for `simple`, or write the layout you want. A list opens a clicked record as its `navigation.mode` says (the drawer by default), and the console's New and Edit entries open the record form themselves, sized by the form's `modalSize`. - **The one-line fix.** Delete the retired value and the three split-only keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-presentation-types-removed` conversion), so a form view saved with one of the three values keeps loading as a `simple` form. - **What the parse says.** Each retired value is refused by name with its prescription, for example "'modal' was removed from the form view `type` enum in @objectstack/spec 18.3.0 (ADR-0050) — …", at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. `pane`, `splitSize` and `splitResizable` are refused on every layout. TypeScript rejects each at the authoring site. A misspelt value keeps zod's own message. -- **Unchanged.** `splitDirection`, `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes the other three from this schema until its renderer follows. +- **Unchanged.** `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the `object-form` block's drawer container reads the other two. `splitDirection` left later in the same release, with the split renderer (#27159). The console (`@object-ui/app-shell`): Studio's New View form offers the three form layouts only. It offered all six, and a form view created with `split`, `drawer` or `modal` would now be refused when saved. diff --git a/.changeset/27159-object-form-split-retired.md b/.changeset/27159-object-form-split-retired.md new file mode 100644 index 00000000000..b8eb731062a --- /dev/null +++ b/.changeset/27159-object-form-split-retired.md @@ -0,0 +1,29 @@ +--- +'@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`, 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. +- **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 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`). +- **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/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 59bd354a877..200a88b46a0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1905,12 +1905,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`. | @@ -1984,12 +1984,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 6202a20c497..72e36e50a4e 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] `object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one 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. | | **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 | @@ -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] `object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one 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. | | **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 d1b4ef1a9c9..530aa10ec3f 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/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 11393526b2f..8599e2fc5df 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 41503a0411f..ea2520c043e 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 b7cf6f8a056..873ee47e237 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8700,10 +8700,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} @@ -8724,6 +8734,12 @@ 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. */ const formViewPresentationTypesRemoved: MetadataConversion = { id: 'form-view-presentation-types-removed', @@ -8732,12 +8748,17 @@ 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', 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). 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 => { @@ -8759,11 +8780,29 @@ 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') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const next = convertBlock(properties, `${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; @@ -8778,14 +8817,44 @@ 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' } }, + // 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, @@ -8813,13 +8882,36 @@ 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: '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'] }, @@ -8836,10 +8928,12 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { name: 'fv_task.side', object: 'fv_task', viewKind: 'form' }, ], }, - // `beside`: the member, `splitSize`, `splitResizable` and two panes (5); - // `quick`, `dialog`, the item record and the assembled overlay: one member - // each (4). The tabbed form and the list emit none. - expectedNotices: 9, + // The split block: the member, three keys and two panes (6). `beside`: the + // member, `splitDirection`, `splitSize`, `splitResizable` and two panes + // (6); `quick`, `dialog`, the item record and the assembled overlay: one + // member each (4). The drawer block, the non-form component, the tabbed + // form and the list emit none. + expectedNotices: 16, }, }; 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..cc9e29ca2f2 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)', 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). 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`; 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 3bb630632d6..5fdb58d52d0 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 @@ -107,6 +107,7 @@ import { ElementRecordPickerPropsSchema, ElementButtonPropsSchema, ElementRepeaterPropsSchema, + ObjectFormPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, PageHeaderProps, @@ -404,6 +405,22 @@ 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', + ), ], // #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 f80182df692..5b01cfec5cb 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6236,6 +6236,46 @@ 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'); +const OBJECT_FORM_SECTION_PANE_RETIRED = + '`object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it ' + + "placed a section in one pane 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; + /** * [#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 @@ -6837,7 +6877,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(), @@ -6857,13 +6898,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`). @@ -6884,8 +6925,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, @@ -6925,20 +6974,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` @@ -7029,8 +7081,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 " 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..c2c3f1bc7ac 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 } 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,110 @@ 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); + } + }); + + it('a section `pane` is a tombstone on the block', () => { + const r = ObjectFormPropsSchema.safeParse({ ...BLOCK, formType: 'tabbed', 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( + "`object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a " + + "section in one pane of a `formType: 'split'` form", + ), issues[0]!.message).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); + }); +}); + + 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 +380,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 +388,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 +450,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 +470,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 +507,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 +545,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 +554,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 67b847316ed..d3e8fec2f9b 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 0c2f6a49a0f..0431534b341 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4509,7 +4509,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: @@ -4529,16 +4529,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 ' @@ -4686,32 +4692,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 2f314dd051d..c8b1c9c48ff 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-28", - "sha256": "de11057952187fdd2b100a98f3ce1a4f123b76be013bb0eaa754346904b3fdde", + "sha256": "760674a43020360142d94b2a8e051faa0d4cf640479a3d7ca4efb51f59f91fc4", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index 9b217aa148b..cb2bb021a5d 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -482,10 +482,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`, or `subforms` to edit child records beside the parent." }, { "name": "sections", @@ -542,22 +542,6 @@ "name": "showStepIndicator", "type": "boolean" }, - { - "name": "splitDirection", - "type": "enum", - "enum": [ - "horizontal", - "vertical" - ] - }, - { - "name": "splitSize", - "type": "number" - }, - { - "name": "splitResizable", - "type": "boolean" - }, { "name": "drawerSide", "type": "enum", @@ -586,7 +570,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 02d606b34f1..10169139f95 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/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 9468e86f073..78c8604c5e8 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/content/docs/api/schema-reference.md b/ui/content/docs/api/schema-reference.md index fe92e732b45..5458c36452c 100644 --- a/ui/content/docs/api/schema-reference.md +++ b/ui/content/docs/api/schema-reference.md @@ -858,7 +858,7 @@ the spec's row did not declare until objectstack#20694; it is authorable in the ### ObjectFormSchema -A smart form that auto-generates fields from an ObjectQL object. Supports simple, tabbed, wizard, split, drawer, and modal layouts. +A smart form that auto-generates fields from an ObjectQL object. It lays its fields out as a simple, tabbed or wizard form, and the block can open that form in a drawer or a modal. An authored `object-form` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-form']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#10859). `SchemaRenderer` hoists the bag onto the node before `ObjectForm` runs, so `ObjectFormSchema` is the node as the renderer reads it, and the table below lists its members. @@ -895,7 +895,7 @@ An authored `object-form` node takes its props in its `properties` bag, whose me |----------|------|-------------| | `objectName` | `string` | **Required.** ObjectQL object API name. | | `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. | -| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. | +| `formType` | `string` | The form's layout — `"simple"` (default), `"tabbed"`, `"wizard"` — or the container the block opens it in: `"drawer"`, `"modal"`. Only the three layouts are `@objectstack/spec` `FormViewSchema.type` values; the two containers belong to the `object-form` block, whose renderer opens them (ADR-0050). `"split"` was removed with its renderer (ADR-0050) and the spec refuses it with a prescription. | | `recordId` | `string \| number` | Record ID for edit/view modes. | | `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | | `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | @@ -916,7 +916,7 @@ An authored `object-form` node takes its props in its `properties` bag, whose me `ObjectFormSchema` keys fall into four classes (#2545): -- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). +- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type` for the three layouts; `drawer` and `modal` are the block's own). `splitDirection`/`splitSize`/`splitResizable` were removed with `formType: "split"` (ADR-0050) and are refused by name. - **Component-contract keys** — not on `FormViewSchema`, but members of the spec's `ComponentPropsMap['object-form']` row, the `object-form` block's own props contract: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `initialValues`, `fields`, `customFields`. `submitText`, `cancelText`, `nextText`, `prevText` and `successMessage` are `I18nLabel` there, as `title` and `description` are. This list was read off the installed `@objectstack/spec`'s `ComponentPropsMap['object-form']` row; nothing re-derives it, so check that row itself before relying on it. - **Envelope key** — `className` is on neither `FormViewSchema` nor the `object-form` row, but on the spec's `PageComponentSchema`, the envelope every page node carries beside `type` and `properties`. - **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). diff --git a/ui/content/docs/plugins/plugin-form.mdx b/ui/content/docs/plugins/plugin-form.mdx index f119a5aae0c..a01f57077a8 100644 --- a/ui/content/docs/plugins/plugin-form.mdx +++ b/ui/content/docs/plugins/plugin-form.mdx @@ -172,14 +172,14 @@ A sectioned form renders as ONE grid. The form view's `columns` (spec densely that section fills it. The view's value wins; without it the grid takes the widest section's density, and without either it is single-column. -`ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm` and `WizardForm` all -resolve it that way, so the same metadata lays out identically in every host +`ObjectForm` (simple), `ModalForm`, `TabbedForm` and `WizardForm` all resolve it +that way, so the same metadata lays out identically in every host (`WizardForm` has no widest-section fallback — its steps never share a viewport, so each keeps its own authored width). The grid is applied to the field container inside the form, never wrapped around the ``. -One default differs: a dialog. `ModalForm`, whether `ObjectForm` opens it with -`formType: 'modal'` or a host mounts it directly, draws two fields per row from +One default differs: a dialog. `ModalForm`, whether an `object-form` block opens +it with `formType: 'modal'` or a host mounts it directly, draws two fields per row from the container's `@md` tier up wherever nothing declares `columns`: the flat field list, the object's `fieldGroups`, and every section that declares no `columns` in a form that declares none. Such a section draws two per row beside @@ -352,9 +352,8 @@ the object's `fieldGroups`) follow one rule for a section's divider row: - A section's **own settings apply whether or not it has a heading.** Its `visibleWhen` hides the whole group, fields included, on these three - layouts. A section with no `label` is never un-gated there. (The split - layout still draws and gates a section's row only when it has a `label` or a - `description`, and the wizard drops a step predicate, as described above.) + layouts. A section with no `label` is never un-gated there. (The wizard + drops a step predicate, as described above.) - The **divider row is drawn when the section has a `label` or a `description`**. With a `description` alone it is a blurb-only row: no heading, just the blurb. @@ -422,11 +421,20 @@ 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` builds on this. Each section declares its panel via `pane: 'primary' -| 'secondary'` (spec `FormSection.pane`) — explicit placement that survives -reordering. When omitted, the legacy rule applies: section 1 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). `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). `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 +gets the inline form and opens its own drawer or dialog around it. ## Usage @@ -481,7 +489,7 @@ 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`, +`TabbedForm`, `WizardForm`, `DrawerForm`, `ModalForm`, `EmbeddableForm`, `MasterDetailForm`, `LineItemsPanel`, `FormAnalytics`, `FormSectionContainer` — alongside the layout helpers (`applyAutoLayout`, `inferColumns`, `filterCreateModeFields`, …) and the per-container schema types @@ -492,7 +500,7 @@ aggregate map among them. An `object-form` in `mode: 'edit'` reads its record with `findOne`, and its save writes only the fields that differ from that read. That covers every layout — the -simple form and the `tabbed`, `wizard`, `split`, `modal` and `drawer` variants — +simple form and the `tabbed`, `wizard`, `modal` and `drawer` variants — as well as a simple form whose mobile `stepper` option routes it through the wizard, and the parent operation of a master-detail form. Every layout also strips what a form never writes, on a create as on an edit: server-owned, @@ -674,7 +682,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/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 1dcf095a254..55524cce91f 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -754,7 +754,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 a4386de6a92..1726d801e17 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -642,7 +642,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 b9283807275..e4123ec897a 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/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/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/TabbedForm.tsx b/ui/packages/plugin-form/src/TabbedForm.tsx index 5aa5a5f39f1..924b30018df 100644 --- a/ui/packages/plugin-form/src/TabbedForm.tsx +++ b/ui/packages/plugin-form/src/TabbedForm.tsx @@ -95,8 +95,8 @@ export interface FormSectionConfig { * step by default. `WizardStepConfig` is now declared independently, exactly * 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. + * 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 1ba6b1f8e28..208009ff962 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -820,8 +820,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/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/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 d2ec0bdc3fd..3f08a7f2dea 100644 --- a/ui/packages/plugin-form/src/index.tsx +++ b/ui/packages/plugin-form/src/index.tsx @@ -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 three 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`, which persisted through - // `dataSource.create` instead of the batch, retired with ADR-0050.) + // 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/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/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/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( - `, 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..c32996991ce 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. @@ -122,12 +122,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 +141,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 17712667063..7680a959231 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 256ad8f64ce..46e4bdc46ff 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') @@ -2133,7 +2124,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 @@ -2207,27 +2198,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 ───────────────────────────────── /** @@ -2243,13 +2213,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 97a0b0504b9..be9974aa29f 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'), From cd51d873b2a4fe962e214c116b9c96a5a52da7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:03:21 +0000 Subject: [PATCH 03/14] chore(spec): regenerate the reference docs after merging main Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 200a88b46a0..61abebef398 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -899,7 +899,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1757,7 +1757,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **object** | `string` | ✅ | Object name | | **recordId** | `string` | ✅ | The record the projection was judged on | | **readable** | `string[]` | ✅ | Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection | -| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership) and a field's own rules (readonly, computed types) are not part of it | +| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership; see `writable`) and a field's own rules (readonly, computed types) are not part of it | +| **writable** | `boolean` | ✅ | Whether the caller may update this record at all, judged by the write gate on the stored row: the object's edit permission, record sharing and ownership, row-level security, and a parent record's edit access for a record controlled by its parent. False means an update that leaves the record as it is stored is refused: either the caller may not edit this record, or the stored record fails a row-level security check that every save must satisfy, which a save moving the checked value back inside that check can still pass. True does not promise that a save succeeds: a pending approval that locks the record, conditional read-only and validation rules on the values sent, a data source that refuses writes, the platform's own rules for its own records (comments, attachments, mail messages, approval requests) and an object the API serves read-only are judged when the record is saved | --- From 6f5071c39eb1b8da50abfa1efb7e42d915928c36 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:21:15 +0000 Subject: [PATCH 04/14] test(types): drop pane from the ObjectFormSection key census Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- .../__tests__/object-form-section-style-keys-undeclared.test.ts | 1 - 1 file changed, 1 deletion(-) 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 c32996991ce..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 @@ -84,7 +84,6 @@ const DECLARED_KEYS = [ 'collapsible', 'collapsed', 'columns', - 'pane', 'fields', 'group', 'visibleWhen', From 73eb8292e8b2fea72a4b23c97adb6c183d2c0e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:50:44 +0000 Subject: [PATCH 05/14] chore: hold the generated skills reference at main's bytes while the PR opens Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- skills/objectstack-ui/references/react-blocks.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 10169139f95..02d606b34f1 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' \| '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). | +| `formType` | `'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'` | binding | | Form presentation; drawer/modal render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize). | | `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 close button of the block's modal container (formType 'modal'). | +| `modalCloseButton` | `boolean` | binding | | Show the modal presentation's close button (formType 'modal'; honoured by the modal form). | | `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,9 +44,10 @@ Server-connected create/edit/view form for one object. Config props come from th | `layout` | `'vertical' \| 'horizontal'` | data | | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | `columns` | `integer` | data | | Number of columns for the form body | | `tabPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Tab strip position (tabbed forms) | -| `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| `drawerWidth` | `string` | data | | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chos… | -| `modalSize` | `'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'` | data | | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal… | +| `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) | | `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. | From 235fc7d74ff5e783bdfb45e4df3739aa452e5588 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:51:53 +0000 Subject: [PATCH 06/14] chore: restore the regenerated skills reference Reverts the hold commit now the PR is open: the React ObjectForm contract regenerated by gen:react-blocks. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- skills/objectstack-ui/references/react-blocks.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 02d606b34f1..10169139f95 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. | From 9a7426476b0ef53f557fc9cab5fa84e4f57fe9c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:58:06 +0000 Subject: [PATCH 07/14] chore(changeset): answer ADR-0087 with the existing entries; leave the #27155 note as written Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- .changeset/27155-form-view-type-layout-only.md | 2 +- .changeset/27159-object-form-split-retired.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/27155-form-view-type-layout-only.md b/.changeset/27155-form-view-type-layout-only.md index 7f2402ff322..e7e508f7734 100644 --- a/.changeset/27155-form-view-type-layout-only.md +++ b/.changeset/27155-form-view-type-layout-only.md @@ -18,6 +18,6 @@ The model: `type` says how the form lays out its sections. Where a form opens is - **FROM → TO.** `type: 'split'` → `type: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the form's `splitSize` / `splitResizable`; declare `subforms` to edit child records beside the parent. `type: 'drawer'` or `type: 'modal'` → delete the value for `simple`, or write the layout you want. A list opens a clicked record as its `navigation.mode` says (the drawer by default), and the console's New and Edit entries open the record form themselves, sized by the form's `modalSize`. - **The one-line fix.** Delete the retired value and the three split-only keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-presentation-types-removed` conversion), so a form view saved with one of the three values keeps loading as a `simple` form. - **What the parse says.** Each retired value is refused by name with its prescription, for example "'modal' was removed from the form view `type` enum in @objectstack/spec 18.3.0 (ADR-0050) — …", at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. `pane`, `splitSize` and `splitResizable` are refused on every layout. TypeScript rejects each at the authoring site. A misspelt value keeps zod's own message. -- **Unchanged.** `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the `object-form` block's drawer container reads the other two. `splitDirection` left later in the same release, with the split renderer (#27159). +- **Unchanged.** `splitDirection`, `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes the other three from this schema until its renderer follows. The console (`@object-ui/app-shell`): Studio's New View form offers the three form layouts only. It offered all six, and a form view created with `split`, `drawer` or `modal` would now be refused when saved. diff --git a/.changeset/27159-object-form-split-retired.md b/.changeset/27159-object-form-split-retired.md index b8eb731062a..6a87411af0e 100644 --- a/.changeset/27159-object-form-split-retired.md +++ b/.changeset/27159-object-form-split-retired.md @@ -13,7 +13,7 @@ Clause-②: no (narrowing) Metadata-protocol: narrowed (the `object-form` block's `formType` refuses `split`; its `splitDirection`, `splitSize`, `splitResizable` and `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. @@ -26,4 +26,5 @@ The model: a form's `formType` says how it lays out its sections. Where it opens - **`@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. From 1f16817acee4559a492cbc57e76f90315db8405a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 16:01:09 +0000 Subject: [PATCH 08/14] docs: regenerate the SDUI component reference from the manifest The object-form block's formType row drops split and its input count is 37, matching sdui.manifest.json. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/plugins.mdx | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index ccea5b3fb07..4c14a731392 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 ee672621c97..0213007ca09 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -130,7 +130,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`, or `subforms` 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`. | @@ -140,13 +140,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`. | From f282e8a966d52be3f95f34d415d2734501846076 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:32:54 +0000 Subject: [PATCH 09/14] test(console): the #11605 sectioned-form rows drop the retired split split left the object-form block's formType with ADR-0050, so the inline-sections control no longer runs it as a sectioned variant. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- .../__tests__/objectname-neither-hint-11605.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 })), From ee84fa2b701334aa19070605b2fcc28bd6076956 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:24:36 +0000 Subject: [PATCH 10/14] fix(spec): the form-view conversion strips a master-detail block's section pane object-master-detail-form shares the object-form section shape, so its sections[].pane parsed through 18.2.0 and is a tombstone now. A stored master-detail page would have failed at parse after the upgrade. convertBlock's page walk now strips sections[].pane on that block too (fixture row, expectedNotices 17), the D3 entry names it, and the pane prescription is worded for both blocks. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 6 +-- packages/spec/src/conversions/registry.ts | 41 +++++++++++---- .../18.ui-form-view-type-layout-only.ts | 6 +-- .../retired-key-removal-version.pin.test.ts | 6 +++ packages/spec/src/ui/component.zod.ts | 19 ++++--- .../form-view-type-layout-retirement.test.ts | 52 +++++++++++++++++-- 6 files changed, 101 insertions(+), 29 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 72e36e50a4e..c3ec83ec0c7 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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** | `never` | optional | [REMOVED] `object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one 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. | +| **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** | `never` | optional | [REMOVED] `object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a section in one 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. | +| **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/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 641db2a2609..bcd346723a3 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8739,7 +8739,10 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { * 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. + * 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', @@ -8751,13 +8754,15 @@ const formViewPresentationTypesRemoved: MetadataConversion = { + '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-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`, `splitDirection`, ' + '`splitSize` and `splitResizable`; the `object-form` page block sheds its `split` member and the same ' - + 'four keys (its `drawer` / `modal` containers stay). The retired member is deleted, which lands the ' + + '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']); @@ -8796,10 +8801,14 @@ const formViewPresentationTypesRemoved: MetadataConversion = { return stripSectionPanes(next, 'sections', path); }; const withPages = mapPageComponents(stack, (component, path) => { - if (component.type !== 'object-form') return component; + if (component.type !== 'object-form' && component.type !== 'object-master-detail-form') return component; const properties = component.properties; if (!isDict(properties)) return component; - const next = convertBlock(properties, `${path}.properties`); + // 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) => @@ -8841,6 +8850,11 @@ const formViewPresentationTypesRemoved: MetadataConversion = { }, // 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 } }, ], @@ -8900,6 +8914,10 @@ const formViewPresentationTypesRemoved: MetadataConversion = { }, }, { 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 } }, ], }, @@ -8928,12 +8946,13 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { name: 'fv_task.side', object: 'fv_task', viewKind: 'form' }, ], }, - // The split block: the member, three keys and two panes (6). `beside`: the - // member, `splitDirection`, `splitSize`, `splitResizable` and two panes - // (6); `quick`, `dialog`, the item record and the assembled overlay: one - // member each (4). The drawer block, the non-form component, the tabbed - // form and the list emit none. - expectedNotices: 16, + // 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 drawer block, the non-form component, the tabbed form and + // the list emit none. + expectedNotices: 17, }, }; 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 cc9e29ca2f2..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 @@ -23,7 +23,7 @@ export const entry: SemanticMigration = { + '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)', + + '\'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 ' @@ -40,13 +40,13 @@ export const entry: SemanticMigration = { + '`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`, `splitDirection`, ' + '`splitSize` and `splitResizable` (on an `object-form` page block it deletes `formType: \'split\'` ' - + 'and strips the same keys). What it cannot decide is the layout a former `split` form should take (`tabbed` ' + + '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 `splitDirection`, `splitSize` or `splitResizable`; no `object-form` page block ' - + 'declares `formType` `split`, `splitDirection`, `splitSize`, `splitResizable` or a section `pane`; each is refused at parse with its prescription, and ' + + '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 bac6d76c4c4..a73df63c9f4 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 @@ -110,6 +110,7 @@ import { ElementRepeaterPropsSchema, ObjectFormPropsSchema, ObjectGridPropsSchema, + ObjectMasterDetailFormPropsSchema, ObjectKanbanPropsSchema, PageHeaderProps, } from '../ui/component.zod'; @@ -431,6 +432,11 @@ const JOIN: Readonly> = { () => 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.zod.ts b/packages/spec/src/ui/component.zod.ts index 5b01cfec5cb..a0202364837 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -6270,10 +6270,15 @@ const objectFormSplitKeyRetired = (key: string, what: string): string => 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 = - '`object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it ' - + "placed a section in one pane of a `formType: 'split'` form, and `split` left the block in the same " - + 'release, so no form draws panes. Delete the key. ' + '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; /** @@ -6823,9 +6828,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`). * @@ -7255,7 +7260,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 c2c3f1bc7ac..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 @@ -51,7 +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 } from './component.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'; @@ -306,15 +306,23 @@ describe('the `object-form` block — `split` leaves `formType`, `drawer` / `mod } }); - it('a section `pane` is a tombstone on the block', () => { - const r = ObjectFormPropsSchema.safeParse({ ...BLOCK, formType: 'tabbed', sections: [{ label: 'Main', pane: 'primary', fields: ['name'] }] }) as Parse; + // 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( - "`object-form` property `sections[].pane` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it placed a " - + "section in one pane of a `formType: 'split'` form", + '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]', () => { @@ -358,6 +366,40 @@ describe('the `object-form` block — `split` leaves `formType`, `drawer` / `mod 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); + }); }); From 7cfa968d2da508b89626dccd8a76aa4b9bd6594d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:24:36 +0000 Subject: [PATCH 11/14] docs(plugin-form): name object-master-detail-form in the formType input; drop SplitForm from the export list The registered input's prescription now matches the spec's; the manifest and component reference are regenerated. The frontend plugin-form page no longer lists SplitForm / SplitFormSchema, and the platform checklist records the split keys as refused at parse. Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/frontend/plugins/plugin-form.mdx | 5 ++--- content/docs/ui/components/plugins.mdx | 2 +- docs/qa/platform-checklist/areas/studio-authoring.json | 2 +- scripts/sdui-manifest.record.json | 4 ++-- sdui.manifest.json | 2 +- ui/packages/plugin-form/src/index.tsx | 2 +- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/content/docs/frontend/plugins/plugin-form.mdx b/content/docs/frontend/plugins/plugin-form.mdx index 88354d7f6c4..6d3966e04c7 100644 --- a/content/docs/frontend/plugins/plugin-form.mdx +++ b/content/docs/frontend/plugins/plugin-form.mdx @@ -74,8 +74,7 @@ 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 @@ -103,7 +102,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/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index 0213007ca09..72a62796010 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -130,7 +130,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`, `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`, or `subforms` to edit child records beside the parent. | +| `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`. | diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index fd040360662..006b4490dc4 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -3781,7 +3781,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/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index c8b1c9c48ff..15c34906cd3 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,8 +13,8 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-28", - "sha256": "760674a43020360142d94b2a8e051faa0d4cf640479a3d7ca4efb51f59f91fc4", + "generatedAt": "2026-10-29", + "sha256": "efb88be5cfc185dfff300363ebcc0fbd67e76983e8f4b2c673fae1efa4ecba0b", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index cb2bb021a5d..fe5c13bc1a6 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -485,7 +485,7 @@ "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`, or `subforms` to edit child records beside the parent." + "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", diff --git a/ui/packages/plugin-form/src/index.tsx b/ui/packages/plugin-form/src/index.tsx index 3f08a7f2dea..facb24699d1 100644 --- a/ui/packages/plugin-form/src/index.tsx +++ b/ui/packages/plugin-form/src/index.tsx @@ -296,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', '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`, or `subforms` to edit child records beside the parent.' }, + { 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`, From 1e59a2a3e380e6a521ea32c091578d98b1cdc516 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:24:50 +0000 Subject: [PATCH 12/14] chore(changeset): name the master-detail section pane Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- .changeset/27159-object-form-split-retired.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/27159-object-form-split-retired.md b/.changeset/27159-object-form-split-retired.md index 6a87411af0e..ae8e6e812a4 100644 --- a/.changeset/27159-object-form-split-retired.md +++ b/.changeset/27159-object-form-split-retired.md @@ -11,7 +11,7 @@ feat(spec,plugin-form)!: `ObjectForm` lays a form out and no longer picks its co Clause-②: no (narrowing) -Metadata-protocol: narrowed (the `object-form` block's `formType` refuses `split`; its `splitDirection`, `splitSize`, `splitResizable` and `sections[].pane`, and `FormViewSchema.splitDirection`, are tombstones) +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) @@ -19,9 +19,9 @@ Metadata-protocol: narrowed (the `object-form` block's `formType` refuses `split 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. +- **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 and `splitDirection` too), so a block saved with `split` keeps loading as a `simple` form. +- **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. From bf2acff4aa6a37d2b7c90cef79ef9846b90a5ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:14:08 +0000 Subject: [PATCH 13/14] chore: regenerate the references, manifest record and react-blocks after merging main Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++-- content/docs/references/ui/view.mdx | 12 ++++++------ content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/plugins.mdx | 1 - scripts/sdui-manifest.record.json | 2 +- skills/objectstack-ui/references/react-blocks.md | 1 - 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 61abebef398..b0f66622adc 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1918,7 +1918,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `GetUiViewResponse.listViews[string]` @@ -1997,7 +1997,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `GetUiViewResponse.protection` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 530aa10ec3f..36a2f14f9f9 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -408,7 +408,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `FormView.data[provider='object']` @@ -504,7 +504,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **submit** | `{ show?: boolean; label?: string \| Record }` | optional | Submit button | | **cancel** | `{ show?: boolean; label?: string \| Record }` | optional | Cancel button | -| **reset** | `{ show?: boolean; label?: string \| Record }` | optional | Reset button | +| **reset** | `never` | optional | [REMOVED] `view.form.buttons.reset` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form ever drew a reset button, and its label was never read. Delete the key: a form has `submit` and `cancel` buttons only, and Cancel leaves the form without saving. `reset.show: true` used to clear the form after each save (the ObjectForm fold that ObjectView and the record form page render); a form converted by this release no longer clears after a save, and a form that should declares `submitBehavior: { kind: 'continue' }`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -1788,7 +1788,7 @@ Tab configuration for multi-tab view interface | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `View.listViews[string]` @@ -1867,7 +1867,7 @@ Tab configuration for multi-tab view interface | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `View.protection` @@ -2104,7 +2104,7 @@ This schema accepts one of the following structures: | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `ViewItem[viewKind='form'].protection` @@ -2288,7 +2288,7 @@ This schema accepts one of the following structures: | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **buttons** | `{ submit?: object; cancel?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | ### Nested Shape: `ViewItemWire[viewKind='form'].protection` diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index 4c14a731392..c0e65c6514e 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -87,7 +87,7 @@ regenerates from the renderer registry. Regenerate it with | [`ins`](/docs/ui/components/html#ins) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`label`](/docs/ui/components/html#label) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`li`](/docs/ui/components/html#li) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | -| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 42 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 41 | [Plugin Blocks](/docs/ui/components/plugins) | | [`main`](/docs/ui/components/html#main) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`mark`](/docs/ui/components/html#mark) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`markdown`](/docs/ui/components/plugins#markdown) | `plugin-markdown` | 2 | [Plugin Blocks](/docs/ui/components/plugins) | diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index 72a62796010..2256a3f040f 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -310,7 +310,6 @@ Namespace `plugin-list`. | `rowActions` | `array` of `string` | | Names of the actions offered on each row's menu. | | `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. | | `bulkActionDefs` | `array` of `object` | | Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection. | -| `options` | `object` | | Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind's own block. The top-level block of the same name is the canonical spelling. | | `kanban` | `object` | | The board, the spec's kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card. | | `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. | | `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. | diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 15c34906cd3..94309746374 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": "efb88be5cfc185dfff300363ebcc0fbd67e76983e8f4b2c673fae1efa4ecba0b", + "sha256": "ebc9662a1daea4f113b0c2e6c4d8989f821d52edb58a5bd7ec88f735984df846", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 10169139f95..5e5d0513044 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -60,7 +60,6 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `data` | `ViewData — { provider: 'object', object: string } binds an object` | binding | ✓ | The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}. Any ViewData provider is accepted (a 'value' provider renders static rows); nothing stamps the object on a react page, so this prop is required. | | `navigation` | `{ mode: 'drawer' \| 'page' \| 'new_window' \| 'none' }` | binding | | What a row click does. Use { mode: "none" } when you handle clicks via onRowClick. | | `fields` | `string[]` | binding | | Limit/order the columns shown (defaults to the object list fields). | -| `options` | `Record` | binding | | View-type-specific options bag (kanban/calendar/gantt extras); prefer the typed spec props where they exist. | | `filters` | `FilterArray e.g. ['status','=','active']` | controlled | | ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: ["and", […], […]]). | | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'new_window') => void` | callback | | Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch. | From 166b8dd90f9367a58f96016ef872492b9e496b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:34:39 +0000 Subject: [PATCH 14/14] chore: regenerate the manifest record, component references and authorable surface after merging main Claude-Session: https://claude.ai/code/session_01JQwxbWHWPRD5rZXeY69YvL Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 1 + content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/plugins.mdx | 5 +++-- packages/spec/authorable-surface/ui.json | 1 + scripts/sdui-manifest.record.json | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c3ec83ec0c7..c2277d1e0fa 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1221,6 +1221,7 @@ Sort field and direction pair | **trend** | `{ value: number; label?: string \| Record; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it | | **drillDown** | `{ enabled?: boolean; title?: string; target?: Enum<'drawer' \| 'dialog' \| 'navigate'>; columns?: string[]; … }` | optional | Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric's own `filter` — or draws the report `report` defines; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'> }` | optional | Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension | +| **polarity** | `Enum<'higher' \| 'lower' \| 'neutral'>` | optional | Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) or neutral (no good/bad colour). Colours the trend badge, computed (`compareTo`) or static (`trend`); undeclared keeps rise green, fall red | ### Nested Shape: `ObjectMetricProps.aggregate` diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index c0e65c6514e..61bbb40c1de 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -100,7 +100,7 @@ regenerates from the renderer registry. Regenerate it with | [`object-kanban`](/docs/ui/components/plugins#object-kanban) | `plugin-kanban` | 15 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-map`](/docs/ui/components/plugins#object-map) | `plugin-map` | 10 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-master-detail-form`](/docs/ui/components/plugins#object-master-detail-form) | `plugin-form` | 15 | [Plugin Blocks](/docs/ui/components/plugins) | -| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 19 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`object-metric`](/docs/ui/components/plugins#object-metric) | `plugin-dashboard` | 20 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-pivot`](/docs/ui/components/plugins#object-pivot) | `plugin-dashboard` | 12 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-timeline`](/docs/ui/components/plugins#object-timeline) | `plugin-timeline` | 16 | [Plugin Blocks](/docs/ui/components/plugins) | | [`object-tree`](/docs/ui/components/plugins#object-tree) | `plugin-tree` | 6 | [Plugin Blocks](/docs/ui/components/plugins) | diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index 2256a3f040f..415135190c0 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -85,6 +85,7 @@ Namespace `plugin-dashboard`. | `fallbackValue` | `string` | | Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it. | | `trend` | `object` | | Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data. | | `compareTo` | `object` | | Period-over-period comparison, `{ kind: "previousPeriod" }` or `{ kind: "previousYear" }` — the computed alternative to a static `trend`. | +| `polarity` | `enum`: `higher`, `lower`, `neutral` | | Which direction of change is good news. Colours the trend badge, computed (`compareTo`) or static (`trend`): `lower` draws a rise red and a fall green (turnaround, overdue counts, cost), `neutral` draws it muted. Omit it for rise green, fall red. The arrow always shows which way the number moved. | | `drillDown` | `object` | | Click-through config that opens the records behind the number. `drillDown.filter` and `drillDown.mode` do not apply to a metric, which has no clicked point to filter by and no row to open, so the list is always scoped by this block’s own `filter`; a drill `filter` belongs on `object-chart` or `object-pivot`, and `mode` on `object-data-table`. | | `dataSource` | `object` (`object` data binding) | | Per-element data binding: \{ object, view, filter, sort, limit \}. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it. | @@ -195,9 +196,9 @@ Namespace `plugin-gantt`. | :--- | :--- | :--- | :--- | | `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`; the `object-gantt` schema refuses a block that declares none of them, unless the node's `dataSource.object` names the object, which then lands on this key. | | `gantt` | `object` | | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored. The spec requires `startDateField`, `endDateField` and `titleField`, which name the record fields a task's bar starts at, ends at and is titled by; the other members (`progressField`, `colorField`, `dependenciesField`, `parentField`, `viewMode`, `exportFileName`, …) are optional. | -| `data` | `object` | | A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a gantt carrying one never reaches `staticData` and never queries `objectName`. `{ provider: 'value', items }` charts those rows, `{ provider: 'object', object }` queries that object and `{ provider: 'api', read }` reads through that request. A bare array is not this key’s shape and is not a record source: the gantt falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`. | +| `data` | `object` | | A `{ provider, … }` data-source configuration, read FIRST on the record-source ladder: a gantt carrying one never reaches `staticData`. The gantt reads two providers: `{ provider: 'value', items }` charts those rows and never queries `objectName`, and `{ provider: 'object', object }` queries that object in place of `objectName`. Rows a server computes reach the gantt as an object whose plugin driver computes them, never as a request URL in the view. A bare array is not this key’s shape and is not a record source: the gantt falls through to `staticData`, then `objectName`, so inline rows belong under `staticData`. | | `staticData` | `array` | | Inline records, read SECOND on the record-source ladder: a `data` configuration wins and this key is then never reached, while `objectName` is read AFTER it, so a gantt carrying both charts these rows and never queries that object. `filter` and `sort` narrow and order these rows exactly as they do fetched ones. | -| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the tasks the gantt charts — from every record source it reads: the object, an `api` request, or inline rows. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. The MongoDB-style record form is not this key’s shape. | +| `filter` | `array` | | Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the tasks the gantt charts — from every record source it reads: the object or inline rows. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. The MongoDB-style record form is not this key’s shape. | | `sort` | `array` | | Task order in `[{ field, order }]` form, ordering the rows the gantt charts. Lowered to `$orderby` on the same query. | | `navigation` | `object` | | What a task click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. With the key ABSENT a click opens the task's record in a drawer, this renderer's own default. `mode` is `drawer` (the overlay), `new_window`, `page` or `none`, and a block written without `mode` takes the spec's `drawer` default. `drawer` opens the task's record in the drawer; `none` opens nothing. `page` opens the task's record page in the same tab and `new_window` opens it in a new tab. The gantt builds that address itself from the page it is on and does not use a record navigator the host publishes, so a host that publishes one changes nothing: on the object's own list or view route (`…/OBJECT/view/VIEW`) the address is the record page `…/OBJECT/record/ID`, while on any other page, such as a custom page, `/OBJECT/record/ID` is appended to the current address. The object is `data.object` when `data` is the object provider, else `objectName`; on inline rows that name neither there is no record to open, and every mode, the default drawer included, opens nothing. `preventNavigation: true` opens nothing whatever the mode, `openNewTab: true` opens the record page in a new tab and outranks every mode except `none`, and `size` sets the overlay width. | | `label` | `string` \| `object` | | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label. A string is used as written, and an inline locale map (`{ en, "zh-CN", … }`) gives the entry for the display locale. The chart draws it nowhere else. | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8599e2fc5df..8653b7dcf6a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -985,6 +985,7 @@ "ui/ObjectMetricProps:invert", "ui/ObjectMetricProps:label", "ui/ObjectMetricProps:objectName", + "ui/ObjectMetricProps:polarity", "ui/ObjectMetricProps:prefix", "ui/ObjectMetricProps:suffix", "ui/ObjectMetricProps:title", diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 94309746374..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": "ebc9662a1daea4f113b0c2e6c4d8989f821d52edb58a5bd7ec88f735984df846", + "sha256": "d55576794739e7893f7ffdfeadfe8a0ebdd480469e95f431dc7bc80565f4e229", "components": 106, "inTree": { "modulesRoot": "ui/apps/console",