@object-ui/plugin-form 17.6.0 → 18.0.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 (72) hide show
  1. package/CHANGELOG.md +4991 -0
  2. package/README.md +522 -80
  3. package/dist/DrawerForm.d.ts +42 -3
  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 +79 -38
  10. package/dist/MasterDetailForm.d.ts.map +1 -1
  11. package/dist/ModalForm.d.ts +60 -5
  12. package/dist/ModalForm.d.ts.map +1 -1
  13. package/dist/ObjectForm.d.ts.map +1 -1
  14. package/dist/SplitForm.d.ts +49 -3
  15. package/dist/SplitForm.d.ts.map +1 -1
  16. package/dist/TabbedForm.d.ts +75 -5
  17. package/dist/TabbedForm.d.ts.map +1 -1
  18. package/dist/WizardForm.d.ts +145 -38
  19. package/dist/WizardForm.d.ts.map +1 -1
  20. package/dist/autoLayout.d.ts +113 -8
  21. package/dist/autoLayout.d.ts.map +1 -1
  22. package/dist/closedAffordanceNotice.d.ts +14 -0
  23. package/dist/closedAffordanceNotice.d.ts.map +1 -0
  24. package/dist/customFieldsMerge.d.ts +29 -0
  25. package/dist/customFieldsMerge.d.ts.map +1 -0
  26. package/dist/deriveMasterDetail.d.ts +82 -29
  27. package/dist/deriveMasterDetail.d.ts.map +1 -1
  28. package/dist/fieldGroups.d.ts +189 -0
  29. package/dist/fieldGroups.d.ts.map +1 -1
  30. package/dist/fieldWriteGate.d.ts +136 -0
  31. package/dist/fieldWriteGate.d.ts.map +1 -0
  32. package/dist/flatFields.d.ts +11 -2
  33. package/dist/flatFields.d.ts.map +1 -1
  34. package/dist/formChrome.d.ts +55 -0
  35. package/dist/formChrome.d.ts.map +1 -0
  36. package/dist/index.d.ts +149 -26
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +3984 -2332
  39. package/dist/index.umd.cjs +1 -1
  40. package/dist/initialFieldFocus.d.ts +23 -0
  41. package/dist/initialFieldFocus.d.ts.map +1 -0
  42. package/dist/initialRecord.d.ts +72 -0
  43. package/dist/initialRecord.d.ts.map +1 -0
  44. package/dist/loadFailure.d.ts +28 -0
  45. package/dist/loadFailure.d.ts.map +1 -0
  46. package/dist/masterDetailTx.d.ts.map +1 -1
  47. package/dist/occSave.d.ts +6 -1
  48. package/dist/occSave.d.ts.map +1 -1
  49. package/dist/recordInvalidation.d.ts +33 -0
  50. package/dist/recordInvalidation.d.ts.map +1 -0
  51. package/dist/recordReadGate.d.ts +34 -0
  52. package/dist/recordReadGate.d.ts.map +1 -0
  53. package/dist/sanitize.d.ts +272 -5
  54. package/dist/sanitize.d.ts.map +1 -1
  55. package/dist/schemaDefaults.d.ts +104 -9
  56. package/dist/schemaDefaults.d.ts.map +1 -1
  57. package/dist/sectionFields.d.ts +67 -2
  58. package/dist/sectionFields.d.ts.map +1 -1
  59. package/dist/sectionGroups.d.ts +62 -0
  60. package/dist/sectionGroups.d.ts.map +1 -0
  61. package/dist/sectionPredicateDiagnostic.d.ts +38 -0
  62. package/dist/sectionPredicateDiagnostic.d.ts.map +1 -0
  63. package/dist/submitTarget.d.ts +136 -0
  64. package/dist/submitTarget.d.ts.map +1 -0
  65. package/dist/successBehavior.d.ts +44 -25
  66. package/dist/successBehavior.d.ts.map +1 -1
  67. package/dist/thankYouRedirectCountdown.d.ts +1 -1
  68. package/dist/uploadGate.d.ts +35 -0
  69. package/dist/uploadGate.d.ts.map +1 -0
  70. package/dist/writePayload.d.ts +87 -0
  71. package/dist/writePayload.d.ts.map +1 -0
  72. package/package.json +13 -12
