@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.
- package/CHANGELOG.md +4187 -0
- package/README.md +376 -44
- package/dist/DrawerForm.d.ts +30 -2
- package/dist/DrawerForm.d.ts.map +1 -1
- package/dist/EmbeddableForm.d.ts.map +1 -1
- package/dist/FormAnalytics.d.ts.map +1 -1
- package/dist/LineItemsPanel.d.ts +6 -3
- package/dist/LineItemsPanel.d.ts.map +1 -1
- package/dist/MasterDetailForm.d.ts +31 -10
- package/dist/MasterDetailForm.d.ts.map +1 -1
- package/dist/ModalForm.d.ts +48 -4
- package/dist/ModalForm.d.ts.map +1 -1
- package/dist/ObjectForm.d.ts.map +1 -1
- package/dist/SplitForm.d.ts +37 -2
- package/dist/SplitForm.d.ts.map +1 -1
- package/dist/TabbedForm.d.ts +65 -3
- package/dist/TabbedForm.d.ts.map +1 -1
- package/dist/WizardForm.d.ts +143 -38
- package/dist/WizardForm.d.ts.map +1 -1
- package/dist/autoLayout.d.ts +12 -3
- package/dist/autoLayout.d.ts.map +1 -1
- package/dist/closedAffordanceNotice.d.ts +14 -0
- package/dist/closedAffordanceNotice.d.ts.map +1 -0
- package/dist/customFieldsMerge.d.ts +29 -0
- package/dist/customFieldsMerge.d.ts.map +1 -0
- package/dist/deriveMasterDetail.d.ts +32 -8
- package/dist/deriveMasterDetail.d.ts.map +1 -1
- package/dist/fieldGroups.d.ts +176 -0
- package/dist/fieldGroups.d.ts.map +1 -1
- package/dist/fieldWriteGate.d.ts +116 -0
- package/dist/fieldWriteGate.d.ts.map +1 -0
- package/dist/flatFields.d.ts +11 -2
- package/dist/flatFields.d.ts.map +1 -1
- package/dist/formChrome.d.ts +55 -0
- package/dist/formChrome.d.ts.map +1 -0
- package/dist/index.d.ts +124 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3435 -2093
- package/dist/index.umd.cjs +1 -1
- package/dist/initialRecord.d.ts +72 -0
- package/dist/initialRecord.d.ts.map +1 -0
- package/dist/loadFailure.d.ts +28 -0
- package/dist/loadFailure.d.ts.map +1 -0
- package/dist/masterDetailTx.d.ts.map +1 -1
- package/dist/occSave.d.ts +6 -1
- package/dist/occSave.d.ts.map +1 -1
- package/dist/recordInvalidation.d.ts +33 -0
- package/dist/recordInvalidation.d.ts.map +1 -0
- package/dist/recordReadGate.d.ts +34 -0
- package/dist/recordReadGate.d.ts.map +1 -0
- package/dist/sanitize.d.ts +187 -5
- package/dist/sanitize.d.ts.map +1 -1
- package/dist/schemaDefaults.d.ts +27 -6
- package/dist/schemaDefaults.d.ts.map +1 -1
- package/dist/sectionFields.d.ts +56 -2
- package/dist/sectionFields.d.ts.map +1 -1
- package/dist/sectionGroups.d.ts +62 -0
- package/dist/sectionGroups.d.ts.map +1 -0
- package/dist/sectionPredicateDiagnostic.d.ts +38 -0
- package/dist/sectionPredicateDiagnostic.d.ts.map +1 -0
- package/dist/submitTarget.d.ts +119 -0
- package/dist/submitTarget.d.ts.map +1 -0
- package/dist/successBehavior.d.ts +44 -25
- package/dist/successBehavior.d.ts.map +1 -1
- package/dist/thankYouRedirectCountdown.d.ts +1 -1
- package/dist/uploadGate.d.ts +35 -0
- package/dist/uploadGate.d.ts.map +1 -0
- package/dist/writePayload.d.ts +68 -0
- package/dist/writePayload.d.ts.map +1 -0
- 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
|
|
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,
|
|
63
|
-
layout helpers
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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` |
|
|
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
|
|
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`, `
|
|
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*
|
|
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 {
|
|
729
|
+
import type { ObjectFormBlockNode } from '@object-ui/types';
|
|
507
730
|
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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. `
|
|
641
|
-
names its object with **`objectName`** and its intent with **`mode`**,
|
|
642
|
-
|
|
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 {
|
|
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:
|
|
986
|
+
const schema: ObjectFormBlockNode = {
|
|
658
987
|
type: 'object-form',
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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'`
|
|
675
|
-
|
|
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.
|
|
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
|