@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +1159 -0
  2. package/README.md +494 -77
  3. package/dist/DrawerForm.d.ts.map +1 -1
  4. package/dist/EmbeddableForm.d.ts.map +1 -1
  5. package/dist/LineItemsPanel.d.ts +33 -0
  6. package/dist/LineItemsPanel.d.ts.map +1 -1
  7. package/dist/MasterDetailForm.d.ts.map +1 -1
  8. package/dist/ModalForm.d.ts.map +1 -1
  9. package/dist/ObjectForm.d.ts +18 -2
  10. package/dist/ObjectForm.d.ts.map +1 -1
  11. package/dist/SplitForm.d.ts.map +1 -1
  12. package/dist/TabbedForm.d.ts.map +1 -1
  13. package/dist/WizardForm.d.ts +33 -0
  14. package/dist/WizardForm.d.ts.map +1 -1
  15. package/dist/autoLayout.d.ts.map +1 -1
  16. package/dist/deriveMasterDetail.d.ts +1 -1
  17. package/dist/deriveMasterDetail.d.ts.map +1 -1
  18. package/dist/flatFields.d.ts +21 -0
  19. package/dist/flatFields.d.ts.map +1 -0
  20. package/dist/index.d.ts +9 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1570 -1112
  23. package/dist/index.umd.cjs +1 -1
  24. package/dist/schemaDefaults.d.ts +142 -0
  25. package/dist/schemaDefaults.d.ts.map +1 -0
  26. package/dist/sectionFields.d.ts +18 -0
  27. package/dist/sectionFields.d.ts.map +1 -1
  28. package/dist/submitRedirect.d.ts +148 -0
  29. package/dist/submitRedirect.d.ts.map +1 -0
  30. package/dist/submitRedirectNavigation.d.ts +82 -0
  31. package/dist/submitRedirectNavigation.d.ts.map +1 -0
  32. package/dist/successBehavior.d.ts +18 -1
  33. package/dist/successBehavior.d.ts.map +1 -1
  34. package/dist/thankYouRedirectCountdown.d.ts +17 -0
  35. package/dist/thankYouRedirectCountdown.d.ts.map +1 -0
  36. package/dist/thankYouRedirectNavigation.d.ts +111 -0
  37. package/dist/thankYouRedirectNavigation.d.ts.map +1 -0
  38. package/package.json +13 -13
package/README.md CHANGED
@@ -35,54 +35,303 @@ const schema = {
35
35
  };
36
36
  ```
37
37
 
38
- ### Manual Registration
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
- // Register form components
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
- ### Form
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
- Complete form with fields and validation:
192
+ ### Form Field
55
193
 
56
- ```typescript
57
- {
58
- type: 'form',
59
- fields: FormField[],
60
- submitLabel?: string,
61
- cancelLabel?: string,
62
- onSubmit?: (data) => void,
63
- onCancel?: () => void,
64
- className?: string
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
- ### Form Field
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
- Individual form field configuration:
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
- ```typescript
73
- interface FormField {
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
- const schema = {
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
- validation: [
214
- { type: 'email', message: 'Invalid email format' }
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
- const schema = {
244
- type: 'multi-step-form',
245
- steps: [
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
- title: 'Personal Info',
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
- title: 'Contact Info',
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
- onSubmit: (data) => {
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
- const schema = {
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
- { type: 'minLength', value: 3, message: 'Username must be at least 3 characters' },
284
- { type: 'maxLength', value: 20, message: 'Username must be less than 20 characters' },
285
- { type: 'pattern', value: '^[a-zA-Z0-9_]+$', message: 'Only letters, numbers, and underscores' }
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
- { type: 'minLength', value: 8, message: 'Password must be at least 8 characters' },
296
- { type: 'pattern', value: '(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])', message: 'Must contain uppercase, lowercase, and number' }
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
- Connect forms to backend APIs:
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
- ```typescript
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
- dataSource,
318
- resource: 'users',
319
- fields: [/* fields */],
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/plugin-form';
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)