@object-ui/plugin-form 17.3.0 → 17.6.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 +1159 -0
- package/README.md +494 -77
- package/dist/DrawerForm.d.ts.map +1 -1
- package/dist/EmbeddableForm.d.ts.map +1 -1
- package/dist/LineItemsPanel.d.ts +33 -0
- package/dist/LineItemsPanel.d.ts.map +1 -1
- package/dist/MasterDetailForm.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/TabbedForm.d.ts.map +1 -1
- package/dist/WizardForm.d.ts +33 -0
- package/dist/WizardForm.d.ts.map +1 -1
- package/dist/autoLayout.d.ts.map +1 -1
- package/dist/deriveMasterDetail.d.ts +1 -1
- package/dist/deriveMasterDetail.d.ts.map +1 -1
- package/dist/flatFields.d.ts +21 -0
- package/dist/flatFields.d.ts.map +1 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1570 -1112
- package/dist/index.umd.cjs +1 -1
- package/dist/schemaDefaults.d.ts +142 -0
- package/dist/schemaDefaults.d.ts.map +1 -0
- package/dist/sectionFields.d.ts +18 -0
- package/dist/sectionFields.d.ts.map +1 -1
- 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/successBehavior.d.ts +18 -1
- 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/package.json +13 -13
package/README.md
CHANGED
|
@@ -35,54 +35,303 @@ 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 six `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:form-analytics` | `form-analytics` | `FormAnalytics` — submission dashboard |
|
|
51
|
+
| `plugin-form:object-master-detail-form` | `object-master-detail-form` | `MasterDetailForm` — parent + child line items in one submit |
|
|
52
|
+
| `record:line_items` | none | `LineItemsPanel` — child grid bound to the record on the page |
|
|
53
|
+
|
|
54
|
+
`ComponentRegistry.register` also registers a namespaced type under its bare name
|
|
55
|
+
for backwards compatibility, unless the call passes `skipFallback: true`
|
|
56
|
+
(`@object-ui/core`, `src/registry/Registry.ts`). Two calls here do: bare `form`
|
|
57
|
+
stays the basic `@object-ui/components` form, and bare `line_items` is left to
|
|
58
|
+
whoever else claims it.
|
|
59
|
+
|
|
60
|
+
### Public exports
|
|
61
|
+
|
|
62
|
+
The package entry exports these — components, their prop/schema types, and the
|
|
63
|
+
layout helpers. There is no aggregate map among them:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import {
|
|
67
|
+
ObjectForm,
|
|
68
|
+
TabbedForm,
|
|
69
|
+
WizardForm,
|
|
70
|
+
SplitForm,
|
|
71
|
+
DrawerForm,
|
|
72
|
+
ModalForm,
|
|
73
|
+
EmbeddableForm,
|
|
74
|
+
MasterDetailForm,
|
|
75
|
+
LineItemsPanel,
|
|
76
|
+
FormAnalytics,
|
|
77
|
+
FormSectionContainer,
|
|
78
|
+
applyAutoLayout,
|
|
79
|
+
applyAutoColSpan,
|
|
80
|
+
inferColumns,
|
|
81
|
+
inferModalSize,
|
|
82
|
+
isWideFieldType,
|
|
83
|
+
isAutoGeneratedFieldType,
|
|
84
|
+
containerGridColsFor,
|
|
85
|
+
filterCreateModeFields,
|
|
86
|
+
filterSystemFields,
|
|
87
|
+
filterAutoGeneratedFields,
|
|
88
|
+
deriveDetail,
|
|
89
|
+
deriveColumns,
|
|
90
|
+
deriveFormFields,
|
|
91
|
+
findRelationshipField,
|
|
92
|
+
resolveInlineMode,
|
|
93
|
+
} from '@object-ui/plugin-form';
|
|
94
|
+
|
|
95
|
+
import type {
|
|
96
|
+
ObjectFormComponentProps,
|
|
97
|
+
ObjectFormProps, // deprecated alias of ObjectFormComponentProps
|
|
98
|
+
FormSectionContainerProps,
|
|
99
|
+
TabbedFormProps,
|
|
100
|
+
TabbedFormSchema,
|
|
101
|
+
FormSectionConfig,
|
|
102
|
+
WizardFormProps,
|
|
103
|
+
WizardFormSchema,
|
|
104
|
+
SplitFormProps,
|
|
105
|
+
SplitFormSchema,
|
|
106
|
+
DrawerFormProps,
|
|
107
|
+
DrawerFormSchema,
|
|
108
|
+
ModalFormProps,
|
|
109
|
+
ModalFormSchema,
|
|
110
|
+
EmbeddableFormProps,
|
|
111
|
+
EmbeddableFormConfig,
|
|
112
|
+
EmbeddableFormTexts,
|
|
113
|
+
FormAnalyticsProps,
|
|
114
|
+
FormSubmissionMetric,
|
|
115
|
+
MasterDetailFormProps,
|
|
116
|
+
MasterDetailFormSchema,
|
|
117
|
+
MasterDetailDetailConfig,
|
|
118
|
+
LineItemsPanelSchema,
|
|
119
|
+
DerivedDetail,
|
|
120
|
+
InlineMode,
|
|
121
|
+
} from '@object-ui/plugin-form';
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Registering a component under your own key
|
|
125
|
+
|
|
126
|
+
To put one of these components on a schema type of your own, register the
|
|
127
|
+
exported component:
|
|
39
128
|
|
|
40
129
|
```typescript
|
|
41
|
-
import { formComponents } from '@object-ui/plugin-form';
|
|
42
130
|
import { ComponentRegistry } from '@object-ui/core';
|
|
131
|
+
import { ObjectForm } from '@object-ui/plugin-form';
|
|
43
132
|
|
|
44
|
-
|
|
45
|
-
Object.entries(formComponents).forEach(([type, component]) => {
|
|
46
|
-
ComponentRegistry.register(type, component);
|
|
47
|
-
});
|
|
133
|
+
ComponentRegistry.register('my-form', ObjectForm, { namespace: 'my-app' });
|
|
48
134
|
```
|
|
49
135
|
|
|
136
|
+
The renderers this package registers for itself are internal wrappers rather
|
|
137
|
+
than these exported components. `SchemaRenderer` hands a registered component its
|
|
138
|
+
`schema` (plus the schema's own props), but never a `dataSource` — that travels
|
|
139
|
+
on `SchemaRendererContext` — so each wrapper reads it off the context first.
|
|
140
|
+
`ObjectForm` takes `dataSource` as a prop, optional because inline `customFields`
|
|
141
|
+
need no adapter, so a custom-key registration either supplies one or wraps the
|
|
142
|
+
component the same way.
|
|
143
|
+
|
|
50
144
|
## Schema API
|
|
51
145
|
|
|
52
|
-
|
|
146
|
+
Two form schemas reach a renderer, and **both are declared in
|
|
147
|
+
`@object-ui/types`** — this package imports them and declares neither. The
|
|
148
|
+
tables below name the keys and point at the declaration; the declaration is the
|
|
149
|
+
contract. (Restating an interface inside this README is what let this section
|
|
150
|
+
drift away from the code in the first place — objectui#5075.)
|
|
151
|
+
|
|
152
|
+
| Schema | `type` | Declared in | Rendered by |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `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) |
|
|
155
|
+
| `ObjectFormSchema` | `'object-form'` | `packages/types/src/objectql.ts` | `ObjectForm` here, through `plugin-form:object-form` / `object-form` |
|
|
156
|
+
|
|
157
|
+
Both `type` slots are string **literals**. A schema whose `type` names something
|
|
158
|
+
no registration claims does not fall back to a form — it renders the
|
|
159
|
+
unknown-component placeholder.
|
|
160
|
+
|
|
161
|
+
### Form (`type: 'form'`)
|
|
162
|
+
|
|
163
|
+
| Key | Type | Notes |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `type` | `'form'` | literal, not a free string |
|
|
166
|
+
| `fields` | `FormField[]` | **optional** — a form may render `children` instead |
|
|
167
|
+
| `defaultValues` | `Record<string, any>` | form-level initial values. There is no field-level `defaultValue` |
|
|
168
|
+
| `submitLabel` / `cancelLabel` | `string` | button text |
|
|
169
|
+
| `showCancel` / `showActions` | `boolean` | action-row composition |
|
|
170
|
+
| `mobileStickyActions` | `boolean` | pin the action row on small viewports |
|
|
171
|
+
| `layout` | `'vertical' \| 'horizontal'` | label placement |
|
|
172
|
+
| `columns` | `number` | grid width (1–4) |
|
|
173
|
+
| `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run |
|
|
174
|
+
| `resetOnSubmit` / `disabled` | `boolean` | |
|
|
175
|
+
| `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode |
|
|
176
|
+
| `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) |
|
|
177
|
+
| `previousValues` | `Record<string, any>` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere |
|
|
178
|
+
| `fieldContainerClass` | `string` | class for the field grid inside the `<form>` |
|
|
179
|
+
| `fieldTabs` / `defaultFieldTab` / `fieldTabsPosition` | see [Tabbed field layout](#tabbed-field-layout-fieldtabs) | |
|
|
180
|
+
| `fieldPanes` / `fieldPanesOrientation` / `fieldPanesResizable` | see [Split field layout](#split-field-layout-fieldpanes) | |
|
|
181
|
+
| `actions` | `SchemaNode[]` | extra nodes in the action row |
|
|
182
|
+
| `children` | `SchemaNode \| SchemaNode[]` | custom body instead of `fields` |
|
|
183
|
+
| `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
|
+
| `className`, `id`, `hidden`, … | — | inherited from `BaseSchema` |
|
|
185
|
+
|
|
186
|
+
⚠️ `FormSchema` extends `BaseSchema`, which declares `[key: string]: any`
|
|
187
|
+
(`packages/types/src/base.ts`), so an invented or misspelled key on a form schema
|
|
188
|
+
is **not** a compile error — it is simply never read. That is why every example
|
|
189
|
+
below is annotated with its real type *and* checked against these key tables:
|
|
190
|
+
an un-annotated `const schema = { … }` type-checks whatever is written in it.
|
|
53
191
|
|
|
54
|
-
|
|
192
|
+
### Form Field
|
|
55
193
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
194
|
+
`FormField` (`packages/types/src/form.ts`) declares 23 keys, and `name` is the
|
|
195
|
+
only **required** one:
|
|
196
|
+
|
|
197
|
+
| Key | Type | What it does |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `name` | `string` | **required** — the submit key |
|
|
200
|
+
| `id` | `string` | stable render key; falls back to `name` |
|
|
201
|
+
| `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 |
|
|
202
|
+
| `description` | `string` | help text under the control |
|
|
203
|
+
| `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) |
|
|
204
|
+
| `inputType` | `string` | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, … |
|
|
205
|
+
| `widget` | `string` | widget override; wins over `type` (spec `FormField.widget`) |
|
|
206
|
+
| `placeholder` | `string` | |
|
|
207
|
+
| `required` | `boolean` | the presence rule. `validation.required` does **not** make a field required — see below |
|
|
208
|
+
| `disabled` | `boolean` | not interactive, muted |
|
|
209
|
+
| `readonly` | `boolean` | shown plainly, not editable — deliberately distinct from `disabled` |
|
|
210
|
+
| `hidden` | `boolean` | field is not rendered at all |
|
|
211
|
+
| `options` | `SelectOption[] \| RadioOption[]` | for `select` / radio fields |
|
|
212
|
+
| `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below |
|
|
213
|
+
| `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in, custom }` matcher |
|
|
214
|
+
| `visibleWhen` / `readonlyWhen` / `requiredWhen` | `string \| { dialect?, source }` | CEL predicates over the live record, evaluated by `@objectstack/formula` — the same engine and dialect the server uses. Fail open |
|
|
215
|
+
| `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) |
|
|
216
|
+
| `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries |
|
|
217
|
+
| `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) |
|
|
218
|
+
| `colSpan` | `number` | legacy column span (1–4), clamped to the current column count |
|
|
219
|
+
| `field` | `Record<string, any>` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` |
|
|
220
|
+
|
|
221
|
+
`FormField` also declares `[key: string]: any`, so an invented key type-checks
|
|
222
|
+
here too. Two that a reader might expect, and that are **not** declared:
|
|
223
|
+
|
|
224
|
+
| Not a `FormField` key | Write this instead |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `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
|
+
|
|
229
|
+
There is no `ValidationRule` type in this repo, under any spelling.
|
|
230
|
+
|
|
231
|
+
#### `validation` is an object keyed by rule name
|
|
232
|
+
|
|
233
|
+
`FieldValidationRules` (`packages/types/src/form.ts`) is **not** an array of
|
|
234
|
+
`{ type, value, message }` entries:
|
|
235
|
+
|
|
236
|
+
| Rule | Type | Notes |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** |
|
|
239
|
+
| `minLength` / `maxLength` | `{ value: number; message: string }` | `message` is not optional when you author the rule by hand |
|
|
240
|
+
| `min` / `max` | `{ value: number; message: string }` | numeric range |
|
|
241
|
+
| `pattern` | `{ value: string \| RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and it is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one |
|
|
242
|
+
| `validate` | `(value) => boolean \| string \| Promise<boolean \| string>` | custom check; TypeScript-authored schemas only |
|
|
243
|
+
|
|
244
|
+
There is no `email` rule name — an email check is a `pattern`, which is exactly
|
|
245
|
+
what `buildValidationRules` emits for an object field of type `email`.
|
|
246
|
+
|
|
247
|
+
**Why the array spelling fails silently.** The only reader of this key is the
|
|
248
|
+
basic form renderer, which spreads it into the rule object handed to
|
|
249
|
+
react-hook-form — `const rules: any = { ...validation }`
|
|
250
|
+
(`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array**
|
|
251
|
+
into an object literal produces numeric keys (`{ '0': …, '1': … }`), which
|
|
252
|
+
react-hook-form does not recognise: every rule is dropped, nothing throws, and
|
|
253
|
+
the form looks validated while validating nothing.
|
|
254
|
+
|
|
255
|
+
### What a create form opens with
|
|
256
|
+
|
|
257
|
+
A create form has no persisted record, so its opening values come from the
|
|
258
|
+
object schema's declared field `defaultValue`s. Every object-form container
|
|
259
|
+
(`ObjectForm`, `ModalForm`, `DrawerForm`, `TabbedForm`, `SplitForm`,
|
|
260
|
+
`WizardForm`) resolves them the same way, through `schemaDefaults`:
|
|
261
|
+
|
|
262
|
+
| Declared | Create form opens with | Why |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `defaultValue: 'draft'` (any static literal) | `draft`, preselected and submittable | the value is known; making the user pick it is busywork, and on a status-like field every wrong option is one click away |
|
|
265
|
+
| `defaultValue: 'NOW()'` / `'current_user'` (a runtime token) | empty | the token is an *instruction*, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it |
|
|
266
|
+
| `defaultValue: cel\`today()\`` (an Expression envelope) | empty | same reason: the server evaluates it per insert |
|
|
267
|
+
| an option's `default: true` | empty | see below |
|
|
268
|
+
| nothing | empty | no default is invented |
|
|
269
|
+
|
|
270
|
+
`initialData` / `initialValues` passed by the caller always outrank a schema
|
|
271
|
+
default — a lookup prefill or a "duplicate this record" seed is the more
|
|
272
|
+
specific instruction.
|
|
273
|
+
|
|
274
|
+
Only the **field-level** `defaultValue` is read, never a select option's
|
|
275
|
+
`default: true`, even though `@objectstack/spec`'s `SelectOptionSchema` declares
|
|
276
|
+
that key. The server's insert path resolves `defaultValue` and nothing else, so
|
|
277
|
+
a form that also seeded from option-level `default` would preselect values the
|
|
278
|
+
server would never have applied on its own — a renderer-side second default
|
|
279
|
+
contract (AGENTS.md #0.1). If option-level `default` is meant to mean "the
|
|
280
|
+
initial value", that belongs at the producer.
|
|
281
|
+
|
|
282
|
+
**Edit forms are never seeded.** An edit form shows the row as the server holds
|
|
283
|
+
it; folding a default in over a column the record leaves unset would arm a
|
|
284
|
+
silent write of a value the user never chose, on the next save of any other
|
|
285
|
+
field.
|
|
286
|
+
|
|
287
|
+
#### `required` (or `requiredWhen`) + a runtime default
|
|
288
|
+
|
|
289
|
+
A field may declare both, and it is coherent authoring — storage-level required,
|
|
290
|
+
with the value guaranteed by the producer:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
|
|
66
294
|
```
|
|
67
295
|
|
|
68
|
-
|
|
296
|
+
But the control opens empty (see the table), so enforcing `required` on it
|
|
297
|
+
refused the submit with nothing sensible for the user to type. In **create**
|
|
298
|
+
mode a runtime `defaultValue` therefore suppresses the client-side `required`
|
|
299
|
+
rule, and the field is **omitted from the payload** — omitted, not sent empty,
|
|
300
|
+
because `applyFieldDefaults` resolves the declaration only for a field that
|
|
301
|
+
arrives absent or null, and a blank string is neither.
|
|
69
302
|
|
|
70
|
-
|
|
303
|
+
The **conditional** spelling reaches the same verdict. A `requiredWhen`
|
|
304
|
+
predicate says "required in this state", but `NOW()` / `current_user` resolve
|
|
305
|
+
at insert regardless of state, so the producer's guarantee covers the
|
|
306
|
+
conditional claim exactly as it covers the unconditional one:
|
|
71
307
|
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
name: string;
|
|
75
|
-
type: string; // 'input', 'select', 'checkbox', etc.
|
|
76
|
-
label: string;
|
|
77
|
-
placeholder?: string;
|
|
78
|
-
required?: boolean;
|
|
79
|
-
validation?: ValidationRule[];
|
|
80
|
-
defaultValue?: any;
|
|
81
|
-
disabled?: boolean;
|
|
82
|
-
className?: string;
|
|
83
|
-
}
|
|
308
|
+
```ts
|
|
309
|
+
remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaultValue: 'NOW()' }),
|
|
84
310
|
```
|
|
85
311
|
|
|
312
|
+
| Mode | Declared | Left empty | Effect |
|
|
313
|
+
|---|---|---|---|
|
|
314
|
+
| create | `required` / `requiredWhen` TRUE + a runtime default | yes | submits; the key is absent, and the server resolves it |
|
|
315
|
+
| create | `required` / `requiredWhen` TRUE + a runtime default | no (user typed) | submits the typed value — it outranks the default |
|
|
316
|
+
| create | `required` / `requiredWhen` TRUE + a static literal | user cleared the seeded control | refused: they removed a value that was really there |
|
|
317
|
+
| create | `requiredWhen` TRUE, no default | yes | refused: nobody else supplies this value |
|
|
318
|
+
| edit | `required` / `requiredWhen` TRUE, anything | user blanked it | refused: the token was resolved at insert, so this is a real removal |
|
|
319
|
+
|
|
320
|
+
The required marker and `aria-required` go with the rule in the create case,
|
|
321
|
+
since one verdict drives all three — in that mode the user genuinely is not
|
|
322
|
+
required to provide the value. Showing what the server *will* supply, as a
|
|
323
|
+
non-authoritative preview, is a separate follow-up.
|
|
324
|
+
|
|
325
|
+
Every consumer reads one predicate, `isRuntimeDefault` in `@object-ui/core`
|
|
326
|
+
(re-exported for this package's own use by `src/schemaDefaults.ts`, not from the
|
|
327
|
+
package entry listed above) — which is what keeps a form from seeding a field it
|
|
328
|
+
also refuses to submit. The static half is decided at the producer, in
|
|
329
|
+
`isRequiredInForm`; the conditional half cannot be, because `requiredWhen` is
|
|
330
|
+
resolved downstream against the live record, so it is suppressed inside the one
|
|
331
|
+
evaluator that resolves it — `resolveFieldRuleState`, reading
|
|
332
|
+
`isServerOwnedValue`. Display and validation share that single verdict, so a
|
|
333
|
+
field can never lose its asterisk while still refusing the submit.
|
|
334
|
+
|
|
86
335
|
### Column width of a sectioned form
|
|
87
336
|
|
|
88
337
|
A sectioned form renders as ONE grid, and two keys decide its shape:
|
|
@@ -194,7 +443,9 @@ primary pane, the rest stack in the secondary one behind inline section headers.
|
|
|
194
443
|
### Basic Form
|
|
195
444
|
|
|
196
445
|
```typescript
|
|
197
|
-
|
|
446
|
+
import type { FormSchema } from '@object-ui/types';
|
|
447
|
+
|
|
448
|
+
const schema: FormSchema = {
|
|
198
449
|
type: 'form',
|
|
199
450
|
fields: [
|
|
200
451
|
{
|
|
@@ -210,9 +461,15 @@ const schema = {
|
|
|
210
461
|
inputType: 'email',
|
|
211
462
|
label: 'Email Address',
|
|
212
463
|
required: true,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
464
|
+
// Rule name → rule. Not an array (see Schema API above), and there is no
|
|
465
|
+
// 'email' rule: the email check is the pattern the metadata path builds
|
|
466
|
+
// for a field of type `email`.
|
|
467
|
+
validation: {
|
|
468
|
+
pattern: {
|
|
469
|
+
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
470
|
+
message: 'Invalid email format'
|
|
471
|
+
}
|
|
472
|
+
}
|
|
216
473
|
},
|
|
217
474
|
{
|
|
218
475
|
name: 'country',
|
|
@@ -239,51 +496,111 @@ const schema = {
|
|
|
239
496
|
|
|
240
497
|
### Multi-Step Form
|
|
241
498
|
|
|
499
|
+
A multi-step form is an **`object-form` with `formType: 'wizard'`**, and its
|
|
500
|
+
steps are its `sections` — one step per section. There is no
|
|
501
|
+
`multi-step-form` schema type (no registration anywhere claims that name) and no
|
|
502
|
+
`steps` key on any form schema, so a schema written that way renders the
|
|
503
|
+
unknown-component placeholder and the fields inside `steps` are never read.
|
|
504
|
+
|
|
242
505
|
```typescript
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
506
|
+
import type { ObjectFormSchema } from '@object-ui/types';
|
|
507
|
+
|
|
508
|
+
const schema: ObjectFormSchema = {
|
|
509
|
+
type: 'object-form',
|
|
510
|
+
objectName: 'contacts', // required
|
|
511
|
+
mode: 'create', // required
|
|
512
|
+
formType: 'wizard', // routes to WizardForm — needs at least one section
|
|
513
|
+
sections: [
|
|
246
514
|
{
|
|
247
|
-
|
|
515
|
+
name: 'personal',
|
|
516
|
+
label: 'Personal Info',
|
|
517
|
+
fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: 'contact',
|
|
521
|
+
label: 'Contact Info',
|
|
522
|
+
fields: ['email', 'phone']
|
|
523
|
+
}
|
|
524
|
+
],
|
|
525
|
+
allowSkip: false, // see "Wizard steps and allowSkip" above
|
|
526
|
+
showStepIndicator: true
|
|
527
|
+
};
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
A section's `fields` accepts three shapes — a field **name**, a spec
|
|
531
|
+
`FormFieldSchema` object (whose identity key is `field`), or an inline runtime
|
|
532
|
+
`FormField` object. The inline shape is what lets a wizard run with no data
|
|
533
|
+
source at all, which is the closest equivalent of the old snippet; note that
|
|
534
|
+
`WizardForm` reports a data-source-less submit through `onSuccess` (there is no
|
|
535
|
+
`onSubmit` on this schema):
|
|
536
|
+
|
|
537
|
+
```tsx
|
|
538
|
+
import { WizardForm } from '@object-ui/plugin-form';
|
|
539
|
+
import type { WizardFormSchema } from '@object-ui/plugin-form';
|
|
540
|
+
|
|
541
|
+
const wizard: WizardFormSchema = {
|
|
542
|
+
type: 'object-form',
|
|
543
|
+
formType: 'wizard',
|
|
544
|
+
objectName: 'contacts',
|
|
545
|
+
mode: 'create',
|
|
546
|
+
sections: [
|
|
547
|
+
{
|
|
548
|
+
name: 'personal',
|
|
549
|
+
label: 'Personal Info',
|
|
248
550
|
fields: [
|
|
249
551
|
{ name: 'firstName', type: 'input', label: 'First Name', required: true },
|
|
250
552
|
{ name: 'lastName', type: 'input', label: 'Last Name', required: true }
|
|
251
553
|
]
|
|
252
554
|
},
|
|
253
555
|
{
|
|
254
|
-
|
|
556
|
+
name: 'contact',
|
|
557
|
+
label: 'Contact Info',
|
|
255
558
|
fields: [
|
|
256
559
|
{ name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
|
|
257
560
|
{ name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }
|
|
258
561
|
]
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
title: 'Review',
|
|
262
|
-
fields: []
|
|
263
562
|
}
|
|
264
563
|
],
|
|
265
|
-
|
|
564
|
+
onSuccess: (data) => {
|
|
266
565
|
console.log('Multi-step form completed:', data);
|
|
267
566
|
}
|
|
268
567
|
};
|
|
568
|
+
|
|
569
|
+
<WizardForm schema={wizard} /> // dataSource omitted: every step lists inline fields
|
|
269
570
|
```
|
|
270
571
|
|
|
572
|
+
`WizardFormSchema` declares no index signature, so an invented key on *this*
|
|
573
|
+
type is a real compile error — unlike `ObjectFormSchema`, which inherits
|
|
574
|
+
`BaseSchema`'s `[key: string]: any`.
|
|
575
|
+
|
|
576
|
+
One more route exists and is worth knowing about rather than reinventing: a flat
|
|
577
|
+
`object-form` can be turned into a stepper on small viewports with
|
|
578
|
+
`mobile: { stepper: true | 'auto', stepperFieldsPerStep, stepperMinFields }`,
|
|
579
|
+
which feeds the same `WizardForm`.
|
|
580
|
+
|
|
271
581
|
### Form with Validation
|
|
272
582
|
|
|
273
583
|
```typescript
|
|
274
|
-
|
|
584
|
+
import type { FormSchema } from '@object-ui/types';
|
|
585
|
+
|
|
586
|
+
const schema: FormSchema = {
|
|
275
587
|
type: 'form',
|
|
588
|
+
validationMode: 'onBlur',
|
|
276
589
|
fields: [
|
|
277
590
|
{
|
|
278
591
|
name: 'username',
|
|
279
592
|
type: 'input',
|
|
280
593
|
label: 'Username',
|
|
281
|
-
required: true,
|
|
282
|
-
validation:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
594
|
+
required: true, // presence: this is the key that decides it
|
|
595
|
+
validation: {
|
|
596
|
+
required: 'Pick a username', // the MESSAGE for the rule above, nothing more
|
|
597
|
+
minLength: { value: 3, message: 'Username must be at least 3 characters' },
|
|
598
|
+
maxLength: { value: 20, message: 'Username must be less than 20 characters' },
|
|
599
|
+
pattern: {
|
|
600
|
+
value: /^[a-zA-Z0-9_]+$/, // a RegExp, not a string
|
|
601
|
+
message: 'Only letters, numbers, and underscores'
|
|
602
|
+
}
|
|
603
|
+
}
|
|
287
604
|
},
|
|
288
605
|
{
|
|
289
606
|
name: 'password',
|
|
@@ -291,42 +608,153 @@ const schema = {
|
|
|
291
608
|
inputType: 'password',
|
|
292
609
|
label: 'Password',
|
|
293
610
|
required: true,
|
|
294
|
-
validation:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
611
|
+
validation: {
|
|
612
|
+
minLength: { value: 8, message: 'Password must be at least 8 characters' },
|
|
613
|
+
pattern: {
|
|
614
|
+
value: /(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])/,
|
|
615
|
+
message: 'Must contain uppercase, lowercase, and number'
|
|
616
|
+
},
|
|
617
|
+
validate: (value) =>
|
|
618
|
+
String(value).toLowerCase() !== 'password' || 'Pick something less guessable'
|
|
619
|
+
}
|
|
298
620
|
}
|
|
299
621
|
]
|
|
300
622
|
};
|
|
301
623
|
```
|
|
302
624
|
|
|
625
|
+
Each rule appears **once**, under its own name — an object, not a list. A second
|
|
626
|
+
`minLength` cannot exist, which is the point: the shape the renderer hands
|
|
627
|
+
react-hook-form is one rule per kind.
|
|
628
|
+
|
|
303
629
|
## Integration with Data Sources
|
|
304
630
|
|
|
305
|
-
|
|
631
|
+
**The adapter is not a schema key.** A schema is a serialisable document; a live
|
|
632
|
+
adapter is an object with methods, so it cannot travel in one. Both form routes
|
|
633
|
+
read the adapter from React context, which the host installs once above the whole
|
|
634
|
+
tree — the same rule [Registering a component under your own
|
|
635
|
+
key](#registering-a-component-under-your-own-key) states for custom
|
|
636
|
+
registrations.
|
|
306
637
|
|
|
307
|
-
|
|
638
|
+
### The metadata route — `object-form`
|
|
639
|
+
|
|
640
|
+
This is the route that actually reads and writes a record. `ObjectFormSchema`
|
|
641
|
+
names its object with **`objectName`** and its intent with **`mode`**, and both
|
|
642
|
+
are **required** (`packages/types/src/objectql.ts`); the adapter arrives on the
|
|
643
|
+
context, which `ObjectFormRenderer` reads at `src/index.tsx` before handing
|
|
644
|
+
`ObjectForm` its `dataSource` prop.
|
|
645
|
+
|
|
646
|
+
```tsx
|
|
647
|
+
import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
|
|
308
648
|
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
|
|
649
|
+
import '@object-ui/plugin-form';
|
|
650
|
+
import type { ObjectFormSchema } from '@object-ui/types';
|
|
309
651
|
|
|
310
652
|
const dataSource = createObjectStackAdapter({
|
|
311
653
|
baseUrl: 'https://api.example.com',
|
|
312
|
-
token: 'your-auth-token'
|
|
654
|
+
token: 'your-auth-token',
|
|
313
655
|
});
|
|
314
656
|
|
|
315
|
-
const schema = {
|
|
657
|
+
const schema: ObjectFormSchema = {
|
|
658
|
+
type: 'object-form',
|
|
659
|
+
objectName: 'users',
|
|
660
|
+
mode: 'create',
|
|
661
|
+
fields: ['name', 'email'],
|
|
662
|
+
submitText: 'Create user',
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
export const App = () => (
|
|
666
|
+
<SchemaRendererProvider dataSource={dataSource}>
|
|
667
|
+
<SchemaRenderer schema={schema} />
|
|
668
|
+
</SchemaRendererProvider>
|
|
669
|
+
);
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'`
|
|
673
|
+
or `'view'`, add the `recordId` of the record being opened. Note that this
|
|
674
|
+
`mode` vocabulary is `'create' | 'edit' | 'view'` — the basic form's `mode` is a
|
|
675
|
+
different key with a different vocabulary (`'edit' | 'read' | 'disabled'`, see
|
|
676
|
+
[Schema API](#schema-api)).
|
|
677
|
+
|
|
678
|
+
### The TypeScript route — basic `form`
|
|
679
|
+
|
|
680
|
+
A bare `form` never fetches or saves by itself: it has no object name and no
|
|
681
|
+
query, so there is nothing for it to call an adapter *with*. It collects values
|
|
682
|
+
and hands them to your `onSubmit`, which the renderer awaits
|
|
683
|
+
(`packages/components/src/renderers/form/form.tsx:1428`). Any persistence is
|
|
684
|
+
whatever that function does:
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
import type { FormSchema } from '@object-ui/types';
|
|
688
|
+
|
|
689
|
+
const dataSource = createObjectStackAdapter({
|
|
690
|
+
baseUrl: 'https://api.example.com',
|
|
691
|
+
token: 'your-auth-token',
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const schema: FormSchema = {
|
|
316
695
|
type: 'form',
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
696
|
+
fields: [
|
|
697
|
+
{ name: 'name', type: 'input', label: 'Full Name', required: true },
|
|
698
|
+
{ name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
|
|
699
|
+
],
|
|
700
|
+
submitLabel: 'Create user',
|
|
701
|
+
// The adapter reaches this call through the CLOSURE, not through the schema.
|
|
320
702
|
onSubmit: async (data) => {
|
|
321
703
|
await dataSource.create('users', data);
|
|
322
|
-
}
|
|
704
|
+
},
|
|
323
705
|
};
|
|
324
706
|
```
|
|
325
707
|
|
|
708
|
+
`onSubmit` is a function, so this route is **TypeScript-authored schemas only** —
|
|
709
|
+
a JSON metadata document cannot carry one. Metadata pages take the `object-form`
|
|
710
|
+
route above.
|
|
711
|
+
|
|
712
|
+
What the adapter on the context still does for a bare `form` is supply the
|
|
713
|
+
**field widgets**: the renderer reads it at `form.tsx:1004`
|
|
714
|
+
(`const contextDataSource = schemaCtx?.dataSource ?? null`) and passes it down
|
|
715
|
+
per field at `:2061`, which is how a lookup or cascading select loads its
|
|
716
|
+
options. Nothing else about the form is wired to it.
|
|
717
|
+
|
|
718
|
+
### Keys that look like wiring but are not
|
|
719
|
+
|
|
720
|
+
Neither of these is a key of either form schema, and writing them changes
|
|
721
|
+
nothing:
|
|
722
|
+
|
|
723
|
+
| Written on a form schema | What actually happens |
|
|
724
|
+
|---|---|
|
|
725
|
+
| `dataSource` | **Discarded.** The basic form strips it in both directions — `dataSource: _dataSource` at `form.tsx:304` (`stripRendererOnlyProps`) and `:2168` — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one |
|
|
726
|
+
| `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all. The key exists elsewhere in the protocol — on **`CRUDSchema`** (`packages/types/src/crud.ts`, `type: 'crud'`), where `CRUDBuilder` in `@object-ui/core` sets it — but no form renderer reads it under any spelling. On a form it names nothing |
|
|
727
|
+
|
|
728
|
+
Both survive compilation for the reason [Schema API](#schema-api) gives: `FormSchema`
|
|
729
|
+
and `ObjectFormSchema` extend `BaseSchema`, which declares `[key: string]: any`, so an
|
|
730
|
+
invented key is not a type error — it is simply never read. That is also why the
|
|
731
|
+
older version of this section looked like it worked: its `onSubmit` genuinely ran
|
|
732
|
+
and genuinely saved, but through the adapter its closure captured. The `dataSource`
|
|
733
|
+
and `resource` keys sitting beside it in the same object were inert. Delete them and
|
|
734
|
+
that example behaves identically — which is the test for whether a key is doing
|
|
735
|
+
anything.
|
|
736
|
+
|
|
737
|
+
> A top-level `dataSource` **does** mean something on a schema node, but it is not
|
|
738
|
+
> an adapter: it is the spec's element **binding** (`PageComponentSchema.dataSource`,
|
|
739
|
+
> objectstack#6953) — a descriptor such as `{ object: 'users' }` — resolved by
|
|
740
|
+
> `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts:139`).
|
|
741
|
+
> `object-form` passes through that gate, and honours the binding's `object` key
|
|
742
|
+
> only. Handing that slot a live adapter is rejected on purpose: the predicate
|
|
743
|
+
> refuses any value carrying a `find` method
|
|
744
|
+
> (`packages/core/src/data-scope/element-data-source.ts:131`), so an adapter written
|
|
745
|
+
> there is ignored rather than mistaken for a binding. Pass adapters through the
|
|
746
|
+
> provider above.
|
|
747
|
+
|
|
326
748
|
## TypeScript Support
|
|
327
749
|
|
|
750
|
+
`FormSchema` and `FormField` are protocol types, so they live in
|
|
751
|
+
`@object-ui/types` alongside the rest of the JSON contract. This package imports
|
|
752
|
+
them and does not re-export them — the form types on its own entry are the
|
|
753
|
+
per-container ones (`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …)
|
|
754
|
+
listed under [Public exports](#public-exports).
|
|
755
|
+
|
|
328
756
|
```typescript
|
|
329
|
-
import type { FormSchema, FormField } from '@object-ui/
|
|
757
|
+
import type { FormSchema, FormField } from '@object-ui/types';
|
|
330
758
|
|
|
331
759
|
const emailField: FormField = {
|
|
332
760
|
name: 'email',
|
|
@@ -357,17 +785,6 @@ The plugin includes these field components:
|
|
|
357
785
|
- Date picker
|
|
358
786
|
- File upload
|
|
359
787
|
|
|
360
|
-
<!-- release-metadata:v3.3.0 -->
|
|
361
|
-
|
|
362
|
-
## Compatibility
|
|
363
|
-
|
|
364
|
-
- **React:** 18.x or 19.x
|
|
365
|
-
- **Node.js:** ≥ 18
|
|
366
|
-
- **TypeScript:** ≥ 5.0 (strict mode)
|
|
367
|
-
- **`@objectstack/spec`:** ^3.3.0
|
|
368
|
-
- **`@objectstack/client`:** ^3.3.0
|
|
369
|
-
- **Tailwind CSS:** ≥ 3.4 (for packages with UI)
|
|
370
|
-
|
|
371
788
|
## Links
|
|
372
789
|
|
|
373
790
|
- 📚 [Documentation](https://www.objectui.org/docs/plugins/plugin-form)
|