@object-ui/plugin-form 17.6.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 (70) hide show
  1. package/CHANGELOG.md +4187 -0
  2. package/README.md +376 -44
  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.map +1 -1
  14. package/dist/SplitForm.d.ts +37 -2
  15. package/dist/SplitForm.d.ts.map +1 -1
  16. package/dist/TabbedForm.d.ts +65 -3
  17. package/dist/TabbedForm.d.ts.map +1 -1
  18. package/dist/WizardForm.d.ts +143 -38
  19. package/dist/WizardForm.d.ts.map +1 -1
  20. package/dist/autoLayout.d.ts +12 -3
  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 +32 -8
  27. package/dist/deriveMasterDetail.d.ts.map +1 -1
  28. package/dist/fieldGroups.d.ts +176 -0
  29. package/dist/fieldGroups.d.ts.map +1 -1
  30. package/dist/fieldWriteGate.d.ts +116 -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 +124 -26
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +3435 -2093
  39. package/dist/index.umd.cjs +1 -1
  40. package/dist/initialRecord.d.ts +72 -0
  41. package/dist/initialRecord.d.ts.map +1 -0
  42. package/dist/loadFailure.d.ts +28 -0
  43. package/dist/loadFailure.d.ts.map +1 -0
  44. package/dist/masterDetailTx.d.ts.map +1 -1
  45. package/dist/occSave.d.ts +6 -1
  46. package/dist/occSave.d.ts.map +1 -1
  47. package/dist/recordInvalidation.d.ts +33 -0
  48. package/dist/recordInvalidation.d.ts.map +1 -0
  49. package/dist/recordReadGate.d.ts +34 -0
  50. package/dist/recordReadGate.d.ts.map +1 -0
  51. package/dist/sanitize.d.ts +187 -5
  52. package/dist/sanitize.d.ts.map +1 -1
  53. package/dist/schemaDefaults.d.ts +27 -6
  54. package/dist/schemaDefaults.d.ts.map +1 -1
  55. package/dist/sectionFields.d.ts +56 -2
  56. package/dist/sectionFields.d.ts.map +1 -1
  57. package/dist/sectionGroups.d.ts +62 -0
  58. package/dist/sectionGroups.d.ts.map +1 -0
  59. package/dist/sectionPredicateDiagnostic.d.ts +38 -0
  60. package/dist/sectionPredicateDiagnostic.d.ts.map +1 -0
  61. package/dist/submitTarget.d.ts +119 -0
  62. package/dist/submitTarget.d.ts.map +1 -0
  63. package/dist/successBehavior.d.ts +44 -25
  64. package/dist/successBehavior.d.ts.map +1 -1
  65. package/dist/thankYouRedirectCountdown.d.ts +1 -1
  66. package/dist/uploadGate.d.ts +35 -0
  67. package/dist/uploadGate.d.ts.map +1 -0
  68. package/dist/writePayload.d.ts +68 -0
  69. package/dist/writePayload.d.ts.map +1 -0
  70. package/package.json +12 -11
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,18 @@ 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
+ There is no aggregate map among them:
64
71
 
65
72
  ```typescript
66
73
  import {
@@ -90,6 +97,9 @@ import {
90
97
  deriveFormFields,
91
98
  findRelationshipField,
92
99
  resolveInlineMode,
100
+ omitServerResolvedDefaults,
101
+ isRequiredInForm,
102
+ resolveSectionGroupReferences,
93
103
  } from '@object-ui/plugin-form';
94
104
 
95
105
  import type {
@@ -118,6 +128,9 @@ import type {
118
128
  LineItemsPanelSchema,
119
129
  DerivedDetail,
120
130
  InlineMode,
131
+ ChildObjectSchemaLike,
132
+ FieldDefaultsSchemaLike,
133
+ ResolveSectionGroupsOptions,
121
134
  } from '@object-ui/plugin-form';
122
135
  ```
123
136
 
@@ -137,9 +150,10 @@ The renderers this package registers for itself are internal wrappers rather
137
150
  than these exported components. `SchemaRenderer` hands a registered component its
138
151
  `schema` (plus the schema's own props), but never a `dataSource` — that travels
139
152
  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.
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).
143
157
 
