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