@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.
- package/CHANGELOG.md +4991 -0
- package/README.md +522 -80
- package/dist/DrawerForm.d.ts +42 -3
- 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 +79 -38
- package/dist/MasterDetailForm.d.ts.map +1 -1
- package/dist/ModalForm.d.ts +60 -5
- package/dist/ModalForm.d.ts.map +1 -1
- package/dist/ObjectForm.d.ts.map +1 -1
- package/dist/SplitForm.d.ts +49 -3
- package/dist/SplitForm.d.ts.map +1 -1
- package/dist/TabbedForm.d.ts +75 -5
- package/dist/TabbedForm.d.ts.map +1 -1
- package/dist/WizardForm.d.ts +145 -38
- package/dist/WizardForm.d.ts.map +1 -1
- package/dist/autoLayout.d.ts +113 -8
- 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 +82 -29
- package/dist/deriveMasterDetail.d.ts.map +1 -1
- package/dist/fieldGroups.d.ts +189 -0
- package/dist/fieldGroups.d.ts.map +1 -1
- package/dist/fieldWriteGate.d.ts +136 -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 +149 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3984 -2332
- package/dist/index.umd.cjs +1 -1
- package/dist/initialFieldFocus.d.ts +23 -0
- package/dist/initialFieldFocus.d.ts.map +1 -0
- 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 +272 -5
- package/dist/sanitize.d.ts.map +1 -1
- package/dist/schemaDefaults.d.ts +104 -9
- package/dist/schemaDefaults.d.ts.map +1 -1
- package/dist/sectionFields.d.ts +67 -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 +136 -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 +87 -0
- package/dist/writePayload.d.ts.map +1 -0
- 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
|
|
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,
|
|
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
|
+
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
|
|
141
|
-
|
|
142
|
-
|
|
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` |
|
|
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
|
-
|
|
187
|
-
(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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`)
|
|
195
|
-
|
|
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
|
|
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 `
|
|
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
|
-
| `
|
|
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`
|
|
222
|
-
|
|
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:
|
|
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
|
|
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
|
|
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` |
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
323
|
-
non-authoritative preview, is a separate
|
|
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 {
|
|
815
|
+
import type { ObjectFormBlockNode } from '@object-ui/types';
|
|
507
816
|
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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 —
|
|
574
|
-
`
|
|
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. `
|
|
641
|
-
names its object with **`objectName`** and its intent with **`mode`**,
|
|
642
|
-
|
|
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 {
|
|
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:
|
|
1094
|
+
const schema: ObjectFormBlockNode = {
|
|
658
1095
|
type: 'object-form',
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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'`
|
|
675
|
-
|
|
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
|
-
(`
|
|
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
|
|
1154
|
+
**field widgets**: the renderer reads it
|
|
714
1155
|
(`const contextDataSource = schemaCtx?.dataSource ?? null`) and passes it down
|
|
715
|
-
per field
|
|
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
|
|
726
|
-
| `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all.
|
|
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
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
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
|
|
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
|
|