144
158
  ## Schema API
145
159
 
@@ -172,7 +186,7 @@ unknown-component placeholder.
172
186
  | `columns` | `number` | grid width (1–4) |
173
187
  | `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run |
174
188
  | `resetOnSubmit` / `disabled` | `boolean` | |
175
- | `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode |
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 |
176
190
  | `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) |
177
191
  | `previousValues` | `Record<string, any>` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere |
178
192
  | `fieldContainerClass` | `string` | class for the field grid inside the `<form>` |
@@ -210,13 +224,13 @@ only **required** one:
210
224
  | `hidden` | `boolean` | field is not rendered at all |
211
225
  | `options` | `SelectOption[] \| RadioOption[]` | for `select` / radio fields |
212
226
  | `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below |
213
- | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in, custom }` matcher |
227
+ | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in }` matcher (`custom` is retired, objectui#7759: nothing ran it) |
214
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 |
215
229
  | `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) |
216
230
  | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries |
217
231
  | `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) |
218
232
  | `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` |
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` |
220
234
 
221
235
  `FormField` also declares `[key: string]: any`, so an invented key type-checks
222
236
  here too. Two that a reader might expect, and that are **not** declared:
@@ -224,7 +238,7 @@ here too. Two that a reader might expect, and that are **not** declared:
224
238
  | Not a `FormField` key | Write this instead |
225
239
  |---|---|
226
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) |
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.) |
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).) |
228
242
 
229
243
  There is no `ValidationRule` type in this repo, under any spelling.
230
244
 
@@ -289,6 +303,8 @@ field.
289
303
  A field may declare both, and it is coherent authoring — storage-level required,
290
304
  with the value guaranteed by the producer:
291
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
+
292
308
  ```ts
293
309
  remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
294
310
  ```
@@ -305,6 +321,8 @@ predicate says "required in this state", but `NOW()` / `current_user` resolve
305
321
  at insert regardless of state, so the producer's guarantee covers the
306
322
  conditional claim exactly as it covers the unconditional one:
307
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
+
308
326
  ```ts
309
327
  remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaultValue: 'NOW()' }),
310
328
  ```
@@ -322,6 +340,34 @@ since one verdict drives all three — in that mode the user genuinely is not
322
340
  required to provide the value. Showing what the server *will* supply, as a
323
341
  non-authoritative preview, is a separate follow-up.
324
342
 
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
+
325
371
  Every consumer reads one predicate, `isRuntimeDefault` in `@object-ui/core`
326
372
  (re-exported for this package's own use by `src/schemaDefaults.ts`, not from the
327
373
  package entry listed above) — which is what keeps a form from seeding a field it
@@ -332,6 +378,60 @@ evaluator that resolves it — `resolveFieldRuleState`, reading
332
378
  `isServerOwnedValue`. Display and validation share that single verdict, so a
333
379
  field can never lose its asterisk while still refusing the submit.
334
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.
434
+
335
435
  ### Column width of a sectioned form
336
436
 
337
437
  A sectioned form renders as ONE grid, and two keys decide its shape:
@@ -352,6 +452,42 @@ The grid is applied to the field container **inside** the form, never wrapped
352
452
  around the `<form>` (which would put the whole form in cell 1 and leave the other
353
453
  columns empty — #2128).
354
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
+
355
491
  ### Tabbed field layout (`fieldTabs`)
356
492
 
357
493
  A sectioned form is **one** form. Instead of rendering a form per section — which
@@ -359,6 +495,8 @@ strands every section but the first outside the submit, and (in tabs) lets the
359
495
  inactive panel unmount with its values — declare tabs on the single form and let
360
496
  the renderer distribute the fields:
361
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
+
362
500
  ```typescript
