@object-ui/plugin-form 17.5.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +4841 -0
  2. package/README.md +772 -77
  3. package/dist/DrawerForm.d.ts +30 -2
  4. package/dist/DrawerForm.d.ts.map +1 -1
  5. package/dist/EmbeddableForm.d.ts.map +1 -1
  6. package/dist/FormAnalytics.d.ts.map +1 -1
  7. package/dist/LineItemsPanel.d.ts +6 -3
  8. package/dist/LineItemsPanel.d.ts.map +1 -1
  9. package/dist/MasterDetailForm.d.ts +31 -10
  10. package/dist/MasterDetailForm.d.ts.map +1 -1
  11. package/dist/ModalForm.d.ts +48 -4
  12. package/dist/ModalForm.d.ts.map +1 -1
  13. package/dist/ObjectForm.d.ts +18 -2
  14. package/dist/ObjectForm.d.ts.map +1 -1
  15. package/dist/SplitForm.d.ts +37 -2
  16. package/dist/SplitForm.d.ts.map +1 -1
  17. package/dist/TabbedForm.d.ts +65 -3
  18. package/dist/TabbedForm.d.ts.map +1 -1
  19. package/dist/WizardForm.d.ts +149 -11
  20. package/dist/WizardForm.d.ts.map +1 -1
  21. package/dist/autoLayout.d.ts +12 -3
  22. package/dist/autoLayout.d.ts.map +1 -1
  23. package/dist/closedAffordanceNotice.d.ts +14 -0
  24. package/dist/closedAffordanceNotice.d.ts.map +1 -0
  25. package/dist/customFieldsMerge.d.ts +29 -0
  26. package/dist/customFieldsMerge.d.ts.map +1 -0
  27. package/dist/deriveMasterDetail.d.ts +33 -9
  28. package/dist/deriveMasterDetail.d.ts.map +1 -1
  29. package/dist/fieldGroups.d.ts +176 -0
  30. package/dist/fieldGroups.d.ts.map +1 -1
  31. package/dist/fieldWriteGate.d.ts +116 -0
  32. package/dist/fieldWriteGate.d.ts.map +1 -0
  33. package/dist/flatFields.d.ts +30 -0
  34. package/dist/flatFields.d.ts.map +1 -0
  35. package/dist/formChrome.d.ts +55 -0
  36. package/dist/formChrome.d.ts.map +1 -0
  37. package/dist/index.d.ts +131 -25
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3676 -1967
  40. package/dist/index.umd.cjs +1 -1
  41. package/dist/initialRecord.d.ts +72 -0
  42. package/dist/initialRecord.d.ts.map +1 -0
  43. package/dist/loadFailure.d.ts +28 -0
  44. package/dist/loadFailure.d.ts.map +1 -0
  45. package/dist/masterDetailTx.d.ts.map +1 -1
  46. package/dist/occSave.d.ts +6 -1
  47. package/dist/occSave.d.ts.map +1 -1
  48. package/dist/recordInvalidation.d.ts +33 -0
  49. package/dist/recordInvalidation.d.ts.map +1 -0
  50. package/dist/recordReadGate.d.ts +34 -0
  51. package/dist/recordReadGate.d.ts.map +1 -0
  52. package/dist/sanitize.d.ts +187 -5
  53. package/dist/sanitize.d.ts.map +1 -1
  54. package/dist/schemaDefaults.d.ts +51 -30
  55. package/dist/schemaDefaults.d.ts.map +1 -1
  56. package/dist/sectionFields.d.ts +65 -2
  57. package/dist/sectionFields.d.ts.map +1 -1
  58. package/dist/sectionGroups.d.ts +62 -0
  59. package/dist/sectionGroups.d.ts.map +1 -0
  60. package/dist/sectionPredicateDiagnostic.d.ts +38 -0
  61. package/dist/sectionPredicateDiagnostic.d.ts.map +1 -0
  62. package/dist/submitRedirect.d.ts +148 -0
  63. package/dist/submitRedirect.d.ts.map +1 -0
  64. package/dist/submitRedirectNavigation.d.ts +82 -0
  65. package/dist/submitRedirectNavigation.d.ts.map +1 -0
  66. package/dist/submitTarget.d.ts +119 -0
  67. package/dist/submitTarget.d.ts.map +1 -0
  68. package/dist/successBehavior.d.ts +44 -8
  69. package/dist/successBehavior.d.ts.map +1 -1
  70. package/dist/thankYouRedirectCountdown.d.ts +17 -0
  71. package/dist/thankYouRedirectCountdown.d.ts.map +1 -0
  72. package/dist/thankYouRedirectNavigation.d.ts +111 -0
  73. package/dist/thankYouRedirectNavigation.d.ts.map +1 -0
  74. package/dist/uploadGate.d.ts +35 -0
  75. package/dist/uploadGate.d.ts.map +1 -0
  76. package/dist/writePayload.d.ts +68 -0
  77. package/dist/writePayload.d.ts.map +1 -0
  78. package/package.json +13 -12
package/README.md CHANGED
@@ -35,53 +35,236 @@ const schema = {
35
35
  };