package/README.md CHANGED
@@ -38,7 +38,7 @@ const schema = {
38
38
  ### What the side-effect import registers
39
39
 
40
40
  There is no component map to iterate: registration is a side effect of importing
41
- the package entry, which makes six `ComponentRegistry.register(...)` calls. These
41
+ the package entry, which makes five `ComponentRegistry.register(...)` calls. These
42
42
  are the schema types those calls claim, read off the calls themselves in
43
43
  `src/index.tsx`:
44
44
 
@@ -47,7 +47,6 @@ are the schema types those calls claim, read off the calls themselves in
47
47
  | `plugin-form:object-form` | `object-form` | `ObjectForm` — metadata-driven form over one record |
48
48
  | `view:form` | none | the same renderer under the view protocol |
49
49
  | `plugin-form:embeddable-form` | `embeddable-form` | `EmbeddableForm` — standalone public form |
50
- | `plugin-form:form-analytics` | `form-analytics` | `FormAnalytics` — submission dashboard |
51
50
  | `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent + child line items in one submit |
52
51
  | `record:line_items` | none | `LineItemsPanel` — child grid bound to the record on the page |
53
52
 
@@ -57,10 +56,20 @@ for backwards compatibility, unless the call passes `skipFallback: true`
57
56
  stays the basic `@object-ui/components` form, and bare `line_items` is left to
58
57
  whoever else claims it.
59
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
+
60
63
  ### Public exports
61
64
 
62
- The package entry exports these — components, their prop/schema types, and the
63
- layout helpers. There is no aggregate map among them:
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
+ with `sectionEntryName` to read the entries it returns
71
+ (see [What a section's `fields` entries draw](#what-a-sections-fields-entries-draw)).
72
+ There is no aggregate map among them:
64
73
 
65
74
  ```typescript
66
75
  import {
@@ -90,6 +99,11 @@ import {
90
99
  deriveFormFields,
91
100
  findRelationshipField,
92
101
  resolveInlineMode,
102
+ omitServerResolvedDefaults,
103
+ isRequiredInForm,
104
+ optionListDefault,
105
+ resolveSectionGroupReferences,
106
+ sectionEntryName,
93
107
  } from '@object-ui/plugin-form';
94
108
 
95
109
  import type {
@@ -118,9 +132,35 @@ import type {
118
132
  LineItemsPanelSchema,
119
133
  DerivedDetail,
120
134
  InlineMode,
135
+ ChildObjectSchemaLike,
136
+ FieldDefaultsSchemaLike,
137
+ ResolveSectionGroupsOptions,
121
138
  } from '@object-ui/plugin-form';
122
139
  ```
123
140
 
141
+ ### Deriving a master-detail grid outside the form
142
+
143
+ `deriveDetail` and `deriveColumns` build a child collection's grid columns from
144
+ the child object's schema. A `boolean` or `toggle` child field becomes a select
145
+ column whose two options store `'true'` and `'false'`; their labels are the
146
+ session's word for each value, so both functions require a `booleanLabel`. Pass
147
+ the one `@object-ui/fields` publishes for this, called in your component:
148
+
149
+ ```tsx
150
+ import { useBooleanValueLabel } from '@object-ui/fields';
151
+ import { deriveDetail, type ChildObjectSchemaLike } from '@object-ui/plugin-form';
152
+
153
+ declare const childSchema: ChildObjectSchemaLike; // as your DataSource's getObjectSchema serves it
154
+
155
+ export function useInvoiceLines() {
156
+ const booleanLabel = useBooleanValueLabel(); // common.yes / common.no
157
+ return deriveDetail('invoice_line', childSchema, 'invoice', { booleanLabel });
158
+ }
159
+ ```
160
+
161
+ There is no default: an English one would label the options in English for a
162
+ session in any other language.
163
+
124
164
  ### Registering a component under your own key
125
165
 
126
166
  To put one of these components on a schema type of your own, register the
@@ -137,9 +177,10 @@ The renderers this package registers for itself are internal wrappers rather
137
177
  than these exported components. `SchemaRenderer` hands a registered component its
138
178
  `schema` (plus the schema's own props), but never a `dataSource` — that travels
139
179
  on `SchemaRendererContext` — so each wrapper reads it off the context first.
140
- `ObjectForm` takes `dataSource` as a prop, optional because inline `customFields`
141
- need no adapter, so a custom-key registration either supplies one or wraps the
142
- component the same way.
180
+ `ObjectForm` takes `dataSource` as a prop, optional because inline fields need no
181
+ adapter, so a custom-key registration either supplies one or wraps the component
182
+ the same way. "Optional" is not "absent is fine for every form", though — see
183
+ [What a form submits to](#what-a-form-submits-to).
143
184
 
144
185
  ## Schema API
145
186
 
@@ -172,7 +213,7 @@ unknown-component placeholder.
172
213
  | `columns` | `number` | grid width (1–4) |
173
214
  | `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run |
174
215
  | `resetOnSubmit` / `disabled` | `boolean` | |
175
- | `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode |
216
+ | `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 |
176
217
  | `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) |
177
218
  | `previousValues` | `Record<string, any>` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere |
178
219
  | `fieldContainerClass` | `string` | class for the field grid inside the `<form>` |
@@ -183,16 +224,17 @@ unknown-component placeholder.
183
224
  | `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 |
184
225
  | `className`, `id`, `hidden`, … | — | inherited from `BaseSchema` |
185
226
 
186
- ⚠️ `FormSchema` extends `BaseSchema`, which declares `[key: string]: any`
187
- (`packages/types/src/base.ts`), so an invented or misspelled key on a form schema
188
- is **not** a compile error — it is simply never read. That is why every example
189
- below is annotated with its real type *and* checked against these key tables:
190
- an un-annotated `const schema = { … }` type-checks whatever is written in it.
227
+ `FormSchema` extends `BaseSchema`, which declares no index signature
228
+ (objectui#8347), so an invented or misspelled key in a form schema literal
229
+ annotated with its type is a compile error. That is why every example below is
230
+ annotated with its real type: an un-annotated `const schema = { … }` type-checks
231
+ whatever is written in it. (`fields` entries are `FormField`s, which still carry
232
+ their own index signature — see below.)
191
233
 
192
234
  ### Form Field
193
235
 
194
- `FormField` (`packages/types/src/form.ts`) declares 23 keys, and `name` is the
195
- only **required** one:
236
+ `FormField` (`packages/types/src/form.ts`): `name` is the only **required**
237
+ key:
196
238
 
197
239
  | Key | Type | What it does |
198
240
  |---|---|---|
@@ -210,21 +252,21 @@ only **required** one:
210
252
  | `hidden` | `boolean` | field is not rendered at all |
211
253
  | `options` | `SelectOption[] \| RadioOption[]` | for `select` / radio fields |
212
254
  | `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below |
213
- | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in, custom }` matcher |
255
+ | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in }` matcher (`custom` is retired, objectui#7759: nothing ran it) |
214
256
  | `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 |
215
- | `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) |
257
+ | `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate: objectui's runtime slot (not a spec key) a form view field's `visibleWhen` is carried into, ANDed with the field's own `visibleWhen` above. A form **view** field writes `visibleWhen` (its old `visibleOn` spelling is retired in `@objectstack/spec` 18 and is not read), and a section entry written as a bare field name carries no view-level predicate |
216
258
  | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries |
217
259
  | `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) |
218
260
  | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count |
219
- | `field` | `Record<string, any>` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, … 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` |
261
+ | `className` | `string` | the classes of the element the field's type renders (its control, or a `section-divider` row's divider), joined with that element's own classes; never the field row: `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the grid |
262
+ | `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` |
220
263
 
221
- `FormField` also declares `[key: string]: any`, so an invented key type-checks
222
- here too. Two that a reader might expect, and that are **not** declared:
264
+ `FormField` declares its own `[key: string]: any`, so an invented key on a field
265
+ still type-checks. One that a reader might expect, and that is **not** declared:
223
266
 
224
267
  | Not a `FormField` key | Write this instead |
225
268
  |---|---|
226
269
  | `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) |
227
- | `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* on exactly one pseudo-field, `type: 'section-divider'`, where it styles the inline section header.) |
228
270
 
229
271
  There is no `ValidationRule` type in this repo, under any spelling.
230
272
 
@@ -238,7 +280,7 @@ There is no `ValidationRule` type in this repo, under any spelling.
238
280
  | `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** |
239
281
  | `minLength` / `maxLength` | `{ value: number; message: string }` | `message` is not optional when you author the rule by hand |
240
282
  | `min` / `max` | `{ value: number; message: string }` | numeric range |
241
- | `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 |
283
+ | `pattern` | `{ value: RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and nothing compiles a string into one. An object field has no `pattern` key: a regex check on an object is its `format` validation rule, which the server runs on save |
242
284
  | `validate` | `(value) => boolean \| string \| Promise<boolean \| string>` | custom check; TypeScript-authored schemas only |
243
285
 
244
286
  There is no `email` rule name — an email check is a `pattern`, which is exactly
@@ -247,7 +289,7 @@ what `buildValidationRules` emits for an object field of type `email`.
247
289
  **Why the array spelling fails silently.** The only reader of this key is the
248
290
  basic form renderer, which spreads it into the rule object handed to
249
291
  react-hook-form — `const rules: any = { ...validation }`
250
- (`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array**
292
+ (`renderFormField` in `packages/components/src/renderers/form/form.tsx`). Spreading an **array**
251
293
  into an object literal produces numeric keys (`{ '0': …, '1': … }`), which
252
294
  react-hook-form does not recognise: every rule is dropped, nothing throws, and
253
295
  the form looks validated while validating nothing.
@@ -255,29 +297,60 @@ the form looks validated while validating nothing.
255
297
  ### What a create form opens with
256
298
 
257
299
  A create form has no persisted record, so its opening values come from the
258
- object schema's declared field `defaultValue`s. Every object-form container
300
+ object schema's declared defaults: a field's `defaultValue`, or the option its
301
+ option list marks `default: true`. Every object-form container
259
302
  (`ObjectForm`, `ModalForm`, `DrawerForm`, `TabbedForm`, `SplitForm`,
260
303
  `WizardForm`) resolves them the same way, through `schemaDefaults`:
261
304
 
262
305
  | Declared | Create form opens with | Why |
263
306
  |---|---|---|
264
307
  | `defaultValue: 'draft'` (any static literal) | `draft`, preselected and submittable | the value is known; making the user pick it is busywork, and on a status-like field every wrong option is one click away |
265
- | `defaultValue: 'NOW()'` / `'current_user'` (a runtime token) | empty | the token is an *instruction*, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it |
266
- | `defaultValue: cel\`today()\`` (an Expression envelope) | empty | same reason: the server evaluates it per insert |
267
- | an option's `default: true` | empty | see below |
308
+ | `defaultValue: 'NOW()'` / `'current_user'` (a runtime token) | empty, with *Set automatically when saved.* under it | the token is an *instruction*, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it |
309
+ | `defaultValue: cel\`today()\`` (an Expression envelope) | empty, with *Set automatically when saved.* under it | same reason: the server evaluates it per insert |
310
+ | an option's `default: true`, and no `defaultValue` | that option, preselected and submittable | the server stores the marked option when the field is omitted; see below |
268
311
  | nothing | empty | no default is invented |
269
312
 
270
313
  `initialData` / `initialValues` passed by the caller always outrank a schema
271
314
  default — a lookup prefill or a "duplicate this record" seed is the more
272
315
  specific instruction.
273
316
 
274
- Only the **field-level** `defaultValue` is read, never a select option's
275
- `default: true`, even though `@objectstack/spec`'s `SelectOptionSchema` declares
276
- that key. The server's insert path resolves `defaultValue` and nothing else, so
277
- a form that also seeded from option-level `default` would preselect values the
278
- server would never have applied on its own — a renderer-side second default
279
- contract (AGENTS.md #0.1). If option-level `default` is meant to mean "the
280
- initial value", that belongs at the producer.
317
+ **The "set on save" hint (objectui#12108).** A field left empty for the server
318
+ is not left unexplained: the form renderer draws *Set automatically when saved.*
319
+ (locale key `form.serverDefaultHint`) in the field's description slot, after any
320
+ `description` the field declares, and the control's `aria-describedby` already
321
+ names that element. It is drawn exactly while the submit would omit the key — the
322
+ field is server-owned (`isServerOwnedValue` from `@object-ui/core`, the classifier
323
+ the `required` suppression below reads) and its value is empty by
324
+ `isMissingForRequired`, the predicate `omitServerResolvedDefaults` drops a key by.
325
+ So a value the user types removes it, and the hint never names the value, since the
326
+ client cannot evaluate the expression. It is not drawn:
327
+
328
+ - on an edit form, where the default was resolved at insert;
329
+ - on a control that takes no input (disabled or read-only). The containers send
330
+ `previousValues` in edit mode only, so the renderer reads a `mode: 'view'` form
331
+ as create, and this is what keeps the hint off it.
332
+
333
+ A boolean is no exception (objectui#12121). The renderer starts an unsupplied
334
+ two-state control at `false` (cloud#972) only when the field is not
335
+ server-owned, so a boolean with a runtime default opens unchecked with the hint
336
+ under it, and an untouched one is omitted from the submit. A value the user
337
+ sets, `false` included, is submitted as usual.
338
+
339
+ An option's `default: true` is read the way the server's insert path reads it
340
+ (`ObjectQL.applyFieldDefaults`, which falls back to the marked option), so the
341
+ form preselects exactly what omitting the field would store:
342
+
343
+ - only when the field declares no `defaultValue` — a field-level default,
344
+ static or runtime, always wins, and the flag is then not seeded at all;
345
+ - a single-valued field takes the **first** marked option;
346
+ - a multi-valued field (`@objectstack/spec`'s `isMultiValueField`: `multiselect`,
347
+ or `select` with `multiple: true`) takes **every** marked option, as an array.
348
+
349
+ That reading is published as `optionListDefault` (its parameter type is
350
+ `OptionListFieldLike`), so a host with its own form renderer seeds the same
351
+ option instead of re-deriving it (objectui#13049). The console's public form page
352
+ (`/f/:slug`) calls it. The `defaultValue` gate stays the caller's: call it only
353
+ for a field whose `defaultValue` is absent or `null`, as the containers do.
281
354
 
282
355
  **Edit forms are never seeded.** An edit form shows the row as the server holds
283
356
  it; folding a default in over a column the record leaves unset would arm a
@@ -289,6 +362,8 @@ field.
289
362
  A field may declare both, and it is coherent authoring — storage-level required,
290
363
  with the value guaranteed by the producer:
291
364
 
365
+ <!-- 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. -->
366
+
292
367
  ```ts
293
368
  remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
294
369
  ```
@@ -305,6 +380,8 @@ predicate says "required in this state", but `NOW()` / `current_user` resolve
305
380
  at insert regardless of state, so the producer's guarantee covers the
306
381
  conditional claim exactly as it covers the unconditional one:
307
382
 
383
+ <!-- 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. -->
384
+
308
385
  ```ts
309
386
  remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaultValue: 'NOW()' }),
310
387
  ```
@@ -319,8 +396,37 @@ remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaul
319
396
 
320
397
  The required marker and `aria-required` go with the rule in the create case,
321
398
  since one verdict drives all three — in that mode the user genuinely is not
322
- required to provide the value. Showing what the server *will* supply, as a
323
- non-authoritative preview, is a separate follow-up.
399
+ required to provide the value. The hint above says *that* the server supplies it;
400
+ showing *what* it will supply, as a non-authoritative preview, is a separate
401
+ follow-up.
402
+
403
+ The two halves of that verdict are importable, so a host with its own form
404
+ renderer applies the same rule rather than re-deriving it (objectui#6059):
405
+
406
+ ```typescript
407
+ declare const field: { required?: unknown; defaultValue?: unknown };
408
+ declare const isCreateForm: boolean;
409
+ declare const values: Record<string, unknown>;
410
+ declare const objectSchema: FieldDefaultsSchemaLike;
411
+
412
+ import {
413
+ isRequiredInForm,
414
+ omitServerResolvedDefaults,
415
+ type FieldDefaultsSchemaLike,
416
+ } from '@object-ui/plugin-form';
417
+
418
+ // Should this create form enforce `required` on the field?
419
+ const required = isRequiredInForm(field, isCreateForm);
420
+
421
+ // Drop the keys the producer must resolve, immediately before the insert.
422
+ const payload = isCreateForm ? omitServerResolvedDefaults(values, objectSchema) : values;
423
+ ```
424
+
425
+ They are published as a pair on purpose: excusing a server-owned field from
426
+ `required` and then submitting the key anyway is not half a fix, it is no fix.
427
+ Both are pure functions over plain data — no React, no registry — and
428
+ `omitServerResolvedDefaults` is **create-only**, so the caller keeps the mode
429
+ gate, as the example shows.
324
430
 
325
431
  Every consumer reads one predicate, `isRuntimeDefault` in `@object-ui/core`
326
432
  (re-exported for this package's own use by `src/schemaDefaults.ts`, not from the
@@ -332,6 +438,86 @@ evaluator that resolves it — `resolveFieldRuleState`, reading
332
438
  `isServerOwnedValue`. Display and validation share that single verdict, so a
333
439
  field can never lose its asterisk while still refusing the submit.
334
440
 
441
+ ### Resolving `sections[].group` outside this package
442
+
443
+ A form section declares its members exactly one way: it enumerates `fields`, or
444
+ it points `group` at one of the object's declared `fieldGroups` and inherits that
445
+ group's members **and** its presentation (objectstack#13855, ADR-0085 §5 — the
446
+ spec range that carries it is the `@objectstack/spec` entry in this package's own
447
+ `package.json`). `ObjectForm` resolves the reference once, above its routing
448
+ fork, so all six layouts inherit it. `ModalForm` resolves its own `sections`
449
+ through the same call as well, because hosts mount it directly rather than
450
+ through `ObjectForm` — the console's record create / edit dialog and
451
+ action-opened modals do (objectui#11542). `DrawerForm` mounted directly does
452
+ not resolve them; reach it through `ObjectForm` with `formType: 'drawer'`.
453
+
454
+ A host with its **own** section builder resolves it with the same function
455
+ instead of deriving sections itself (objectui#8641):
456
+
457
+ ```typescript
458
+ import {
459
+ resolveSectionGroupReferences,
460
+ type ResolveSectionGroupsOptions,
461
+ } from '@object-ui/plugin-form';
462
+ import type { ObjectFormSection } from '@object-ui/types';
463
+
464
+ // What the host's own builder produced. A `{ group: 'x' }` section is still
465
+ // unresolved here — `ObjectFormSection` declares `group` and leaves `fields`
466
+ // optional, which is the whole shape this call takes in.
467
+ declare const authoredSections: ObjectFormSection[];
468
+ declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading
469
+
470
+ const sections = resolveSectionGroupReferences(authoredSections, {
471
+ objectName: 'ticket',
472
+ formType: 'simple',
473
+ objectDef,
474
+ });
475
+ ```
476
+
477
+ Every `{ group: 'x' }` section comes back as the section that group declares —
478
+ label, members, description and collapse state — and everything else comes back
479
+ **untouched**: with no reference in the list the input array is returned by
480
+ identity, so the call cannot perturb an existing form and is safe inside a
481
+ `useMemo`. A reference that resolves to nothing yields an empty section (never a
482
+ dropped one — a `sections` array that empties out stops being a sectioned form at
483
+ all) and is reported once, naming the object and the key.
484
+
485
+ ⛔ Nothing about the assembly belongs to the caller. Declared order, the
486
+ empty-group drop, the ungrouped trailing bucket and the collapse / `visibleWhen`
487
+ passthrough all come from `deriveFieldGroupLayout` through this package's single
488
+ adapter onto the section shape — the same code path the no-sections field-group
489
+ fallback uses, so a group authored by reference and one derived by the fallback
490
+ are the same section by construction. A host reaching for `deriveFieldGroupLayout`
491
+ directly would have to re-spell the `collapse` enum onto its own boolean pair and
492
+ pass `visibleWhen` through by hand, which is the duplication this export exists to
493
+ prevent.
494
+
495
+ ### What a section's `fields` entries draw
496
+
497
+ A section's `fields` takes three entry shapes, and every `formType` draws them
498
+ the same way: a field **name** and the form view's `{ field, … }` entry (which
499
+ overrides that object field) are resolved against the object schema, while an
500
+ inline runtime `FormField` keyed by `name` carries its own definition and is
501
+ drawn as it stands.
502
+
503
+ On the default (`simple`) form the two named shapes resolve against the form's
504
+ parent field pool — top-level `fields` when given, else the object's fields,
505
+ plus `customFields` — so a named member the pool does not hold is dropped
506
+ (reported once when the object declares it: `fields` and `sections`
507
+ intersect). An inline entry names nothing to resolve, so it is drawn whatever
508
+ the pool holds, exactly as the other five layouts draw it (objectui#11615). A
509
+ `simple` form whose sections list only inline entries is therefore a
510
+ self-contained collector, like the inline wizard below — see
511
+ [What a form submits to](#what-a-form-submits-to).
512
+
513
+ Code that reads a section's entries — a host holding the sections
514
+ `resolveSectionGroupReferences` returns, say — names each one with
515
+ `sectionEntryName(entry)`: the string itself, the `{ field }` entry's `field`,
516
+ the inline entry's `name` (`undefined` when the entry names nothing). Narrowing
517
+ by hand and reading `.name` off every object entry reads `undefined` off a
518
+ `{ field }` entry, and `ObjectFormSection.fields` now types that read as an
519
+ error.
520
+
335
521
  ### Column width of a sectioned form
336
522
 
337
523
  A sectioned form renders as ONE grid, and two keys decide its shape:
@@ -352,6 +538,42 @@ The grid is applied to the field container **inside** the form, never wrapped
352
538
  around the `<form>` (which would put the whole form in cell 1 and leave the other
353
539
  columns empty — #2128).
354
540
 
541
+ ### Section styling is not authorable
542
+
543
+ `className` and `gridClassName` on a form-view **section** do nothing. In JSON
544
+ metadata, authoring them is not a parse error — the renderer simply does not
545
+ read them. In TypeScript they are a compile error: `ObjectFormSection` in
546
+ `@object-ui/types` does not declare either key (objectui#7200), matching the
547
+ spec's strict `FormSectionSchema`.
548
+
549
+ This is deliberate. Both keys sit on the SDUI-only side of the authorable
550
+ boundary: `@objectstack/spec` does not declare either on the form-view/section
551
+ surface, and the authorable-surface ledger carries no entry for them. The
552
+ renderer nevertheless reached them through `as any` at seven sites until
553
+ objectstack#13626 (maintainer ruling 2026-09-01) retired those reads — the
554
+ boundary had been declared on one side and crossed on the other.
555
+
556
+ Declaring the keys instead was weighed and rejected: it would invite free
557
+ Tailwind strings into authored metadata, which is the class the boundary exists
558
+ to keep out, and per ADR-0065 / ADR-0080 utility classNames in runtime metadata
559
+ are never scanned by the build-time Tailwind — so they silently produce no CSS
560
+ even when they are read. If per-view styling becomes a real product need it will
561
+ get an explicit controlled token surface rather than two leaked keys.
562
+
563
+ **What still works:** the form ROOT `className` (`FormSchema.className`) is a
564
+ different key on a different node and is unaffected; section *layout* stays
565
+ authorable through `columns` (above); and a host application styles sections
566
+ through its own CSS.
567
+
568
+ > Note for contributors: `ObjectFormSection` in `@object-ui/types` no longer
569
+ > declares either key (objectui#7200), so an uncast `className: s.className` fails
570
+ > to compile — but a cast read (the shape the seven retired sites had) still
571
+ > would, and JSON metadata carries whatever an author wrote. The non-consumption
572
+ > is therefore pinned behaviourally, in
573
+ > `src/__tests__/sectionStyleKeysRetired-13626.test.tsx`, across all seven arms;
574
+ > the declaration side is pinned in `@object-ui/types`'
575
+ > `object-form-section-style-keys-undeclared.test.ts`.
576
+
355
577
  ### Tabbed field layout (`fieldTabs`)
356
578
 
357
579
  A sectioned form is **one** form. Instead of rendering a form per section — which
@@ -359,6 +581,8 @@ strands every section but the first outside the submit, and (in tabs) lets the
359
581
  inactive panel unmount with its values — declare tabs on the single form and let
360
582
  the renderer distribute the fields:
361
583
 
584
+ <!-- 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. -->
585
+
362
586
  ```typescript
363
587
  {
364
588
  type: 'form',
@@ -366,6 +590,7 @@ the renderer distribute the fields:
366
590
  fieldTabs: [
367
591
  { key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
368
592
  { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
593
+ { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'record.status == "won"' },
369
594
  ],
370
595
  defaultFieldTab?: 'basics', // defaults to the first tab
371
596
  fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
@@ -381,7 +606,89 @@ the renderer distribute the fields:
381
606
  - Fields no tab claims render above the tab strip rather than disappearing.
382
607
  - Needs at least two tabs, and is ignored when the form uses `children`.
383
608
 
384
- `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this.
609
+ `ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this, and
610
+ both forward a section's `visibleWhen` onto the tab they synthesise — see the
611
+ support table below.
612
+
613
+ #### Conditional tabs (`FormFieldTab.visibleWhen`)
614
+
615
+ A tab may carry a `visibleWhen` predicate — the same slot, vocabulary and
616
+ engine as the field-level rule (`string | { dialect?, source }`, a CEL
617
+ predicate over the live record, evaluated by `@objectstack/formula` with the
618
+ host predicate scope bound, so it can read `current_user` / `data` /
619
+ `features` exactly as a field rule can — there is no `app` root, objectui#8155).
620
+ Like every conditional rule in this
621
+ system it **fails open**: a predicate that cannot be evaluated leaves the tab
622
+ visible rather than hiding data behind a broken expression.
623
+
624
+ When the predicate resolves FALSE the renderer draws **neither the tab's
625
+ trigger nor its panel** — a hidden tab disappears from the tab strip entirely,
626
+ it is not merely an empty panel.
627
+
628
+ **Submit semantics are deliberately counter-intuitive — visibility gates
629
+ drawing, and nothing else:**
630
+
631
+ - **A hidden tab's values still submit.** Hiding a tab does not remove its
632
+ fields' values from the record: whatever they hold (loaded from the record,
633
+ seeded by defaults, or typed before the tab hid) is carried in the payload.
634
+ A visibility rule is not a data filter — dropping the values would turn a
635
+ cosmetic predicate into a silent data-loss door.
636
+ - **A hidden tab's fields skip client-side validation.** A user is never
637
+ blocked by an error pointing at a control they cannot see. The flip side:
638
+ a *required* field on a hidden tab sails past the client — **the
639
+ server-side contract is the loud floor** for genuinely-required data, and a
640
+ submit missing it fails there, visibly.
641
+ - **Stale errors clear.** A tab hiding mid-session (its predicate flipping on
642
+ a keystroke or a scope change) clears its fields' leftover validation
643
+ errors, the same hygiene a field's own `visibleWhen` applies.
644
+
645
+ These are not tab-specific rules: they are the ruled hidden-group semantics
646
+ every section `visibleWhen` follows, inherited through the same mechanism a
647
+ field's own FALSE predicate uses (the fields simply stop being drawn; the form
648
+ keeps their values and skips unmounted controls at validation).
649
+
650
+ Two mechanics worth knowing:
651
+
652
+ - **Selection is derived over the visible tabs.** If the predicate hides the
653
+ ACTIVE tab, the renderer re-selects deterministically — the user's own pick
654
+ if still visible, else `defaultFieldTab`, else the first visible tab — so
655
+ the form never shows an empty panel. The pick itself is kept: the tab the
656
+ user chose becomes active again the moment its predicate re-admits it.
657
+ - **Whether the tabbed layout engages at all is judged on the DECLARED tabs**
658
+ (the "needs at least two tabs" rule above counts declarations, not
659
+ verdicts). A predicate hiding all but one tab filters what is drawn; it
660
+ never collapses the strip into the flat layout mid-interaction.
661
+
662
+ **Where `visibleWhen` on a tab works today** — this key landed renderer-first,
663
+ and only where the renderer actually evaluates it:
664
+
665
+ | Authoring surface | Carries the predicate? |
666
+ | --- | --- |
667
+ | `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer |
668
+ | `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab |
669
+ | `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 |
670
+ | `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate |
671
+
672
+ The remaining **No** row is deliberate, not an oversight: declaring the key on a
673
+ surface whose renderer ignores it would make the metadata lie (#6111), so the
674
+ key stops at the boundary until each surface enforces it.
675
+
676
+ ⛔ **A wizard step has no predicate slot at all, and that is enforced by the
677
+ type.** A step predicate is not the tab predicate under another name — steps are
678
+ mounted one at a time and keyed by index, so a step predicate would be
679
+ step-boundary reactive where a tab's is live-record reactive, and it would need
680
+ navigation, indicator, `isLastStep`, final-gate and re-selection semantics that
681
+ none of the tab machinery supplies. So `WizardStepConfig` is declared
682
+ independently of `FormSectionConfig` (maintainer ruling 2026-08-30, #6237) and
683
+ simply does not carry the key: `visibleWhen` on a wizard step literal is a
684
+ **compile error**, not a silently ignored key. Track #6237 for the step
685
+ contract.
686
+
687
+ That row also **reports itself** rather than failing silently. Authoring a
688
+ section `visibleWhen` on `formType: 'wizard'` logs a console warning naming the
689
+ layout and the sections whose predicate is being dropped — untyped JSON reaches
690
+ the renderer too, so the compile-time half cannot be the only one. Nothing warns
691
+ on the five arms that honour the key.
385
692
 
386
693
  ### Wizard steps and `allowSkip`
387
694
 
@@ -409,6 +716,8 @@ The same rule for side-by-side panels: the `<form>` wraps the whole panel group
409
716
  and each pane holds only fields, so one react-hook-form instance spans the
410
717
  divider.
411
718
 
719
+ <!-- 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. -->
720
+
412
721
  ```typescript
413
722
  {
414
723
  type: 'form',
@@ -503,27 +812,31 @@ steps are its `sections` — one step per section. There is no
503
812
  unknown-component placeholder and the fields inside `steps` are never read.
504
813
 
505
814
  ```typescript
506
- import type { ObjectFormSchema } from '@object-ui/types';
815
+ import type { ObjectFormBlockNode } from '@object-ui/types';
507
816
 
508
- const schema: ObjectFormSchema = {
817
+ // The props go in the node's `properties` bag — the members of
818
+ // `@objectstack/spec`'s `ComponentPropsMap['object-form']` row.
819
+ const schema: ObjectFormBlockNode = {
509
820
  type: 'object-form',
510
- objectName: 'contacts', // required
511
- mode: 'create', // required
512
- formType: 'wizard', // routes to WizardForm — needs at least one section
513
- sections: [
514
- {
515
- name: 'personal',
516
- label: 'Personal Info',
517
- fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
518
- },
519
- {
520
- name: 'contact',
521
- label: 'Contact Info',
522
- fields: ['email', 'phone']
523
- }
524
- ],
525
- allowSkip: false, // see "Wizard steps and allowSkip" above
526
- showStepIndicator: true
821
+ properties: {
822
+ objectName: 'contacts', // the object whose fields the steps list
823
+ mode: 'create',
824
+ formType: 'wizard', // routes to WizardForm — needs at least one section
825
+ sections: [
826
+ {
827
+ name: 'personal',
828
+ label: 'Personal Info',
829
+ fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
830
+ },
831
+ {
832
+ name: 'contact',
833
+ label: 'Contact Info',
834
+ fields: ['email', 'phone']
835
+ }
836
+ ],
837
+ allowSkip: false, // see "Wizard steps and allowSkip" above
838
+ showStepIndicator: true
839
+ }
527
840
  };
528
841
  ```
529
842
 
@@ -532,7 +845,10 @@ A section's `fields` accepts three shapes — a field **name**, a spec
532
845
  `FormField` object. The inline shape is what lets a wizard run with no data
533
846
  source at all, which is the closest equivalent of the old snippet; note that
534
847
  `WizardForm` reports a data-source-less submit through `onSuccess` (there is no
535
- `onSubmit` on this schema):
848
+ `onSubmit` on this schema). **Every** step's fields have to be the inline shape
849
+ for that to hold — one bare name among them means the form needed object
850
+ metadata it could not fetch, and the submit is refused rather than confirmed
851
+ (again, [What a form submits to](#what-a-form-submits-to)):
536
852
 
537
853
  ```tsx
538
854
  import { WizardForm } from '@object-ui/plugin-form';
@@ -570,8 +886,8 @@ const wizard: WizardFormSchema = {
570
886
  ```
571
887
 
572
888
  `WizardFormSchema` declares no index signature, so an invented key on *this*
573
- type is a real compile error — unlike `ObjectFormSchema`, which inherits
574
- `BaseSchema`'s `[key: string]: any`.
889
+ type is a real compile error — and since objectui#8347 removed `BaseSchema`'s
890
+ `[key: string]: any`, the same holds for `ObjectFormSchema`.
575
891
 
576
892
  One more route exists and is worth knowing about rather than reinventing: a flat
577
893
  `object-form` can be turned into a stepper on small viewports with
@@ -626,6 +942,123 @@ Each rule appears **once**, under its own name — an object, not a list. A seco
626
942
  `minLength` cannot exist, which is the point: the shape the renderer hands
627
943
  react-hook-form is one rule per kind.
628
944
 
945
+ ## What a form submits to
946
+
947
+ A form has somewhere to put the values when it has **either** a `dataSource` (it
948
+ writes through the adapter) **or** a declared `submitHandler` (the host owns the
949
+ write and needs no adapter of its own). With neither, exactly one shape is still
950
+ legitimate: **fields authored inline**, where the author's `onSuccess` *is* the
951
+ write. That is
952
+
953
+ - a non-empty `customFields`, or
954
+ - `sections` whose fields are **all** inline runtime `FormField` objects.
955
+
956
+ Anything else — a form naming fields the object schema would have to resolve,
957
+ with no adapter to fetch it and no host seam to hand the values to — **refuses**
958
+ the submit with `DataSource is required for form submission (inline mode not
959
+ configured)`. The error reaches `schema.onError` and is rethrown.
960
+
961
+ This is uniform across all six renderers (`simple`, `tabbed`, `wizard`, `split`,
962
+ `drawer`, `modal`). It used to be uniform in the other direction: the five
963
+ variant renderers answered an adapter-less submit by calling `onSuccess` and
964
+ returning, which reported a save that never happened and, through
965
+ `MasterDetailForm`, produced a success toast over a form it then cleared
966
+ (objectui#6300). A declared `submitHandler` is consulted first, so a host that
967
+ said it owns the write is never bypassed for want of an adapter it never needed.
968
+
969
+ ## What an edit save writes
970
+
971
+ An `object-form` in `mode: 'edit'` with a `recordId` reads the record with
972
+ `dataSource.findOne`, and its save writes **only the fields that differ from
973
+ that read** (objectui#10156). Every layout does this: the simple form and the
974
+ `tabbed`, `wizard`, `split`, `modal` and `drawer` variants (objectui#10563). So
975
+ does a simple form whose mobile `stepper` option shows it one step at a time
976
+ through the wizard, and the parent operation of a master-detail form, whose
977
+ header is a simple or a `tabbed` form. A master-detail child row already worked
978
+ this way (objectui#10108), and all of them use the same comparison.
979
+
980
+ A master-detail edit reads each collection's lines in their stored order:
981
+ ascending by the child's sort field (the one its line grid stamps with each
982
+ line's position), the record id breaking ties (objectui#12286). The grid
983
+ renumbers that field only when a line is added, removed, duplicated or moved; a
984
+ cell edit leaves every line's position as it was read. So editing one line
985
+ sends one update, and moving a line sends only the lines whose position changed.
986
+
987
+ Before that comparison, every layout strips what a form never writes: the
988
+ server-owned columns (record identity, audit provenance, ownership and tenancy
989
+ — the roster in `sanitize.ts`, plus any field the object marks `system`),
990
+ computed, formula and read-only columns, keys the object does not declare, and
991
+ every field the caller's field-level security refuses. A create is stripped the
992
+ same way. Every layout also renders a field the caller may read but not edit as
993
+ a disabled input, so nothing is typed into a field the save would leave out.
994
+ Every layout likewise disables every field of a managed object (ADR-0092 D4)
995
+ whose `userActions` do not open the form's mode, `create` on a create form and
996
+ `edit` on an edit form, and of any object whose `create` or `update` the
997
+ server's effective API operations deny (objectui#10612). While that lock holds,
998
+ every layout shows one notice above the fields that names the object, by its
999
+ translated label, and the missing permission (`form.noPermissionToCreate` or
1000
+ `form.noPermissionToEdit`, objectui#11000); a field locked on its own shows none.
1001
+ The submit button of the simple, `tabbed`, `split`, `modal` and `drawer` layouts
1002
+ stays on screen and the lock does not disable it; the server's write guard is
1003
+ what refuses the save.
1004
+ A `wizard` disables Next and its final submit button, and its step indicator does
1005
+ not jump forward, while Cancel and Back stay usable.
1006
+
1007
+ **The record's owner is the one exception, and only on an edit
1008
+ (objectui#12205).** `owner_id` is in the server-owned roster, so a form never
1009
+ echoes it back: the platform refuses an echoed owner from every role without
1010
+ the transfer grant (objectui#10108). The spec declares it `system` but not
1011
+ `readonly`, because ownership is transferable from a form. So an edit save adds
1012
+ it back when, and only when, its value differs from the record the form read: a
1013
+ new owner is sent as its id, and a cleared one as `null`. Field-level security,
1014
+ a `readonly` declaration on the object and the unknown-key strip still keep it
1015
+ off the wire. A create never sends it, and neither does a form with no record
1016
+ read of its own (listed below). The server decides whether the caller may make
1017
+ the change, and the form shows a refusal as its error. The business-unit and
1018
+ tenant columns are not reassignable from a form and are never sent. The
1019
+ master-detail form's batch still strips the owner from the header operation, so
1020
+ an owner changed in a master-detail header is not saved.
1021
+
1022
+ The comparison sends every field it cannot prove unchanged, because a field
1023
+ wrongly judged unchanged would lose the user's edit while the server still
1024
+ answers 200:
1025
+
1026
+ - `null` and `undefined` count as the same value. `''` is not a blank here, so
1027
+ `null` and `''` are different.
1028
+ - A number and a numeric string are different (`5` and `'5'`).
1029
+ - A lookup id and the expanded lookup object are different.
1030
+ - A `Date` and a date string are different. So are two date strings written in
1031
+ different formats.
1032
+ - Objects and arrays are equal only when they serialize identically.
1033
+ Reordering their keys or elements makes them different.
1034
+
1035
+ A field the form changed by itself after the read is a change, so it is sent.
1036
+ That covers a cascade clear or a value cleared when its field was hidden.
1037
+
1038
+ Some saves still send the full payload:
1039
+
1040
+ - **A save with nothing changed.** It sends every field, as it always has.
1041
+ That keeps it a real request, with the same concurrency guard and a real
1042
+ server record for `onSuccess`.
1043
+ - **A form with no record read of its own.** This includes a create, a record
1044
+ given as `initialData` or through inline `customFields`, and a save made
1045
+ while a new record is still loading. Each of these sends every field.
1046
+
1047
+ After a successful save, the form treats the fields it just wrote as saved. A
1048
+ form that stays open therefore compares its next save with the record as it
1049
+ stands now, not as it was first read.
1050
+
1051
+ The concurrency guard is unchanged by this rule. The update carries
1052
+ `ifMatch` = the `updated_at` the form read. Once a form that stays open has
1053
+ saved the record, its next save carries the `updated_at` that save returned
1054
+ instead, so the form is not refused over its own earlier save. A `409` still
1055
+ offers **Keep editing** or **Overwrite**. **Overwrite** now resends only the
1056
+ changed fields, so it no longer rewrites fields this user never touched.
1057
+
1058
+ ⚠️ A host `submitHandler` gets the same payload in edit mode. Normally that is
1059
+ the changed fields; after a save with nothing changed, it is the full payload.
1060
+ A host that needs the whole record must read it itself.
1061
+
629
1062
  ## Integration with Data Sources
630
1063
 
631
1064
  **The adapter is not a schema key.** A schema is a serialisable document; a live
@@ -637,9 +1070,13 @@ registrations.
637
1070
 
638
1071
  ### The metadata route — `object-form`
639
1072
 
640
- This is the route that actually reads and writes a record. `ObjectFormSchema`
641
- names its object with **`objectName`** and its intent with **`mode`**, and both
642
- are **required** (`packages/types/src/objectql.ts`); the adapter arrives on the
1073
+ This is the route that actually reads and writes a record. An `object-form`
1074
+ node names its object with **`objectName`** and its intent with **`mode`**, both
1075
+ in its `properties` bag, whose members are `@objectstack/spec`'s
1076
+ `ComponentPropsMap['object-form']` row; `objectui validate` refuses a prop
1077
+ written flat on the node by name. `SchemaRenderer` hoists the bag onto the node,
1078
+ and `ObjectFormSchema` (`packages/types/src/objectql.ts`) is the node as
1079
+ `ObjectForm` then reads it, with both keys required. The adapter arrives on the
643
1080
  context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
644
1081
  `ObjectForm` its `dataSource` prop.
645
1082
 
@@ -647,19 +1084,21 @@ context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
647
1084
  import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
648
1085
  import { createObjectStackAdapter } from '@object-ui/data-objectstack';
649
1086
  import '@object-ui/plugin-form';
650
- import type { ObjectFormSchema } from '@object-ui/types';
1087
+ import type { ObjectFormBlockNode } from '@object-ui/types';
651
1088
 
652
1089
  const dataSource = createObjectStackAdapter({
653
1090
  baseUrl: 'https://api.example.com',
654
1091
  token: 'your-auth-token',
655
1092
  });
656
1093
 
657
- const schema: ObjectFormSchema = {
1094
+ const schema: ObjectFormBlockNode = {
658
1095
  type: 'object-form',
659
- objectName: 'users',
660
- mode: 'create',
661
- fields: ['name', 'email'],
662
- submitText: 'Create user',
1096
+ properties: {
1097
+ objectName: 'users',
1098
+ mode: 'create',
1099
+ fields: ['name', 'email'],
1100
+ submitText: 'Create user',
1101
+ },
663
1102
  };
664
1103
 
665
1104
  export const App = () => (
@@ -671,8 +1110,8 @@ export const App = () => (
671
1110
 
672
1111
  The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'`
673
1112
  or `'view'`, add the `recordId` of the record being opened. Note that this
674
- `mode` vocabulary is `'create' | 'edit' | 'view'` — the basic form's `mode` is a
675
- different key with a different vocabulary (`'edit' | 'read' | 'disabled'`, see
1113
+ `mode` vocabulary is `'create' | 'edit' | 'view'` and it lives on `object-form`
1114
+ only — the basic `form` node has no `mode` (retired by objectui#10286, see
676
1115
  [Schema API](#schema-api)).
677
1116
 
678
1117
  ### The TypeScript route — basic `form`
@@ -680,10 +1119,12 @@ different key with a different vocabulary (`'edit' | 'read' | 'disabled'`, see
680
1119
  A bare `form` never fetches or saves by itself: it has no object name and no
681
1120
  query, so there is nothing for it to call an adapter *with*. It collects values
682
1121
  and hands them to your `onSubmit`, which the renderer awaits
683
- (`packages/components/src/renderers/form/form.tsx:1428`). Any persistence is
1122
+ (`await onSubmitProp(formData)` in the submit handler of
1123
+ `packages/components/src/renderers/form/form.tsx`). Any persistence is
684
1124
  whatever that function does:
685
1125
 
686
1126
  ```typescript
1127
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
687
1128
  import type { FormSchema } from '@object-ui/types';
688
1129
 
689
1130
  const dataSource = createObjectStackAdapter({
@@ -710,9 +1151,9 @@ a JSON metadata document cannot carry one. Metadata pages take the `object-form`
710
1151
  route above.
711
1152
 
712
1153
  What the adapter on the context still does for a bare `form` is supply the
713
- **field widgets**: the renderer reads it at `form.tsx:1004`
1154
+ **field widgets**: the renderer reads it
714
1155
  (`const contextDataSource = schemaCtx?.dataSource ?? null`) and passes it down
715
- per field at `:2061`, which is how a lookup or cascading select loads its
1156
+ per field (`dataSource: contextDataSource`), which is how a lookup or cascading select loads its
716
1157
  options. Nothing else about the form is wired to it.
717
1158
 
718
1159
  ### Keys that look like wiring but are not
@@ -722,12 +1163,13 @@ nothing:
722
1163
 
723
1164
  | Written on a form schema | What actually happens |
724
1165
  |---|---|
725
- | `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 |
726
- | `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all. The key exists elsewhere in the protocol — on **`CRUDSchema`** (`packages/types/src/crud.ts`, `type: 'crud'`), where `CRUDBuilder` in `@object-ui/core` sets it — but no form renderer reads it under any spelling. On a form it names nothing |
1166
+ | `dataSource` | **Discarded.** The basic form drops it in both directions — the built-in branches hand their control an allowlist (`toFormControlDomProps`) that does not name it, and the renderer's own props destructure takes it off the node — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one |
1167
+ | `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 |
727
1168
 
728
- Both survive compilation for the reason [Schema API](#schema-api) gives: `FormSchema`
729
- and `ObjectFormSchema` extend `BaseSchema`, which declares `[key: string]: any`, so an
730
- invented key is not a type error — it is simply never read. That is also why the
1169
+ Both survived compilation while `FormSchema` and `ObjectFormSchema` extended a
1170
+ `BaseSchema` that declared `[key: string]: any`: an invented key was not a type
1171
+ error, it was simply never read. objectui#8347 removed that signature, so both are
1172
+ compile errors in an annotated literal now. That is also why the
731
1173
  older version of this section looked like it worked: its `onSubmit` genuinely ran
732
1174
  and genuinely saved, but through the adapter its closure captured. The `dataSource`
733
1175
  and `resource` keys sitting beside it in the same object were inert. Delete them and
@@ -737,11 +1179,11 @@ anything.
737
1179
  > A top-level `dataSource` **does** mean something on a schema node, but it is not
738
1180
  > an adapter: it is the spec's element **binding** (`PageComponentSchema.dataSource`,
739
1181
  > objectstack#6953) — a descriptor such as `{ object: 'users' }` — resolved by
740
- > `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts:139`).
1182
+ > `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts`).
741
1183
  > `object-form` passes through that gate, and honours the binding's `object` key
742
1184
  > only. Handing that slot a live adapter is rejected on purpose: the predicate
743
1185
  > refuses any value carrying a `find` method
744
- > (`packages/core/src/data-scope/element-data-source.ts:131`), so an adapter written
1186
+ > (`isElementDataSourceConfig` in `packages/core/src/data-scope/element-data-source.ts`), so an adapter written
745
1187
  > there is ignored rather than mistaken for a binding. Pass adapters through the
746
1188
  > provider above.
747
1189