363
501
  {
364
502
  type: 'form',
@@ -366,6 +504,7 @@ the renderer distribute the fields:
366
504
  fieldTabs: [
367
505
  { key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
368
506
  { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
507
+ { key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'record.status == "won"' },
369
508
  ],
370
509
  defaultFieldTab?: 'basics', // defaults to the first tab
371
510
  fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
@@ -381,7 +520,89 @@ the renderer distribute the fields:
381
520
  - Fields no tab claims render above the tab strip rather than disappearing.
382
521
  - Needs at least two tabs, and is ignored when the form uses `children`.
383
522
 
384
- `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.
385
606
 
386
607
  ### Wizard steps and `allowSkip`
387
608
 
@@ -409,6 +630,8 @@ The same rule for side-by-side panels: the `<form>` wraps the whole panel group
409
630
  and each pane holds only fields, so one react-hook-form instance spans the
410
631
  divider.
411
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
+
412
635
  ```typescript
413
636
  {
414
637
  type: 'form',
@@ -503,27 +726,31 @@ steps are its `sections` — one step per section. There is no
503
726
  unknown-component placeholder and the fields inside `steps` are never read.
504
727
 
505
728
  ```typescript
506
- import type { ObjectFormSchema } from '@object-ui/types';
729
+ import type { ObjectFormBlockNode } from '@object-ui/types';
507
730
 
508
- const schema: ObjectFormSchema = {
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 = {
509
734
  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
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
+ }
527
754
  };
528
755
  ```
529
756
 
@@ -532,7 +759,10 @@ A section's `fields` accepts three shapes — a field **name**, a spec
532
759
  `FormField` object. The inline shape is what lets a wizard run with no data
533
760
  source at all, which is the closest equivalent of the old snippet; note that
534
761
  `WizardForm` reports a data-source-less submit through `onSuccess` (there is no
535
- `onSubmit` on this schema):
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)):
536
766
 
537
767
  ```tsx
538
768
  import { WizardForm } from '@object-ui/plugin-form';
@@ -626,6 +856,101 @@ Each rule appears **once**, under its own name — an object, not a list. A seco
626
856
  `minLength` cannot exist, which is the point: the shape the renderer hands
627
857
  react-hook-form is one rule per kind.
628
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
+
629
954
  ## Integration with Data Sources
630
955
 
631
956
  **The adapter is not a schema key.** A schema is a serialisable document; a live
@@ -637,9 +962,13 @@ registrations.
637
962
 
638
963
  ### The metadata route — `object-form`
639
964
 
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
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
643
972
  context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
644
973
  `ObjectForm` its `dataSource` prop.
645
974
 
@@ -647,19 +976,21 @@ context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
647
976
  import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
648
977
  import { createObjectStackAdapter } from '@object-ui/data-objectstack';
649
978
  import '@object-ui/plugin-form';
650
- import type { ObjectFormSchema } from '@object-ui/types';
979
+ import type { ObjectFormBlockNode } from '@object-ui/types';
651
980
 
652
981
  const dataSource = createObjectStackAdapter({
653
982
  baseUrl: 'https://api.example.com',
654
983
  token: 'your-auth-token',
655
984
  });
656
985
 
657
- const schema: ObjectFormSchema = {
986
+ const schema: ObjectFormBlockNode = {
658
987
  type: 'object-form',
659
- objectName: 'users',
660
- mode: 'create',
661
- fields: ['name', 'email'],
662
- submitText: 'Create user',
988
+ properties: {
989
+ objectName: 'users',
990
+ mode: 'create',
991
+ fields: ['name', 'email'],
992
+ submitText: 'Create user',
993
+ },
663
994
  };
664
995
 
665
996
  export const App = () => (
@@ -671,8 +1002,8 @@ export const App = () => (
671
1002
 
672
1003
  The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'`
673
1004
  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
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
676
1007
  [Schema API](#schema-api)).
677
1008
 
678
1009
  ### The TypeScript route — basic `form`
@@ -684,6 +1015,7 @@ and hands them to your `onSubmit`, which the renderer awaits
684
1015
  whatever that function does:
685
1016
 
686
1017
  ```typescript
1018
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
687
1019
  import type { FormSchema } from '@object-ui/types';
688
1020
 
689
1021
  const dataSource = createObjectStackAdapter({
@@ -723,7 +1055,7 @@ nothing:
723
1055
  | Written on a form schema | What actually happens |
724
1056
  |---|---|
725
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 |
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 |
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 |
727
1059
 
728
1060
  Both survive compilation for the reason [Schema API](#schema-api) gives: `FormSchema`
729
1061
  and `ObjectFormSchema` extend `BaseSchema`, which declares `[key: string]: any`, so an