36
36
  ```
37
37
 
38
- ### Manual Registration
38
+ ### What the side-effect import registers
39
+
40
+ There is no component map to iterate: registration is a side effect of importing
41
+ the package entry, which makes five `ComponentRegistry.register(...)` calls. These
42
+ are the schema types those calls claim, read off the calls themselves in
43
+ `src/index.tsx`:
44
+
45
+ | Namespaced type | Bare-name fallback | Component behind it |
46
+ |---|---|---|
47
+ | `plugin-form:object-form` | `object-form` | `ObjectForm` — metadata-driven form over one record |
48
+ | `view:form` | none | the same renderer under the view protocol |
49
+ | `plugin-form:embeddable-form` | `embeddable-form` | `EmbeddableForm` — standalone public form |
50
+ | `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent + child line items in one submit |
51
+ | `record:line_items` | none | `LineItemsPanel` — child grid bound to the record on the page |
52
+
53
+ `ComponentRegistry.register` also registers a namespaced type under its bare name
54
+ for backwards compatibility, unless the call passes `skipFallback: true`
55
+ (`@object-ui/core`, `src/registry/Registry.ts`). Two calls here do: bare `form`
56
+ stays the basic `@object-ui/components` form, and bare `line_items` is left to
57
+ whoever else claims it.
58
+
59
+ The `form-analytics` node key is RETIRED (objectui#10859 batch 8): no schema
60
+ produced it, and `objectui validate` refused it at `type`. `FormAnalytics` is still
61
+ exported (see below); mount the component directly.
62
+
63
+ ### Public exports
64
+
65
+ The package entry exports these — components, their prop/schema types, the
66
+ layout helpers, the two create-payload rules a second form renderer needs
67
+ (see [`required` + a runtime default](#required-or-requiredwhen--a-runtime-default))
68
+ and the section-group resolver a third one needs
69
+ (see [Resolving `sections[].group` outside this package](#resolving-sectionsgroup-outside-this-package)).
70
+ There is no aggregate map among them:
71
+
72
+ ```typescript
73
+ import {
74
+ ObjectForm,
75
+ TabbedForm,
76
+ WizardForm,
77
+ SplitForm,
78
+ DrawerForm,
79
+ ModalForm,
80
+ EmbeddableForm,
81
+ MasterDetailForm,
82
+ LineItemsPanel,
83
+ FormAnalytics,
84
+ FormSectionContainer,
85
+ applyAutoLayout,
86
+ applyAutoColSpan,
87
+ inferColumns,
88
+ inferModalSize,
89
+ isWideFieldType,
90
+ isAutoGeneratedFieldType,
91
+ containerGridColsFor,
92
+ filterCreateModeFields,
93
+ filterSystemFields,
94
+ filterAutoGeneratedFields,
95
+ deriveDetail,
96
+ deriveColumns,
97
+ deriveFormFields,
98
+ findRelationshipField,
99
+ resolveInlineMode,
100
+ omitServerResolvedDefaults,
101
+ isRequiredInForm,
102
+ resolveSectionGroupReferences,
103
+ } from '@object-ui/plugin-form';
104
+
105
+ import type {
106
+ ObjectFormComponentProps,
107
+ ObjectFormProps, // deprecated alias of ObjectFormComponentProps
108
+ FormSectionContainerProps,
109
+ TabbedFormProps,
110
+ TabbedFormSchema,
111
+ FormSectionConfig,
112
+ WizardFormProps,
113
+ WizardFormSchema,
114
+ SplitFormProps,
115
+ SplitFormSchema,
116
+ DrawerFormProps,
117
+ DrawerFormSchema,
118
+ ModalFormProps,
119
+ ModalFormSchema,
120
+ EmbeddableFormProps,
121
+ EmbeddableFormConfig,
122
+ EmbeddableFormTexts,
123
+ FormAnalyticsProps,
124
+ FormSubmissionMetric,
125
+ MasterDetailFormProps,
126
+ MasterDetailFormSchema,
127
+ MasterDetailDetailConfig,
128
+ LineItemsPanelSchema,
129
+ DerivedDetail,
130
+ InlineMode,
131
+ ChildObjectSchemaLike,
132
+ FieldDefaultsSchemaLike,
133
+ ResolveSectionGroupsOptions,
134
+ } from '@object-ui/plugin-form';
135
+ ```
136
+
137
+ ### Registering a component under your own key
138
+
139
+ To put one of these components on a schema type of your own, register the
140
+ exported component:
39
141
 
40
142
  ```typescript
41
- import { formComponents } from '@object-ui/plugin-form';
42
143
  import { ComponentRegistry } from '@object-ui/core';
144
+ import { ObjectForm } from '@object-ui/plugin-form';
43
145
 
44
- // Register form components
45
- Object.entries(formComponents).forEach(([type, component]) => {
46
- ComponentRegistry.register(type, component);
47
- });
146
+ ComponentRegistry.register('my-form', ObjectForm, { namespace: 'my-app' });
48
147
  ```
49
148
 
149
+ The renderers this package registers for itself are internal wrappers rather
150
+ than these exported components. `SchemaRenderer` hands a registered component its
151
+ `schema` (plus the schema's own props), but never a `dataSource` — that travels
152
+ on `SchemaRendererContext` — so each wrapper reads it off the context first.
153
+ `ObjectForm` takes `dataSource` as a prop, optional because inline fields need no
154
+ adapter, so a custom-key registration either supplies one or wraps the component
155
+ the same way. "Optional" is not "absent is fine for every form", though — see
156
+ [What a form submits to](#what-a-form-submits-to).
157
+
50
158
  ## Schema API
51
159
 
52
- ### Form
160
+ Two form schemas reach a renderer, and **both are declared in
161
+ `@object-ui/types`** — this package imports them and declares neither. The
162
+ tables below name the keys and point at the declaration; the declaration is the
163
+ contract. (Restating an interface inside this README is what let this section
164
+ drift away from the code in the first place — objectui#5075.)
53
165
 
54
- Complete form with fields and validation:
166
+ | Schema | `type` | Declared in | Rendered by |
167
+ |---|---|---|---|
168
+ | `FormSchema` | `'form'` | `packages/types/src/form.ts` | the basic form in `@object-ui/components` — bare `form` is deliberately **not** claimed by this plugin (`skipFallback: true`, see the table above) |
169
+ | `ObjectFormSchema` | `'object-form'` | `packages/types/src/objectql.ts` | `ObjectForm` here, through `plugin-form:object-form` / `object-form` |
55
170
 
56
- ```typescript
57
- {
58
- type: 'form',
59
- fields: FormField[],
60
- submitLabel?: string,
61
- cancelLabel?: string,
62
- onSubmit?: (data) => void,
63
- onCancel?: () => void,
64
- className?: string
65
- }
66
- ```
171
+ Both `type` slots are string **literals**. A schema whose `type` names something
172
+ no registration claims does not fall back to a form — it renders the
173
+ unknown-component placeholder.
174
+
175
+ ### Form (`type: 'form'`)
176
+
177
+ | Key | Type | Notes |
178
+ |---|---|---|
179
+ | `type` | `'form'` | literal, not a free string |
180
+ | `fields` | `FormField[]` | **optional** — a form may render `children` instead |
181
+ | `defaultValues` | `Record<string, any>` | form-level initial values. There is no field-level `defaultValue` |
182
+ | `submitLabel` / `cancelLabel` | `string` | button text |
183
+ | `showCancel` / `showActions` | `boolean` | action-row composition |
184
+ | `mobileStickyActions` | `boolean` | pin the action row on small viewports |
185
+ | `layout` | `'vertical' \| 'horizontal'` | label placement |
186
+ | `columns` | `number` | grid width (1–4) |
187
+ | `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run |
188
+ | `resetOnSubmit` / `disabled` | `boolean` | |
189
+ | `mode` | — | **retired** (objectui#10286): nothing read it, and validation now refuses it. Use `disabled` for a read-only basic form, or `object-form` for create / edit / view |
190
+ | `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) |
191
+ | `previousValues` | `Record<string, any>` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere |
192
+ | `fieldContainerClass` | `string` | class for the field grid inside the `<form>` |
193
+ | `fieldTabs` / `defaultFieldTab` / `fieldTabsPosition` | see [Tabbed field layout](#tabbed-field-layout-fieldtabs) | |
194
+ | `fieldPanes` / `fieldPanesOrientation` / `fieldPanesResizable` | see [Split field layout](#split-field-layout-fieldpanes) | |
195
+ | `actions` | `SchemaNode[]` | extra nodes in the action row |
196
+ | `children` | `SchemaNode \| SchemaNode[]` | custom body instead of `fields` |
197
+ | `onSubmit` / `onChange` / `onDirtyChange` / `onCancel` | callbacks | **TypeScript-authored schemas only** — a JSON metadata document cannot carry a function. Metadata pages go through the object-form route instead |
198
+ | `className`, `id`, `hidden`, … | — | inherited from `BaseSchema` |
199
+
200
+ ⚠️ `FormSchema` extends `BaseSchema`, which declares `[key: string]: any`
201
+ (`packages/types/src/base.ts`), so an invented or misspelled key on a form schema
202
+ is **not** a compile error — it is simply never read. That is why every example
203
+ below is annotated with its real type *and* checked against these key tables:
204
+ an un-annotated `const schema = { … }` type-checks whatever is written in it.
67
205
 
68
206
  ### Form Field
69
207
 
70
- Individual form field configuration:
208
+ `FormField` (`packages/types/src/form.ts`) declares 23 keys, and `name` is the
209
+ only **required** one:
71
210
 
72
- ```typescript
73
- interface FormField {
74
- name: string;
75
- type: string; // 'input', 'select', 'checkbox', etc.
76
- label: string;
77
- placeholder?: string;
78
- required?: boolean;
79
- validation?: ValidationRule[];
80
- defaultValue?: any;
81
- disabled?: boolean;
82
- className?: string;
83
- }
84
- ```
211
+ | Key | Type | What it does |
212
+ |---|---|---|
213
+ | `name` | `string` | **required** — the submit key |
214
+ | `id` | `string` | stable render key; falls back to `name` |
215
+ | `label` | `string` | optional — with none, no label element is rendered at all (validation messages fall back to `name`). The object-bound paths always fill it from the object field |
216
+ | `description` | `string` | help text under the control |
217
+ | `type` | `string` | optional, defaults to `'input'`. Built-ins: `input`, `textarea`, `checkbox`, `switch`, `select`; any other value resolves through the registry (`field:<type>` first, then the bare name) |
218
+ | `inputType` | `string` | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, … |
219
+ | `widget` | `string` | widget override; wins over `type` (spec `FormField.widget`) |
220
+ | `placeholder` | `string` | |
221
+ | `required` | `boolean` | the presence rule. `validation.required` does **not** make a field required — see below |
222
+ | `disabled` | `boolean` | not interactive, muted |
223
+ | `readonly` | `boolean` | shown plainly, not editable — deliberately distinct from `disabled` |
224
+ | `hidden` | `boolean` | field is not rendered at all |
225
+ | `options` | `SelectOption[] \| RadioOption[]` | for `select` / radio fields |
226
+ | `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below |
227
+ | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in }` matcher (`custom` is retired, objectui#7759: nothing ran it) |
228
+ | `visibleWhen` / `readonlyWhen` / `requiredWhen` | `string \| { dialect?, source }` | CEL predicates over the live record, evaluated by `@objectstack/formula` — the same engine and dialect the server uses. Fail open |
229
+ | `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) |
230
+ | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries |
231
+ | `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) |
232
+ | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count |
233
+ | `field` | `Record<string, any>` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` |
234
+
235
+ `FormField` also declares `[key: string]: any`, so an invented key type-checks
236
+ here too. Two that a reader might expect, and that are **not** declared:
237
+
238
+ | Not a `FormField` key | Write this instead |
239
+ |---|---|
240
+ | `defaultValue` | `FormSchema.defaultValues` at form level. An object-bound form seeds from the object field's own declared `defaultValue` — see [What a create form opens with](#what-a-create-form-opens-with) |
241
+ | `className` | `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the grid. (An undeclared key still rides the props spread down to whichever component the field resolves to, so a field-level `className` can visibly land on a built-in control — but nothing in the contract promises that, and a registered widget honours it only if it happens to spread its leftover props. The renderer reads it *explicitly* nowhere: it used to stamp the `type: 'section-divider'` pseudo-field from an authored section's `className`, and that read was **retired** in objectstack#13626 — see [Section styling is not authorable](#section-styling-is-not-authorable).) |
242
+
243
+ There is no `ValidationRule` type in this repo, under any spelling.
244
+
245
+ #### `validation` is an object keyed by rule name
246
+
247
+ `FieldValidationRules` (`packages/types/src/form.ts`) is **not** an array of
248
+ `{ type, value, message }` entries:
249
+
250
+ | Rule | Type | Notes |
251
+ |---|---|---|
252
+ | `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** |
253
+ | `minLength` / `maxLength` | `{ value: number; message: string }` | `message` is not optional when you author the rule by hand |
254
+ | `min` / `max` | `{ value: number; message: string }` | numeric range |
255
+ | `pattern` | `{ value: string \| RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and it is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one |
256
+ | `validate` | `(value) => boolean \| string \| Promise<boolean \| string>` | custom check; TypeScript-authored schemas only |
257
+
258
+ There is no `email` rule name — an email check is a `pattern`, which is exactly
259
+ what `buildValidationRules` emits for an object field of type `email`.
260
+
261
+ **Why the array spelling fails silently.** The only reader of this key is the
262
+ basic form renderer, which spreads it into the rule object handed to
263
+ react-hook-form — `const rules: any = { ...validation }`
264
+ (`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array**
265
+ into an object literal produces numeric keys (`{ '0': …, '1': … }`), which
266
+ react-hook-form does not recognise: every rule is dropped, nothing throws, and
267
+ the form looks validated while validating nothing.
85
268
 
86
269
  ### What a create form opens with
87
270
 
@@ -115,11 +298,13 @@ it; folding a default in over a column the record leaves unset would arm a
115
298
  silent write of a value the user never chose, on the next save of any other
116
299
  field.
117
300
 
118
- #### `required` + a runtime default
301
+ #### `required` (or `requiredWhen`) + a runtime default
119
302
 
120
303
  A field may declare both, and it is coherent authoring — storage-level required,
121
304
  with the value guaranteed by the producer:
122
305
 
306
+ <!-- doc-snippet: fragment — one object-literal PROPERTY quoted out of a field map — `remind_at: Field.datetime(...)` is a key/value pair, not a statement. Measured TS1109x1. -->
307
+
123
308
  ```ts
124
309
  remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
125
310
  ```
@@ -131,22 +316,121 @@ rule, and the field is **omitted from the payload** — omitted, not sent empty,
131
316
  because `applyFieldDefaults` resolves the declaration only for a field that
132
317
  arrives absent or null, and a blank string is neither.
133
318
 
319
+ The **conditional** spelling reaches the same verdict. A `requiredWhen`
320
+ predicate says "required in this state", but `NOW()` / `current_user` resolve
321
+ at insert regardless of state, so the producer's guarantee covers the
322
+ conditional claim exactly as it covers the unconditional one:
323
+
324
+ <!-- doc-snippet: fragment — one object-literal PROPERTY quoted out of a field map — `remind_at: Field.datetime(...)` is a key/value pair, not a statement. Measured TS1109x1. -->
325
+
326
+ ```ts
327
+ remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaultValue: 'NOW()' }),
328
+ ```
329
+
134
330
  | Mode | Declared | Left empty | Effect |
135
331
  |---|---|---|---|
136
- | create | `required` + a runtime default | yes | submits; the key is absent, and the server resolves it |
137
- | create | `required` + a runtime default | no (user typed) | submits the typed value — it outranks the default |
138
- | create | `required` + a static literal | user cleared the seeded control | refused: they removed a value that was really there |
139
- | edit | `required`, anything | user blanked it | refused: the token was resolved at insert, so this is a real removal |
332
+ | create | `required` / `requiredWhen` TRUE + a runtime default | yes | submits; the key is absent, and the server resolves it |
333
+ | create | `required` / `requiredWhen` TRUE + a runtime default | no (user typed) | submits the typed value — it outranks the default |
334
+ | create | `required` / `requiredWhen` TRUE + a static literal | user cleared the seeded control | refused: they removed a value that was really there |
335
+ | create | `requiredWhen` TRUE, no default | yes | refused: nobody else supplies this value |
336
+ | edit | `required` / `requiredWhen` TRUE, anything | user blanked it | refused: the token was resolved at insert, so this is a real removal |
140
337
 
141
338
  The required marker and `aria-required` go with the rule in the create case,
142
- since one boolean drives all three — in that mode the user genuinely is not
339
+ since one verdict drives all three — in that mode the user genuinely is not
143
340
  required to provide the value. Showing what the server *will* supply, as a
144
341
  non-authoritative preview, is a separate follow-up.
145
342
 
146
- Both halves read one predicate (`isRuntimeDefault` in `schemaDefaults`), which
147
- is what keeps a form from seeding a field it also refuses to submit. Not
148
- extended to `requiredWhen`, the conditional-required CEL rule, which the form
149
- renderer resolves against the live record.
343
+ The two halves of that verdict are importable, so a host with its own form
344
+ renderer applies the same rule rather than re-deriving it (objectui#6059):
345
+
346
+ ```typescript
347
+ declare const field: { required?: unknown; defaultValue?: unknown };
348
+ declare const isCreateForm: boolean;
349
+ declare const values: Record<string, unknown>;
350
+ declare const objectSchema: FieldDefaultsSchemaLike;
351
+
352
+ import {
353
+ isRequiredInForm,
354
+ omitServerResolvedDefaults,
355
+ type FieldDefaultsSchemaLike,
356
+ } from '@object-ui/plugin-form';
357
+
358
+ // Should this create form enforce `required` on the field?
359
+ const required = isRequiredInForm(field, isCreateForm);
360
+
361
+ // Drop the keys the producer must resolve, immediately before the insert.
362
+ const payload = isCreateForm ? omitServerResolvedDefaults(values, objectSchema) : values;
363
+ ```
364
+
365
+ They are published as a pair on purpose: excusing a server-owned field from
366
+ `required` and then submitting the key anyway is not half a fix, it is no fix.
367
+ Both are pure functions over plain data — no React, no registry — and
368
+ `omitServerResolvedDefaults` is **create-only**, so the caller keeps the mode
369
+ gate, as the example shows.
370
+
371
+ Every consumer reads one predicate, `isRuntimeDefault` in `@object-ui/core`
372
+ (re-exported for this package's own use by `src/schemaDefaults.ts`, not from the
373
+ package entry listed above) — which is what keeps a form from seeding a field it
374
+ also refuses to submit. The static half is decided at the producer, in
375
+ `isRequiredInForm`; the conditional half cannot be, because `requiredWhen` is
376
+ resolved downstream against the live record, so it is suppressed inside the one
377
+ evaluator that resolves it — `resolveFieldRuleState`, reading
378
+ `isServerOwnedValue`. Display and validation share that single verdict, so a
379
+ field can never lose its asterisk while still refusing the submit.
380
+
381
+ ### Resolving `sections[].group` outside this package
382
+
383
+ A form section declares its members exactly one way: it enumerates `fields`, or
384
+ it points `group` at one of the object's declared `fieldGroups` and inherits that
385
+ group's members **and** its presentation (objectstack#13855, ADR-0085 §5 — the
386
+ spec range that carries it is the `@objectstack/spec` entry in this package's own
387
+ `package.json`). `ObjectForm` resolves the reference once, above its routing
388
+ fork, so all six layouts inherit it. `ModalForm` resolves its own `sections`
389
+ through the same call as well, because hosts mount it directly rather than
390
+ through `ObjectForm` — the console's record create / edit dialog and
391
+ action-opened modals do (objectui#11542). `DrawerForm` mounted directly does
392
+ not resolve them; reach it through `ObjectForm` with `formType: 'drawer'`.
393
+
394
+ A host with its **own** section builder resolves it with the same function
395
+ instead of deriving sections itself (objectui#8641):
396
+
397
+ ```typescript
398
+ import {
399
+ resolveSectionGroupReferences,
400
+ type ResolveSectionGroupsOptions,
401
+ } from '@object-ui/plugin-form';
402
+ import type { ObjectFormSection } from '@object-ui/types';
403
+
404
+ // What the host's own builder produced. A `{ group: 'x' }` section is still
405
+ // unresolved here — `ObjectFormSection` declares `group` and leaves `fields`
406
+ // optional, which is the whole shape this call takes in.
407
+ declare const authoredSections: ObjectFormSection[];
408
+ declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading
409
+
410
+ const sections = resolveSectionGroupReferences(authoredSections, {
411
+ objectName: 'ticket',
412
+ formType: 'simple',
413
+ objectDef,
414
+ });
415
+ ```
416
+
417
+ Every `{ group: 'x' }` section comes back as the section that group declares —
418
+ label, members, description and collapse state — and everything else comes back
419
+ **untouched**: with no reference in the list the input array is returned by
420
+ identity, so the call cannot perturb an existing form and is safe inside a
421
+ `useMemo`. A reference that resolves to nothing yields an empty section (never a
422
+ dropped one — a `sections` array that empties out stops being a sectioned form at
423
+ all) and is reported once, naming the object and the key.
424
+
425
+ ⛔ Nothing about the assembly belongs to the caller. Declared order, the
426
+ empty-group drop, the ungrouped trailing bucket and the collapse / `visibleWhen`
427
+ passthrough all come from `deriveFieldGroupLayout` through this package's single
428
+ adapter onto the section shape — the same code path the no-sections field-group
429
+ fallback uses, so a group authored by reference and one derived by the fallback
430
+ are the same section by construction. A host reaching for `deriveFieldGroupLayout`
431
+ directly would have to re-spell the `collapse` enum onto its own boolean pair and
432
+ pass `visibleWhen` through by hand, which is the duplication this export exists to
433
+ prevent.
150
434
 
151
435
  ### Column width of a sectioned form
152
436
 
@@ -168,6 +452,42 @@ The grid is applied to the field container **inside** the form, never wrapped
168
452
  around the `<form>` (which would put the whole form in cell 1 and leave the other
169
453
  columns empty — #2128).
170
454
 
455
+ ### Section styling is not authorable
456
+
457
+ `className` and `gridClassName` on a form-view **section** do nothing. In JSON
458
+ metadata, authoring them is not a parse error — the renderer simply does not
459
+ read them. In TypeScript they are a compile error: `ObjectFormSection` in
460
+ `@object-ui/types` does not declare either key (objectui#7200), matching the
461
+ spec's strict `FormSectionSchema`.
462
+
463
+ This is deliberate. Both keys sit on the SDUI-only side of the authorable
464
+ boundary: `@objectstack/spec` does not declare either on the form-view/section
465
+ surface, and the authorable-surface ledger carries no entry for them. The
466
+ renderer nevertheless reached them through `as any` at seven sites until
467
+ objectstack#13626 (maintainer ruling 2026-09-01) retired those reads — the
468
+ boundary had been declared on one side and crossed on the other.
469
+
470
+ Declaring the keys instead was weighed and rejected: it would invite free
471
+ Tailwind strings into authored metadata, which is the class the boundary exists
472
+ to keep out, and per ADR-0065 / ADR-0080 utility classNames in runtime metadata
473
+ are never scanned by the build-time Tailwind — so they silently produce no CSS
474
+ even when they are read. If per-view styling becomes a real product need it will
475
+ get an explicit controlled token surface rather than two leaked keys.
476
+
477
+ **What still works:** the form ROOT `className` (`FormSchema.className`) is a
478
+ different key on a different node and is unaffected; section *layout* stays
479
+ authorable through `columns` (above); and a host application styles sections
480
+ through its own CSS.
481
+
482
+ > Note for contributors: `ObjectFormSection` in `@object-ui/types` no longer
483
+ > declares either key (objectui#7200), so an uncast `className: s.className` fails
484
+ > to compile — but a cast read (the shape the seven retired sites had) still
485
+ > would, and JSON metadata carries whatever an author wrote. The non-consumption
486
+ > is therefore pinned behaviourally, in
487
+ > `src/__tests__/sectionStyleKeysRetired-13626.test.tsx`, across all seven arms;
488
+ > the declaration side is pinned in `@object-ui/types`'
489
+ > `object-form-section-style-keys-undeclared.test.ts`.
490
+
171
491
  ### Tabbed field layout (`fieldTabs`)
172
492
 
173
493
  A sectioned form is **one** form. Instead of rendering a form per section — which
@@ -175,6 +495,8 @@ strands every section but the first outside the submit, and (in tabs) lets the
175
495
  inactive panel unmount with its values — declare tabs on the single form and let
176
496
  the renderer distribute the fields:
177
497
 
498
+ <!-- doc-snippet: fragment — a `fieldTabs` key list in prose notation, not a statement: the trailing `defaultFieldTab?:` / `fieldTabsPosition?:` entries mark OPTIONAL keys, which no object literal can spell. Measured TS1005x2 TS1109x3. -->
499
+
178
500
  ```typescript
179
501
  {
180
502
  type: 'form',
@@ -182,6 +504,7 @@ the renderer distribute the fields:
182
504
  fieldTabs: [
183
505
  { key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
184
506
  { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
507
+ { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'record.status == "won"' },
185
508
  ],
186
509
  defaultFieldTab?: 'basics', // defaults to the first tab
187
510
  fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
@@ -197,7 +520,89 @@ the renderer distribute the fields:
197
520
  - Fields no tab claims render above the tab strip rather than disappearing.
198
521
  - Needs at least two tabs, and is ignored when the form uses `children`.
199
522
 
200
- `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this.
523
+ `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this, and
524
+ both forward a section's `visibleWhen` onto the tab they synthesise — see the
525
+ support table below.
526
+
527
+ #### Conditional tabs (`FormFieldTab.visibleWhen`)
528
+
529
+ A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and
530
+ engine as the field-level rule (`string | { dialect?, source }`, a CEL
531
+ predicate over the live record, evaluated by `@objectstack/formula` with the
532
+ host predicate scope bound, so it can read `current_user` / `data` /
533
+ `features` exactly as a field rule can — there is no `app` root, objectui#8155).
534
+ Like every conditional rule in this
535
+ system it **fails open**: a predicate that cannot be evaluated leaves the tab
536
+ visible rather than hiding data behind a broken expression.
537
+
538
+ When the predicate resolves FALSE the renderer draws **neither the tab's
539
+ trigger nor its panel** — a hidden tab disappears from the tab strip entirely,
540
+ it is not merely an empty panel.
541
+
542
+ **Submit semantics are deliberately counter-intuitive — visibility gates
543
+ drawing, and nothing else:**
544
+
545
+ - **A hidden tab's values still submit.** Hiding a tab does not remove its
546
+ fields' values from the record: whatever they hold (loaded from the record,
547
+ seeded by defaults, or typed before the tab hid) is carried in the payload.
548
+ A visibility rule is not a data filter — dropping the values would turn a
549
+ cosmetic predicate into a silent data-loss door.
550
+ - **A hidden tab's fields skip client-side validation.** A user is never
551
+ blocked by an error pointing at a control they cannot see. The flip side:
552
+ a *required* field on a hidden tab sails past the client — **the
553
+ server-side contract is the loud floor** for genuinely-required data, and a
554
+ submit missing it fails there, visibly.
555
+ - **Stale errors clear.** A tab hiding mid-session (its predicate flipping on
556
+ a keystroke or a scope change) clears its fields' leftover validation
557
+ errors, the same hygiene a field's own `visibleWhen` applies.
558
+
559
+ These are not tab-specific rules: they are the ruled hidden-group semantics
560
+ every section `visibleWhen` follows, inherited through the same mechanism a
561
+ field's own FALSE predicate uses (the fields simply stop being drawn; the form
562
+ keeps their values and skips unmounted controls at validation).
563
+
564
+ Two mechanics worth knowing:
565
+
566
+ - **Selection is derived over the visible tabs.** If the predicate hides the
567
+ ACTIVE tab, the renderer re-selects deterministically — the user's own pick
568
+ if still visible, else `defaultFieldTab`, else the first visible tab — so
569
+ the form never shows an empty panel. The pick itself is kept: the tab the
570
+ user chose becomes active again the moment its predicate re-admits it.
571
+ - **Whether the tabbed layout engages at all is judged on the DECLARED tabs**
572
+ (the "needs at least two tabs" rule above counts declarations, not
573
+ verdicts). A predicate hiding all but one tab filters what is drawn; it
574
+ never collapses the strip into the flat layout mid-interaction.
575
+
576
+ **Where `visibleWhen` on a tab works today** — this key landed renderer-first,
577
+ and only where the renderer actually evaluates it:
578
+
579
+ | Authoring surface | Carries the predicate? |
580
+ | --- | --- |
581
+ | `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer |
582
+ | `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab |
583
+ | `TabbedForm` / `formType: 'tabbed'` sections | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead |
584
+ | `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate |
585
+
586
+ The remaining **No** row is deliberate, not an oversight: declaring the key on a
587
+ surface whose renderer ignores it would make the metadata lie (#6111), so the
588
+ key stops at the boundary until each surface enforces it.
589
+
590
+ ⛔ **A wizard step has no predicate slot at all, and that is enforced by the
591
+ type.** A step predicate is not the tab predicate under another name — steps are
592
+ mounted one at a time and keyed by index, so a step predicate would be
593
+ step-boundary reactive where a tab's is live-record reactive, and it would need
594
+ navigation, indicator, `isLastStep`, final-gate and re-selection semantics that
595
+ none of the tab machinery supplies. So `WizardStepConfig` is declared
596
+ independently of `FormSectionConfig` (maintainer ruling 2026-08-30, #6237) and
597
+ simply does not carry the key: `visibleWhen` on a wizard step literal is a
598
+ **compile error**, not a silently ignored key. Track #6237 for the step
599
+ contract.
600
+
601
+ That row also **reports itself** rather than failing silently. Authoring a
602
+ section `visibleWhen` on `formType: 'wizard'` logs a console warning naming the
603
+ layout and the sections whose predicate is being dropped — untyped JSON reaches
604
+ the renderer too, so the compile-time half cannot be the only one. Nothing warns
605
+ on the five arms that honour the key.
201
606
 
202
607
  ### Wizard steps and `allowSkip`
203
608
 
@@ -225,6 +630,8 @@ The same rule for side-by-side panels: the `<form>` wraps the whole panel group
225
630
  and each pane holds only fields, so one react-hook-form instance spans the
226
631
  divider.
227
632
 
633
+ <!-- doc-snippet: fragment — a `fieldPanes` key list in prose notation, not a statement: the trailing `fieldPanesOrientation?:` / `fieldPanesResizable?:` entries mark OPTIONAL keys, which no object literal can spell. Measured TS1005x2 TS1109x3. -->
634
+
228
635
  ```typescript
229
636
  {
230
637
  type: 'form',
@@ -259,7 +666,9 @@ primary pane, the rest stack in the secondary one behind inline section headers.
259
666
  ### Basic Form
260
667
 
261
668
  ```typescript
262
- const schema = {
669
+ import type { FormSchema } from '@object-ui/types';
670
+
671
+ const schema: FormSchema = {
263
672
  type: 'form',
264
673
  fields: [
265
674
  {
@@ -275,9 +684,15 @@ const schema = {
275
684
  inputType: 'email',
276
685
  label: 'Email Address',
277
686
  required: true,
278
- validation: [
279
- { type: 'email', message: 'Invalid email format' }
280
- ]
687
+ // Rule name → rule. Not an array (see Schema API above), and there is no
688
+ // 'email' rule: the email check is the pattern the metadata path builds
689
+ // for a field of type `email`.
690
+ validation: {
691
+ pattern: {
692
+ value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
693
+ message: 'Invalid email format'
694
+ }
695
+ }
281
696
  },
282
697
  {
283
698
  name: 'country',
@@ -304,51 +719,118 @@ const schema = {
304
719
 
305
720
  ### Multi-Step Form
306
721
 
722
+ A multi-step form is an **`object-form` with `formType: 'wizard'`**, and its
723
+ steps are its `sections` — one step per section. There is no
724
+ `multi-step-form` schema type (no registration anywhere claims that name) and no
725
+ `steps` key on any form schema, so a schema written that way renders the
726
+ unknown-component placeholder and the fields inside `steps` are never read.
727
+
307
728
  ```typescript
308
- const schema = {
309
- type: 'multi-step-form',
310
- steps: [
729
+ import type { ObjectFormBlockNode } from '@object-ui/types';
730
+
731
+ // The props go in the node's `properties` bag — the members of
732
+ // `@objectstack/spec`'s `ComponentPropsMap['object-form']` row.
733
+ const schema: ObjectFormBlockNode = {
734
+ type: 'object-form',
735
+ properties: {
736
+ objectName: 'contacts', // the object whose fields the steps list
737
+ mode: 'create',
738
+ formType: 'wizard', // routes to WizardForm — needs at least one section
739
+ sections: [
740
+ {
741
+ name: 'personal',
742
+ label: 'Personal Info',
743
+ fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
744
+ },
745
+ {
746
+ name: 'contact',
747
+ label: 'Contact Info',
748
+ fields: ['email', 'phone']
749
+ }
750
+ ],
751
+ allowSkip: false, // see "Wizard steps and allowSkip" above
752
+ showStepIndicator: true
753
+ }
754
+ };
755
+ ```
756
+
757
+ A section's `fields` accepts three shapes — a field **name**, a spec
758
+ `FormFieldSchema` object (whose identity key is `field`), or an inline runtime
759
+ `FormField` object. The inline shape is what lets a wizard run with no data
760
+ source at all, which is the closest equivalent of the old snippet; note that
761
+ `WizardForm` reports a data-source-less submit through `onSuccess` (there is no
762
+ `onSubmit` on this schema). **Every** step's fields have to be the inline shape
763
+ for that to hold — one bare name among them means the form needed object
764
+ metadata it could not fetch, and the submit is refused rather than confirmed
765
+ (again, [What a form submits to](#what-a-form-submits-to)):
766
+
767
+ ```tsx
768
+ import { WizardForm } from '@object-ui/plugin-form';
769
+ import type { WizardFormSchema } from '@object-ui/plugin-form';
770
+
771
+ const wizard: WizardFormSchema = {
772
+ type: 'object-form',
773
+ formType: 'wizard',
774
+ objectName: 'contacts',
775
+ mode: 'create',
776
+ sections: [
311
777
  {
312
- title: 'Personal Info',
778
+ name: 'personal',
779
+ label: 'Personal Info',
313
780
  fields: [
314
781
  { name: 'firstName', type: 'input', label: 'First Name', required: true },
315
782
  { name: 'lastName', type: 'input', label: 'Last Name', required: true }
316
783
  ]
317
784
  },
318
785
  {
319
- title: 'Contact Info',
786
+ name: 'contact',
787
+ label: 'Contact Info',
320
788
  fields: [
321
789
  { name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
322
790
  { name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }
323
791
  ]
324
- },
325
- {
326
- title: 'Review',
327
- fields: []
328
792
  }
329
793
  ],
330
- onSubmit: (data) => {
794
+ onSuccess: (data) => {
331
795
  console.log('Multi-step form completed:', data);
332
796
  }
333
797
  };
798
+
799
+ <WizardForm schema={wizard} /> // dataSource omitted: every step lists inline fields
334
800
  ```
335
801
 
802
+ `WizardFormSchema` declares no index signature, so an invented key on *this*
803
+ type is a real compile error — unlike `ObjectFormSchema`, which inherits
804
+ `BaseSchema`'s `[key: string]: any`.
805
+
806
+ One more route exists and is worth knowing about rather than reinventing: a flat
807
+ `object-form` can be turned into a stepper on small viewports with
808
+ `mobile: { stepper: true | 'auto', stepperFieldsPerStep, stepperMinFields }`,
809
+ which feeds the same `WizardForm`.
810
+
336
811
  ### Form with Validation
337
812
 
338
813
  ```typescript
339
- const schema = {
814
+ import type { FormSchema } from '@object-ui/types';
815
+
816
+ const schema: FormSchema = {
340
817
  type: 'form',
818
+ validationMode: 'onBlur',
341
819
  fields: [
342
820
  {
343
821
  name: 'username',
344
822
  type: 'input',
345
823
  label: 'Username',
346
- required: true,
347
- validation: [
348
- { type: 'minLength', value: 3, message: 'Username must be at least 3 characters' },
349
- { type: 'maxLength', value: 20, message: 'Username must be less than 20 characters' },
350
- { type: 'pattern', value: '^[a-zA-Z0-9_]+$', message: 'Only letters, numbers, and underscores' }
351
- ]
824
+ required: true, // presence: this is the key that decides it
825
+ validation: {
826
+ required: 'Pick a username', // the MESSAGE for the rule above, nothing more
827
+ minLength: { value: 3, message: 'Username must be at least 3 characters' },
828
+ maxLength: { value: 20, message: 'Username must be less than 20 characters' },
829
+ pattern: {
830
+ value: /^[a-zA-Z0-9_]+$/, // a RegExp, not a string
831
+ message: 'Only letters, numbers, and underscores'
832
+ }
833
+ }
352
834
  },
353
835
  {
354
836
  name: 'password',
@@ -356,42 +838,255 @@ const schema = {
356
838
  inputType: 'password',
357
839
  label: 'Password',
358
840
  required: true,
359
- validation: [
360
- { type: 'minLength', value: 8, message: 'Password must be at least 8 characters' },
361
- { type: 'pattern', value: '(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])', message: 'Must contain uppercase, lowercase, and number' }
362
- ]
841
+ validation: {
842
+ minLength: { value: 8, message: 'Password must be at least 8 characters' },
843
+ pattern: {
844
+ value: /(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])/,
845
+ message: 'Must contain uppercase, lowercase, and number'
846
+ },
847
+ validate: (value) =>
848
+ String(value).toLowerCase() !== 'password' || 'Pick something less guessable'
849
+ }
363
850
  }
364
851
  ]
365
852
  };
366
853
  ```
367
854
 
855
+ Each rule appears **once**, under its own name — an object, not a list. A second
856
+ `minLength` cannot exist, which is the point: the shape the renderer hands
857
+ react-hook-form is one rule per kind.
858
+
859
+ ## What a form submits to
860
+
861
+ A form has somewhere to put the values when it has **either** a `dataSource` (it
862
+ writes through the adapter) **or** a declared `submitHandler` (the host owns the
863
+ write and needs no adapter of its own). With neither, exactly one shape is still
864
+ legitimate: **fields authored inline**, where the author's `onSuccess` *is* the
865
+ write. That is
866
+
867
+ - a non-empty `customFields`, or
868
+ - `sections` whose fields are **all** inline runtime `FormField` objects.
869
+
870
+ Anything else — a form naming fields the object schema would have to resolve,
871
+ with no adapter to fetch it and no host seam to hand the values to — **refuses**
872
+ the submit with `DataSource is required for form submission (inline mode not
873
+ configured)`. The error reaches `schema.onError` and is rethrown.
874
+
875
+ This is uniform across all six renderers (`simple`, `tabbed`, `wizard`, `split`,
876
+ `drawer`, `modal`). It used to be uniform in the other direction: the five
877
+ variant renderers answered an adapter-less submit by calling `onSuccess` and
878
+ returning, which reported a save that never happened and, through
879
+ `MasterDetailForm`, produced a success toast over a form it then cleared
880
+ (objectui#6300). A declared `submitHandler` is consulted first, so a host that
881
+ said it owns the write is never bypassed for want of an adapter it never needed.
882
+
883
+ ## What an edit save writes
884
+
885
+ An `object-form` in `mode: 'edit'` with a `recordId` reads the record with
886
+ `dataSource.findOne`, and its save writes **only the fields that differ from
887
+ that read** (objectui#10156). Every layout does this: the simple form and the
888
+ `tabbed`, `wizard`, `split`, `modal` and `drawer` variants (objectui#10563). So
889
+ does a simple form whose mobile `stepper` option shows it one step at a time
890
+ through the wizard, and the parent operation of a master-detail form, whose
891
+ header is a simple or a `tabbed` form. A master-detail child row already worked
892
+ this way (objectui#10108), and all of them use the same comparison.
893
+
894
+ Before that comparison, every layout strips what a form never writes: the
895
+ server-owned columns (record identity, audit provenance, ownership and tenancy
896
+ — the roster in `sanitize.ts`, plus any field the object marks `system`),
897
+ computed, formula and read-only columns, keys the object does not declare, and
898
+ every field the caller's field-level security refuses. A create is stripped the
899
+ same way. Every layout also renders a field the caller may read but not edit as
900
+ a disabled input, so nothing is typed into a field the save would leave out.
901
+ Every layout likewise disables every field of a managed object (ADR-0092 D4)
902
+ whose `userActions` do not open the form's mode, `create` on a create form and
903
+ `edit` on an edit form, and of any object whose `create` or `update` the
904
+ server's effective API operations deny (objectui#10612). While that lock holds,
905
+ every layout shows one notice above the fields that names the object, by its
906
+ translated label, and the missing permission (`form.noPermissionToCreate` or
907
+ `form.noPermissionToEdit`, objectui#11000); a field locked on its own shows none.
908
+ The submit button of the simple, `tabbed`, `split`, `modal` and `drawer` layouts
909
+ stays on screen and the lock does not disable it; the server's write guard is
910
+ what refuses the save.
911
+ A `wizard` disables Next and its final submit button, and its step indicator does
912
+ not jump forward, while Cancel and Back stay usable.
913
+
914
+ The comparison sends every field it cannot prove unchanged, because a field
915
+ wrongly judged unchanged would lose the user's edit while the server still
916
+ answers 200:
917
+
918
+ - `null` and `undefined` count as the same value. `''` is not a blank here, so
919
+ `null` and `''` are different.
920
+ - A number and a numeric string are different (`5` and `'5'`).
921
+ - A lookup id and the expanded lookup object are different.
922
+ - A `Date` and a date string are different. So are two date strings written in
923
+ different formats.
924
+ - Objects and arrays are equal only when they serialize identically.
925
+ Reordering their keys or elements makes them different.
926
+
927
+ A field the form changed by itself after the read is a change, so it is sent.
928
+ That covers a cascade clear or a value cleared when its field was hidden.
929
+
930
+ Some saves still send the full payload:
931
+
932
+ - **A save with nothing changed.** It sends every field, as it always has.
933
+ That keeps it a real request, with the same concurrency guard and a real
934
+ server record for `onSuccess`.
935
+ - **A form with no record read of its own.** This includes a create, a record
936
+ given as `initialData` or through inline `customFields`, and a save made
937
+ while a new record is still loading. Each of these sends every field.
938
+
939
+ After a successful save, the form treats the fields it just wrote as saved. A
940
+ form that stays open therefore compares its next save with the record as it
941
+ stands now, not as it was first read.
942
+
943
+ The concurrency guard is unchanged by this rule. The update carries
944
+ `ifMatch` = the `updated_at` the form read. Once a form that stays open has
945
+ saved the record, its next save carries the `updated_at` that save returned
946
+ instead, so the form is not refused over its own earlier save. A `409` still
947
+ offers **Keep editing** or **Overwrite**. **Overwrite** now resends only the
948
+ changed fields, so it no longer rewrites fields this user never touched.
949
+
950
+ ⚠️ A host `submitHandler` gets the same payload in edit mode. Normally that is
951
+ the changed fields; after a save with nothing changed, it is the full payload.
952
+ A host that needs the whole record must read it itself.
953
+
368
954
  ## Integration with Data Sources
369
955
 
370
- Connect forms to backend APIs:
956
+ **The adapter is not a schema key.** A schema is a serialisable document; a live
957
+ adapter is an object with methods, so it cannot travel in one. Both form routes
958
+ read the adapter from React context, which the host installs once above the whole
959
+ tree — the same rule [Registering a component under your own
960
+ key](#registering-a-component-under-your-own-key) states for custom
961
+ registrations.
962
+
963
+ ### The metadata route — `object-form`
964
+
965
+ This is the route that actually reads and writes a record. An `object-form`
966
+ node names its object with **`objectName`** and its intent with **`mode`**, both
967
+ in its `properties` bag, whose members are `@objectstack/spec`'s
968
+ `ComponentPropsMap['object-form']` row; `objectui validate` refuses a prop
969
+ written flat on the node by name. `SchemaRenderer` hoists the bag onto the node,
970
+ and `ObjectFormSchema` (`packages/types/src/objectql.ts`) is the node as
971
+ `ObjectForm` then reads it, with both keys required. The adapter arrives on the
972
+ context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
973
+ `ObjectForm` its `dataSource` prop.
974
+
975
+ ```tsx
976
+ import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
977
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
978
+ import '@object-ui/plugin-form';
979
+ import type { ObjectFormBlockNode } from '@object-ui/types';
980
+
981
+ const dataSource = createObjectStackAdapter({
982
+ baseUrl: 'https://api.example.com',
983
+ token: 'your-auth-token',
984
+ });
985
+
986
+ const schema: ObjectFormBlockNode = {
987
+ type: 'object-form',
988
+ properties: {
989
+ objectName: 'users',
990
+ mode: 'create',
991
+ fields: ['name', 'email'],
992
+ submitText: 'Create user',
993
+ },
994
+ };
995
+
996
+ export const App = () => (
997
+ <SchemaRendererProvider dataSource={dataSource}>
998
+ <SchemaRenderer schema={schema} />
999
+ </SchemaRendererProvider>
1000
+ );
1001
+ ```
1002
+
1003
+ The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'`
1004
+ or `'view'`, add the `recordId` of the record being opened. Note that this
1005
+ `mode` vocabulary is `'create' | 'edit' | 'view'` and it lives on `object-form`
1006
+ only — the basic `form` node has no `mode` (retired by objectui#10286, see
1007
+ [Schema API](#schema-api)).
1008
+
1009
+ ### The TypeScript route — basic `form`
1010
+
1011
+ A bare `form` never fetches or saves by itself: it has no object name and no
1012
+ query, so there is nothing for it to call an adapter *with*. It collects values
1013
+ and hands them to your `onSubmit`, which the renderer awaits
1014
+ (`packages/components/src/renderers/form/form.tsx:1428`). Any persistence is
1015
+ whatever that function does:
371
1016
 
372
1017
  ```typescript
373
1018
  import { createObjectStackAdapter } from '@object-ui/data-objectstack';
1019
+ import type { FormSchema } from '@object-ui/types';
374
1020
 
375
1021
  const dataSource = createObjectStackAdapter({
376
1022
  baseUrl: 'https://api.example.com',
377
- token: 'your-auth-token'
1023
+ token: 'your-auth-token',
378
1024
  });
379
1025
 
380
- const schema = {
1026
+ const schema: FormSchema = {
381
1027
  type: 'form',
382
- dataSource,
383
- resource: 'users',
384
- fields: [/* fields */],
1028
+ fields: [
1029
+ { name: 'name', type: 'input', label: 'Full Name', required: true },
1030
+ { name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
1031
+ ],
1032
+ submitLabel: 'Create user',
1033
+ // The adapter reaches this call through the CLOSURE, not through the schema.
385
1034
  onSubmit: async (data) => {
386
1035
  await dataSource.create('users', data);
387
- }
1036
+ },
388
1037
  };
389
1038
  ```
390
1039
 
1040
+ `onSubmit` is a function, so this route is **TypeScript-authored schemas only** —
1041
+ a JSON metadata document cannot carry one. Metadata pages take the `object-form`
1042
+ route above.
1043
+
1044
+ What the adapter on the context still does for a bare `form` is supply the
1045
+ **field widgets**: the renderer reads it at `form.tsx:1004`
1046
+ (`const contextDataSource = schemaCtx?.dataSource ?? null`) and passes it down
1047
+ per field at `:2061`, which is how a lookup or cascading select loads its
1048
+ options. Nothing else about the form is wired to it.
1049
+
1050
+ ### Keys that look like wiring but are not
1051
+
1052
+ Neither of these is a key of either form schema, and writing them changes
1053
+ nothing:
1054
+
1055
+ | Written on a form schema | What actually happens |
1056
+ |---|---|
1057
+ | `dataSource` | **Discarded.** The basic form strips it in both directions — `dataSource: _dataSource` at `form.tsx:304` (`stripRendererOnlyProps`) and `:2168` — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one |
1058
+ | `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all. It used to exist elsewhere in the protocol — on `CRUDSchema`, where `CRUDBuilder` set it — but objectui#5373 retired both under ADR-0049, so today the key names nothing anywhere in this package's surface, and no form renderer reads it under any spelling |
1059
+
1060
+ Both survive compilation for the reason [Schema API](#schema-api) gives: `FormSchema`
1061
+ and `ObjectFormSchema` extend `BaseSchema`, which declares `[key: string]: any`, so an
1062
+ invented key is not a type error — it is simply never read. That is also why the
1063
+ older version of this section looked like it worked: its `onSubmit` genuinely ran
1064
+ and genuinely saved, but through the adapter its closure captured. The `dataSource`
1065
+ and `resource` keys sitting beside it in the same object were inert. Delete them and
1066
+ that example behaves identically — which is the test for whether a key is doing
1067
+ anything.
1068
+
1069
+ > A top-level `dataSource` **does** mean something on a schema node, but it is not
1070
+ > an adapter: it is the spec's element **binding** (`PageComponentSchema.dataSource`,
1071
+ > objectstack#6953) — a descriptor such as `{ object: 'users' }` — resolved by
1072
+ > `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts:139`).
1073
+ > `object-form` passes through that gate, and honours the binding's `object` key
1074
+ > only. Handing that slot a live adapter is rejected on purpose: the predicate
1075
+ > refuses any value carrying a `find` method
1076
+ > (`packages/core/src/data-scope/element-data-source.ts:131`), so an adapter written
1077
+ > there is ignored rather than mistaken for a binding. Pass adapters through the
1078
+ > provider above.
1079
+
391
1080
  ## TypeScript Support
392
1081
 
1082
+ `FormSchema` and `FormField` are protocol types, so they live in
1083
+ `@object-ui/types` alongside the rest of the JSON contract. This package imports
1084
+ them and does not re-export them — the form types on its own entry are the
1085
+ per-container ones (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …)
1086
+ listed under [Public exports](#public-exports).
1087
+
393
1088
  ```typescript
394
- import type { FormSchema, FormField } from '@object-ui/plugin-form';
1089
+ import type { FormSchema, FormField } from '@object-ui/types';
395
1090
 
396
1091
  const emailField: FormField = {
397
1092
  name: 'email',