@object-ui/plugin-form 17.5.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 CHANGED
@@ -1,5 +1,659 @@
1
1
  # @object-ui/plugin-form
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8c0d52e: A form's ruled `submitBehavior.url` redirect can now be performed by the HOST, so a destination stays inside a console mounted at a sub-path (objectui#4989 defect 4).
8
+
9
+ `ObjectForm` and `WizardForm` accept a relative in-app path and used to travel to it with `window.location.assign`. A rooted path resolves against the ORIGIN there, so under a mounted host — `<BrowserRouter basename="/_console">`, which the framework CLI configures for every embedded deployment — an authored `/thanks` left the application. The destination was correct and the navigation was wrong, and a published renderer cannot fix that alone: only the host knows its mount.
10
+
11
+ **New: `HostNavigationContext` (`@object-ui/react`).** A host offers its own navigate; a renderer uses it when it is there:
12
+
13
+ ```tsx
14
+ import { HostNavigationProvider } from '@object-ui/react';
15
+ import { useNavigate } from 'react-router-dom';
16
+
17
+ function Bridge({ children }) {
18
+ const navigate = useNavigate();
19
+ return (
20
+ <HostNavigationProvider value={{ navigate: (to, o) => navigate(to, { replace: o?.replace ?? false }) }}>
21
+ {children}
22
+ </HostNavigationProvider>
23
+ );
24
+ }
25
+ ```
26
+
27
+ `@object-ui/app-shell`'s `ConsoleShell` now mounts that bridge above every console route, so the console's own routes, and its basename, are what a post-submit redirect resolves against — and the redirect becomes an SPA transition instead of a full page load.
28
+
29
+ **Nothing changes for a host that wires nothing.** With no provider the behaviour is byte-for-byte what it was: one `window.location.assign` of the resolved path after the declared `delayMs`. A host with no router has no basename, so origin-rooted resolution is already right there — the seam changes what a MOUNTED host gets and nothing else. `useHostNavigation()` outside a provider answers `{ navigate: undefined }` and never throws.
30
+
31
+ Two mechanisms were weighed and rejected by the ruling, recorded so they are not re-proposed. **Reading React Router's context when a router happens to be present** is implicit, and unavailable anyway: a React context is a module-instance object, so reading the host's means importing `react-router` into the renderer — which `@object-ui/plugin-form` declares in none of its dependency fields, whose published build externalises every bare specifier, and which two real consumers (`apps/site`, `packages/plugin-view`) do not install. **Requiring `react-router` as a peer** is the honest version of the same thing and costs the package its property of dropping into any React application.
32
+
33
+ What the host takes on by supplying a navigate: the destination becomes a client-side transition, so an in-app path with no matching route renders the host's not-found instead of a full page load. That is the host's routing table to answer for, which is why the choice is the host's. The contract verdict is unchanged either way — a destination `@objectstack/spec` refuses is refused identically with or without a seam, so an injected navigate can never launder a value the authoring door rejects.
34
+
35
+ `navigateOnSuccess` is a different declared key with its own open contract question (objectui#5034) and is deliberately untouched here.
36
+
37
+ ### Patch Changes
38
+
39
+ - 9b20dea: Fix: a `drawer` form with no `sections` now renders the object's declared
40
+ `fieldGroups` as sections, matching `ObjectForm` and `ModalForm`.
41
+
42
+ `deriveFieldGroupSections` had exactly two call sites in the repo —
43
+ `ObjectForm` and `ModalForm` — so the same object, with the same metadata,
44
+ rendered one section per declared group in the modal create dialog and one
45
+ ungrouped flat list in the drawer. The author who laid the groups out in the
46
+ object designer saw them honoured on two surfaces out of three.
47
+
48
+ `DrawerForm` now runs the same fallback the modal does: gated on "no explicit
49
+ `sections`, no `customFields`", over the same auto-layout-filtered field list
50
+ (system fields dropped, auto-generated fields dropped in create mode), with the
51
+ flat path's inferred column count carried onto the grouped layout. A curated
52
+ `sections` list from a form view still wins, and an object whose fields join no
53
+ declared group keeps its flat layout untouched. A derived group declaring
54
+ ADR-0085 `collapse` renders as a collapsible header, like an authored one.
55
+ - 469b604: Fix: a `drawer` form with no `sections` now honours the object's field-level
56
+ conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`) and field
57
+ `group`.
58
+
59
+ `ModalForm` and `DrawerForm` each carried their own copy of the "object-schema
60
+ field to runtime FormField" loop for the no-sections case, and the drawer's
61
+ copy had fallen behind: it stopped at `multiple`, so the ADR-0036 predicates
62
+ never reached the runtime field and `resolveFieldRuleState` had nothing to
63
+ resolve. A hidden field rendered anyway, a frozen field stayed editable (with
64
+ the server then dropping the write), and a conditionally-required field never
65
+ blocked the submit.
66
+
67
+ Both containers now build that list through one shared `buildFlatFields`, which
68
+ resolves each field through the same `fromObjectSchema` the sectioned path uses
69
+ — so the next field-mapping fix lands once and reaches every container.
70
+ - d7be3bd: `EmbeddableForm`'s thank-you redirect stops being mount-blind: an in-app destination now goes through the host's injected navigate.
71
+
72
+ The redirect ended in one unconditional `window.location.href = url`. That is
73
+ right for the external destination this key deliberately admits, and wrong for
74
+ the in-app one it equally admits: a rooted path such as `/thanks` assigned to
75
+ `location.href` resolves against the ORIGIN root, so under a host mounted at a
76
+ sub-path — the framework CLI configures one for every embedded deployment, and
77
+ the console runs at basename `/_console` — the submitter landed outside the
78
+ application, usually on the host's own 404. Nothing refused either half of that
79
+ authoring, so the failure was silent. This is objectui#4989 defect 4 on the key
80
+ that card explicitly did not cover, and it is fixed here through the seam
81
+ objectui#5111 landed (`HostNavigationContext`, `@object-ui/react`).
82
+
83
+ The destinations are now split by who can travel to them:
84
+
85
+ - an **app-relative** destination (`/thanks`, `thanks`, `?ok=1`, `#done`) is
86
+ handed to the host's navigate when a host supplied one, so a mounted host
87
+ places it inside its mount; with no provider the behaviour is byte-for-byte
88
+ what it was — a host with no router has no basename, so origin-rooted
89
+ resolution is already correct there;
90
+ - an **external** destination admitted by `allowedRedirectHosts` keeps
91
+ browser-level navigation **unconditionally**. This is the seam's own declared
92
+ input contract, not a conservatism: `HostNavigationValue.navigate` documents
93
+ `to` as an application-relative path, "never an absolute URL", because a host
94
+ navigate is a client-side router transition. Since a relative reference cannot
95
+ carry an authority, the seam is now structurally incapable of being handed a
96
+ cross-origin URL.
97
+
98
+ A same-origin **absolute** URL — the one shape those two arms do not name — also
99
+ keeps browser-level navigation. Routing it through the seam would mean rewriting
100
+ the author's full address into a path a mounted router then places at a
101
+ different address; an author who spelled out the whole address asked for that
102
+ address.
103
+
104
+ Not changed, deliberately: `isRedirectUrlSafe` and `allowedRedirectHosts` —
105
+ WHICH destinations are followed. That acceptance set (same-origin OR the
106
+ author's allowlist) is this key's own contract, a refused destination reaches
107
+ neither the seam nor the browser, and objectstack#7496's relative-only ruling
108
+ belongs to `submitBehavior.url` and is not imported onto this key. The wait's
109
+ ownership (objectui#5049) and the thank-you panel's copy (objectui#5073) are
110
+ carried over unchanged: unmounting or pressing "Submit Another Response" still
111
+ cancels a pending redirect, seam or no seam.
112
+ - a954b48: A public form's thank-you countdown ("Redirecting in {{seconds}} seconds…") now
113
+ actually counts down, instead of rendering a number once and leaving it frozen
114
+ for the whole wait.
115
+
116
+ All ten locale packs document `publicForm.redirecting`'s `{{seconds}}` as "the
117
+ remaining seconds", but `EmbeddableForm` computed it exactly once — at the render
118
+ that first shows the thank-you panel — from `pendingRedirect.delayMs`, and never
119
+ touched it again. On the 3 second default delay, a submitter saw a fully static
120
+ "Redirecting in 3 seconds…" for the entire wait (objectui#5083).
121
+
122
+ The number is now owned by a per-second `setInterval`, on the same ownership
123
+ model PR #5070 established for the redirect wait itself: an effect keyed on the
124
+ accepted destination (`pendingRedirect`), cancelled on unmount and on
125
+ `handleReset`'s `Submit Another Response` — the exact regression surface
126
+ objectui#5049 fixed for the navigation timer, restated here rather than
127
+ reintroduced. The interval also stops itself once it reaches 0, rather than
128
+ ticking indefinitely past a wait that has already ended.
129
+
130
+ Nothing about WHICH destinations are followed or refused changes
131
+ (`isRedirectUrlSafe` / `allowedRedirectHosts`, objectui#4989), and neither does
132
+ the navigation wait's own ownership (objectui#5049 / PR #5070) — this is the
133
+ display only.
134
+ - bda9b12: A public form's thank-you panel no longer promises a redirect its own guard just
135
+ refused, and the `texts.redirectBlocked` string can finally reach a screen.
136
+
137
+ `EmbeddableForm` decided whether to redirect from `isRedirectUrlSafe` /
138
+ `allowedRedirectHosts`, but the panel's copy was keyed on something else: whether
139
+ a `thankYouPage.redirectUrl` had been *authored* (objectui#5073). An author who
140
+ declared a cross-origin destination without allowlisting its host therefore got a
141
+ submitter who was told `Redirecting in 3 seconds…` and was then never redirected.
142
+ The guard did its job; the screen contradicted it. That screen is the terminal
143
+ state of a public form, so nothing came after to correct the impression.
144
+
145
+ On the same path, the `texts.redirectBlocked` string the refusal set was
146
+ unreachable in every locale. It was recorded with `setError(...)`, whose banner
147
+ lives in the form branch — and `setSubmitted(true)` has already run one statement
148
+ earlier, so the component is showing the thank-you branch, which renders no error
149
+ at all. That was the only assignment of the key anywhere; pressing
150
+ `Submit Another Response` cleared it rather than showing it.
151
+
152
+ Both now follow the verdict:
153
+
154
+ - The countdown renders on `pendingRedirect` — the destination that was actually
155
+ accepted — and reads its seconds from the delay captured with it, so the
156
+ displayed wait is the wait being served. A refused destination, and the
157
+ honeypot's silent fake-success (which accepts no destination either), simply
158
+ omit the line.
159
+ - A refused destination renders `texts.redirectBlocked` in the thank-you panel
160
+ when the author declared it — the case the key exists for, in the author's own
161
+ words to the public. Undeclared means silence; the author keeps the existing
162
+ `console.warn`, which is the channel for the person who can fix the
163
+ declaration.
164
+
165
+ Which destinations are refused is unchanged: `isRedirectUrlSafe` and
166
+ `allowedRedirectHosts` are untouched, as is the timer ownership introduced for
167
+ objectui#5049. Nothing was ever at risk in the data — the write succeeds before
168
+ any of this — the harm was a false statement on the confirmation screen and a
169
+ shipped, translated string no user could see.
170
+ - e354dd0: A public form's thank-you redirect no longer outlives the form that armed it, and
171
+ "Submit Another Response" now cancels it.
172
+
173
+ `EmbeddableForm` armed the `thankYouPage.redirectUrl` wait with a bare
174
+ `setTimeout` inside the submit handler: the handle was not stored, nothing cleared
175
+ it, and no part of the component owned it (objectui#5049). Two consequences, and
176
+ the second needs no unmount at all:
177
+
178
+ - For the whole of the delay a full-page navigation was pending that survived the
179
+ form being taken off screen — an embed removed by the host page, a route change,
180
+ a re-keyed subtree. With `redirectDelay` unset that window is the 3000 ms
181
+ default, so this was the normal state of every submit on this surface rather
182
+ than an edge authoring; the thank-you panel says as much out loud with
183
+ `Redirecting in {{seconds}} seconds…`.
184
+ - Under `allowMultiple`, `Submit Another Response` only flipped `submitted` back
185
+ to false while the pending navigation kept ticking. The component invited the
186
+ submitter into a fresh form and then, about three seconds later, threw the whole
187
+ page away while they were typing the next response.
188
+
189
+ The wait now lives in an effect keyed on the accepted destination, with a
190
+ `clearTimeout` cleanup, so unmounting cancels it; and `handleReset` drops the
191
+ destination, so pressing `Submit Another Response` cancels it too. The button
192
+ offers the submitter a fresh form, and that offer cannot be honoured alongside
193
+ discarding the page a moment later. This is the same move `ObjectForm` /
194
+ `WizardForm` (objectui#5033) and `apps/console`'s `FormPage` already made for
195
+ their own copies of this defect.
196
+
197
+ Nothing else changed. Which destinations are followed and which are refused is
198
+ still decided by the same `isRedirectUrlSafe` / `allowedRedirectHosts` guard, on
199
+ the same line as before — only who owns the wait changed. The delay is captured
200
+ together with the destination at the moment the write is accepted, so a host
201
+ re-rendering with a different `redirectDelay` mid-wait cannot restart the pause
202
+ under the submitter. The countdown copy is untouched. No data was ever at risk:
203
+ the write has already succeeded before the wait begins, so the harm was a
204
+ surprising navigation — and, on the `allowMultiple` path, the loss of what the
205
+ submitter had just re-typed.
206
+ - f68018d: A form's declared redirect delay no longer outlives the form that armed it.
207
+
208
+ `ObjectForm` and `WizardForm` consumed `submitBehavior: { kind: 'redirect' }` by
209
+ arming the `delayMs` wait with a bare `setTimeout` inside the submit handler. The
210
+ handle was not stored, nothing cleared it on unmount, and no part of the component
211
+ owned it — so for the whole of the declared delay there was a pending full-page
212
+ navigation that survived the form being taken off screen (objectui#5033). A
213
+ submitter who dismissed the modal or drawer variant after the confirmation, who
214
+ clicked an in-app link, or whose host re-keyed the subtree for its own reasons was
215
+ pulled away from wherever they had gone by a timer belonging to a form that no
216
+ longer existed. The longer the authored delay, the wider that window — and a
217
+ non-trivial delay is the intended authoring, since `delayMs` exists so the
218
+ confirmation is readable before the redirect.
219
+
220
+ The wait now lives in an effect keyed on the accepted destination, with a
221
+ `clearTimeout` cleanup, so unmounting cancels it. This is the same move
222
+ `apps/console`'s `FormPage` already made for its own copy of this defect.
223
+
224
+ `delayMs` semantics are unchanged: the pause is still a pause, an unset value is
225
+ still "go now" (a zero timer, exactly as the in-handler version scheduled it), and
226
+ which destinations are followed or refused is untouched — the contract verdict
227
+ that decides WHETHER to navigate is the same one, only WHEN it happens is now
228
+ owned by the component. The delay is captured together with the destination at the
229
+ moment the write is accepted, so a host re-rendering with a different `delayMs`
230
+ mid-wait cannot restart the pause under the submitter. Nothing was ever at risk of
231
+ being lost: the write has already succeeded before the wait begins, so the harm
232
+ was a surprising navigation, not a corrupted record.
233
+ - 375efb4: Publish the authoring surfaces of the four GA `object-*` blocks
234
+
235
+ `object-form`, `object-grid`, `object-master-detail-form` and `object-metric`
236
+ each honoured far more keys than they declared as registry `inputs`. An author —
237
+ very often an AI author — who wrote one of the undeclared keys got an
238
+ `unknown-prop` report from `sdui-parser` on a key that works, while the designer
239
+ panel and the generated `sdui-intrinsics.d.ts` denied it existed.
240
+
241
+ 68 keys are now declared with descriptions written to teach correct authoring:
242
+ `object-form` +20 (record binding, button labels, post-submit behaviour, mobile
243
+ overrides), `object-grid` +21 (sorting, pagination, grouping, selection, row and
244
+ bulk actions, navigation, export), `object-master-detail-form` +10, and
245
+ `object-metric` +14 (formatting, comparison, drill-down). No renderer behaviour
246
+ changes — this documents what already shipped, so the manifest, the generated
247
+ `.d.ts`, the designer panel and the renderers finally agree.
248
+
249
+ Ten of `object-grid`'s spec-declared keys are deliberately NOT published:
250
+ its own `@deprecated` legacy spellings (`fields`, `staticData`, `selectable`,
251
+ `pageSize`, `showSearch`, `showPagination`, `defaultSort`, `defaultFilters`,
252
+ `resizableColumns`, `title`). The renderer keeps reading them so existing
253
+ documents render, but recommending a deprecated alias as new authoring surface
254
+ would harden it into a second dialect. Each canonical replacement — `columns`,
255
+ `data`, `selection`, `pagination`, `searchableFields`, `sort`, `filter`,
256
+ `resizable`, `label` — is declared, and each carries a description naming the
257
+ legacy spelling it supersedes.
258
+ - 800f455: fix(fields): grid columns are keyed by the declared `name`, so spec-compliant grid metadata renders populated cells
259
+
260
+ `GridField` declared its own column interface keyed by `field` and read
261
+ `c.field` at every site (`key=`, `row[…]`, the blank row, cell writes, the
262
+ column chooser, the running-total lookup), while the published
263
+ `GridColumnDefinition` in `@object-ui/types` — and the grid documentation, and
264
+ the `fields-grid` catalog examples — declare the key as `name`. Metadata
265
+ authored against the published type therefore rendered a grid with the correct
266
+ row count and every cell empty, plus a React "unique key" warning per column.
267
+
268
+ The renderer now reads the declared `name`, and the master-detail derivation
269
+ (`deriveColumns` / `hydrateColumns` / `pickAmountField`) produces and consumes
270
+ the same key. There is deliberately **no** `col.field ?? col.name` alias: one
271
+ spelling at the producer (AGENTS.md #0.1).
272
+
273
+ **Breaking for `field`-keyed columns.** Grid / line-item / master-detail
274
+ subform columns spelled `{ field: 'amount' }` must be re-spelled
275
+ `{ name: 'amount' }`. This affects author-supplied `columns` on the `grid`
276
+ field, `record:line_items`, `object-master-detail-form` details and a
277
+ relationship field's `inlineColumns`. Auto-derived columns (no explicit
278
+ `columns` block) need no change. List-view and `object-grid` columns are a
279
+ different contract (`ListColumn`) and keep their own `field` key.
280
+ - 3b03704: `mobile.fullscreenLongText` now reaches fields the spec spells `richtext` (objectui#4831).
281
+
282
+ `ObjectForm` is the one and only producer of the `mobile_fullscreen` flag: when a form
283
+ sets `mobile: { fullscreenLongText: true }` it stamps that flag onto the metadata of
284
+ every long-text field, and the widget renders an expand affordance plus a full-height
285
+ editing dialog from it. The list of types it stamped was four hand-written literals —
286
+ `textarea`, `field:textarea`, `field:markdown`, `field:html` — and `field:richtext` was
287
+ not among them.
288
+
289
+ `richtext` is the name `@objectstack/spec`'s `FieldType` gives this type; `markdown` and
290
+ `html` are the other two, and all three are registry keys on ONE widget, `RichTextField`,
291
+ which has read the flag since objectui#3301. So the consumer side was complete and two of
292
+ the widget's three keys were stamped: a field authored exactly as the spec prescribes
293
+ (`type: richtext`) rendered the rich-text editor with no expand button, on a form whose
294
+ `mobile` documentation promises "textarea/rich-text get an expand button". `markdown` and
295
+ `html` beside it worked. This is the same hole objectui#4250 found in this package's
296
+ `WIDE_FIELD_TYPES`, which is why the twin set already lists `richtext` and this one did
297
+ not.
298
+
299
+ Adding the missing key is the whole change; no other type's behaviour moves, and a form
300
+ that has not opted in still stamps nothing.
301
+ - 958d757: The plugin-form documentation-site page now teaches the `validation` shape the
302
+ form renderer actually reads, so a copied example validates instead of only
303
+ looking as though it does.
304
+
305
+ `content/docs/plugins/plugin-form.mdx` carried the same two defects
306
+ `packages/plugin-form/README.md` did before it was rewritten (objectui#5075 /
307
+ objectui#5118): the README half was fixed and the documentation-site mirror was
308
+ not touched.
309
+
310
+ `### Form Field` redeclared a local `interface FormField` whose `validation` was
311
+ `ValidationRule[]`. No `ValidationRule` type exists in this repository under any
312
+ spelling, and `validation` is not an array — it is `FieldValidationRules`
313
+ (`packages/types/src/form.ts`), an object keyed by rule name. The block also
314
+ listed `defaultValue` and `className`, neither of which is a declared member of
315
+ `FormField`, and marked `type` and `label` required when `name` is the only
316
+ required key of the 23. The section no longer declares a local interface at all
317
+ — a hand-written `interface` in a documentation snippet compiles nowhere, which
318
+ is how it drifted this far — and references the declared keys instead, each one
319
+ measured against the renderer's read points.
320
+
321
+ `### Form with Validation` authored the array to match, and that spelling fails
322
+ silently rather than loudly. The only reader of the key spreads it into the rule
323
+ object handed to react-hook-form (`const rules: any = { ...validation }`,
324
+ `packages/components/src/renderers/form/form.tsx:1652`); spreading an array
325
+ produces numeric keys, react-hook-form recognises none of them, and every rule
326
+ is dropped without an error. Measured against the real renderer: the old snippet
327
+ submits a two-character username under `minLength: 3` with no message shown,
328
+ while the rewritten one blocks it. The example is now annotated `FormSchema`, so
329
+ the array spelling is a compile error (TS2559) rather than a runtime surprise,
330
+ and a JSON variant is given alongside it for metadata authoring.
331
+
332
+ Three facts a reader could previously only discover by experiment are now
333
+ stated: `validation.required` supplies the required *message* while `required` /
334
+ `requiredWhen` on the field decide whether it is required; there is no `email`
335
+ rule name, an email check is a `pattern`; and a hand-authored `pattern` has to
336
+ carry a RegExp, because react-hook-form applies a pattern only when its value is
337
+ `instanceof RegExp` — it is the object-metadata path (`buildValidationRules`)
338
+ that compiles a declared string into one.
339
+
340
+ The two `DOC_TYPE_EXEMPTIONS` entries this page held in
341
+ `scripts/check-doc-component-types.mjs` are deleted with it. They exempted
342
+ `minLength` / `maxLength` as "ValidationRule discriminants under a field's
343
+ `validation[]`" — a reason whose every clause was the fiction being removed —
344
+ and without them the gate now fails if the array spelling returns.
345
+ - bfb64ee: `plugin-form` README: the "Integration with Data Sources" section now teaches the adapter's real path instead of two keys no form renderer reads.
346
+
347
+ The section taught backend wiring as two keys on a form schema — `dataSource`
348
+ (the adapter itself) and `resource: 'users'` — on an un-annotated
349
+ `const schema = { … }`. Neither key is read anywhere on either form route:
350
+
351
+ - **`dataSource`** is *discarded* by the basic form. The renderer reads its
352
+ adapter off `SchemaRendererContext`
353
+ (`packages/components/src/renderers/form/form.tsx:1004`) and passes it down per
354
+ field (`:2061`); a same-named key arriving on the schema or props is dropped by
355
+ the discard destructures at `form.tsx:304` and `:2168`, so it reaches neither a
356
+ widget nor the DOM.
357
+ - **`resource`** is declared on neither `FormSchema` nor `ObjectFormSchema`. The
358
+ key exists in the protocol, but on `CRUDSchema` (`packages/types/src/crud.ts`,
359
+ `type: 'crud'`); no form renderer reads it under any spelling.
360
+
361
+ Both survived compilation because `FormSchema` and `ObjectFormSchema` extend
362
+ `BaseSchema`, which declares `[key: string]: any` — so an invented key is never a
363
+ type error, merely never read. A reader who copied the block got a form that did
364
+ not connect to a backend, with nothing reported: what appeared to work was the
365
+ hand-written `onSubmit` closure, which genuinely runs (the renderer awaits it at
366
+ `form.tsx:1428`) using the adapter its *closure* captured, entirely independently
367
+ of the two keys beside it.
368
+
369
+ The section is rewritten around the real mechanism: the adapter is injected once
370
+ by `SchemaRendererProvider` and travels on context, the metadata route uses
371
+ `object-form` with its required `objectName` + `mode`, and the TypeScript route
372
+ is a bare `form` whose `onSubmit` owns persistence. Both examples now carry real
373
+ type annotations (`ObjectFormSchema` / `FormSchema`) in line with the rest of the
374
+ file — an un-annotated object literal type-checks whatever is written in it. A
375
+ closing note records the one thing a top-level `dataSource` *does* mean on a
376
+ schema node: the spec's element binding (`{ object }`, objectstack#6953), which
377
+ explicitly rejects a live adapter (`element-data-source.ts:131` refuses any value
378
+ carrying a `find` method).
379
+
380
+ Documentation only — no source, type or behavior change. This also removes a
381
+ self-contradiction inside the same README, whose "Registering a component under
382
+ your own key" section already stated the rule correctly ("never a `dataSource` —
383
+ that travels on `SchemaRendererContext`").
384
+ - e09f9e8: Docs only: `packages/plugin-form/README.md`'s Schema API and Examples now spell
385
+ the keys the form renderers actually read (objectui#5075). Three connected
386
+ drifts, judged against the build product's `dist/index.d.ts` under `strict`:
387
+
388
+ - **`validation` was written as an ARRAY** of `{ type, value, message }` entries
389
+ in three places. The real key is `FormField.validation?: FieldValidationRules`
390
+ — an OBJECT keyed by rule name (`required`, `minLength`, `maxLength`, `min`,
391
+ `max`, `pattern`, `validate`). The array form is worse than a type error,
392
+ because its runtime failure is SILENT: the only reader spreads the value into
393
+ the rule object handed to react-hook-form (`const rules: any = { ...validation }`,
394
+ `packages/components/src/renderers/form/form.tsx:1652`), and spreading an array
395
+ into an object literal yields numeric keys (`{ '0': …, '1': … }`).
396
+ react-hook-form's field validator reads exactly `required`, `maxLength`,
397
+ `minLength`, `min`, `max`, `pattern`, `validate`, `valueAsNumber` off its
398
+ descriptor, so every documented rule was dropped with nothing thrown — a form
399
+ copied from this README looked validated while validating nothing. The rewrite
400
+ also records two facts a reader could not have guessed: `validation.required`
401
+ supplies the required MESSAGE only (presence is decided by the field's own
402
+ `required` / `requiredWhen`), and a hand-authored `pattern.value` must be a
403
+ RegExp, since react-hook-form only applies a pattern whose value
404
+ `instanceof RegExp` and it is the object-metadata path (`buildValidationRules`)
405
+ that compiles a declared string into one.
406
+
407
+ - **`type: 'multi-step-form'` is registered nowhere**, and `steps` is not a key
408
+ on any form schema — so the whole "Multi-Step Form" example rendered the
409
+ unknown-component placeholder, with the fields inside `steps` never read. The
410
+ example is replaced by the two real entry points: an `object-form` with
411
+ `formType: 'wizard'`, whose steps are its `sections` (this is what
412
+ `ObjectForm` routes to `WizardForm`), and the exported `WizardForm` itself
413
+ with inline section fields and no data source — the shape closest to what the
414
+ old snippet was reaching for. No new schema type was registered to make the
415
+ old spelling true.
416
+
417
+ - **The `FormField` reference block declared a local `interface FormField`**,
418
+ which type-checks whatever it says because it is unrelated to the real type.
419
+ Five of its rows were wrong (`type` and `label` are OPTIONAL; `validation` is
420
+ the object above; `defaultValue` and `className` are not declared keys — the
421
+ form-level `defaultValues` and `span` / `colSpan` / `fieldContainerClass` are),
422
+ it named a `ValidationRule` type that exists nowhere in the repo, and it listed
423
+ 7 of the real 23 keys. The block is now a key table over the real declaration,
424
+ with `FormSchema`'s own keys beside it, and both examples are annotated with
425
+ their real types — the annotation is the point: `FormField` and `BaseSchema`
426
+ both declare `[key: string]: any`, so an un-annotated `const schema = { … }`
427
+ accepts any invented key and a nonexistent key is never a compile error.
428
+
429
+ No renderer behaviour changes, and no capability, export or type was added to
430
+ make an example true.
431
+ - 03e5f97: `packages/plugin-form/README.md`: three assertions about this package's export
432
+ surface were false, and the export names are now taken from the built
433
+ `dist/index.d.ts` (TS compiler API `checker.getExportsOfModule`) with every
434
+ TypeScript block compiled against those same declarations under `strict`.
435
+
436
+ - **`formComponents`** — fiction, and not a name that could be corrected: there
437
+ is no aggregate component map on the surface at all, so the "Manual
438
+ Registration" section described a mechanism that does not exist. Copying it got
439
+ `undefined` and threw on `Object.entries(undefined)`. It is replaced by what
440
+ actually happens: registration is a side effect of importing the entry, whose
441
+ six `ComponentRegistry.register(...)` calls claim
442
+ `plugin-form:object-form`, `view:form`, `plugin-form:embeddable-form`,
443
+ `plugin-form:form-analytics`, `plugin-form:object-master-detail-form` and
444
+ `record:line_items` — the two `skipFallback: true` calls being why bare `form`
445
+ and bare `line_items` are *not* taken over. The section also lists the real
446
+ export surface, and shows the thing the old snippet was reaching for: putting
447
+ an exported component on a schema type of your own, with the caveat that the
448
+ package's own registered renderers are internal wrappers that first resolve
449
+ `dataSource` from `SchemaRendererContext`.
450
+ - **`FormSchema` / `FormField`** — real types imported from the wrong package.
451
+ Both are protocol types declared in `@object-ui/types` (`src/form.ts`); this
452
+ package imports them and does not re-export them, so the documented import was
453
+ a `TS2305` pair. Only the import path changed — no re-export was added to make
454
+ the old path true, since widening a package's public surface is a contract
455
+ change and not a documentation fix. The section now also points at the form
456
+ types that *are* on this entry (`TabbedFormSchema`, `WizardFormSchema`,
457
+ `ModalFormSchema`, …).
458
+ - **`isRuntimeDefault` "(re-exported here)"** — the create-defaults section
459
+ claimed the predicate is re-exported by this package. It is re-exported by
460
+ `src/schemaDefaults.ts` for internal use only, never from the entry, and the
461
+ package publishes just the `"."` export — so `import { isRuntimeDefault } from
462
+ '@object-ui/plugin-form'` is another `TS2305`. The parenthetical now says where
463
+ the re-export actually lives.
464
+
465
+ No code, types or runtime behaviour change — the diff is one README plus this
466
+ changeset. It declares a patch because `README.md` is in the package's published
467
+ `files`, so the correction reaches npm with the next release.
468
+ - ae804ec: `ObjectForm` and `WizardForm` now consume a declared `submitBehavior: { kind: 'redirect' }` the way objectstack#7496 ruled it (objectui#4989): the destination is a **relative** path, `{{record.field_name}}` tokens are substituted from the record the submit just wrote and URL-escaped as the redirect is built, and a destination outside the contract is **refused on screen** instead of being dropped in silence.
469
+
470
+ Both call sites previously read the value through `isSameOriginUrl` — resolve against `window.location.href`, compare origins — and navigated when that answered yes. That is not an open redirect (a cross-origin destination never reached the navigation) but it diverged from the ruled contract three ways, all fixed here:
471
+
472
+ - **An out-of-contract destination was dropped in silence.** When the guard answered no the `if` simply did not fire: no toast, no error, no confirmation. The write had already succeeded, so the submitter was left facing a still-filled form with no feedback about what happened — and the obvious next move, submitting again, wrote a second record. A refusal now shows the spec's own author-facing prescription in an alert beside the confirmation that the record WAS written, toasts it, and replaces the filled form so there is nothing left to resubmit. Silence is the one outcome the ruling's consumer half rules out.
473
+ - **A same-origin ABSOLUTE url was followed**, where the contract is relative-only — so this renderer accepted a spelling the authoring door refuses, which is how a rejected spelling stays alive in a corpus. The verdict is no longer restated here at all: `resolveSubmitRedirect` asks `@objectstack/spec`'s own `FormViewSchema` at the moment of use, so an absolute URL, a protocol-relative `//host`, a backslash, a whitespace or control-character smuggle, a malformed token or a document-relative path is refused with the spec's own wording — and a later widening of the ruling is followed by the version pin rather than by an edit here.
474
+ - **`{{record.field_name}}` tokens were never substituted**, so an authored `/thanks?ref={{record.id}}` navigated with the literal braces in the query. Substitution now happens where the ruling assigns it — when the redirect is built — from the values as submitted with whatever the DataSource answered layered on top, and every interpolated value goes through `encodeURIComponent`, so a token is a value in the path and never a way to add path structure.
475
+
476
+ `delayMs` semantics are unchanged. `navigateOnSuccess` is a different declared key with its own dialect and its own open contract question, and is deliberately untouched; `isSameOriginUrl` survives because that key still needs it.
477
+
478
+ **Escalated rather than guessed at, and since ruled:** this change left a ruled in-app path handed to a browser-level navigation, which resolves it against the origin root, so under a host mounted at a sub-path the destination still left the app (objectui#4989 defect 4). Applying the mount means learning it, and every mechanism available to a published renderer package changes its contract. The maintainer ruled the mechanism on 2026-08-17 — an optional injected navigation seam — and it ships in the same release; see the `plugin-form-injected-navigation-4989` changeset for what a host now supplies and what a host that supplies nothing still gets.
479
+ - d971e51: A create form no longer deadlocks on a `requiredWhen` field that also declares a runtime `defaultValue`.
480
+
481
+ `#4069` ruled that in **create** mode a field whose `defaultValue` is a runtime
482
+ instruction the server resolves per insert (`NOW()` / `current_user`, or a CEL
483
+ Expression envelope) is producer-owned: the control is deliberately left empty
484
+ and the key is omitted from the payload, because `ObjectQL.applyFieldDefaults`
485
+ resolves the declaration only for a field that arrives absent or null. That was
486
+ implemented on the STATIC `required` flag.
487
+
488
+ The conditional spelling was not covered. `requiredWhen` is resolved one layer
489
+ downstream, in the form renderer, against the live record — so a predicate
490
+ resolving TRUE on a create form put the requirement straight back: the control
491
+ was still empty by design, the submit was refused, and the user had nothing
492
+ sensible to type.
493
+
494
+ Both spellings now behave identically on a producer-owned field. A
495
+ `requiredWhen` predicate is a claim about the value at rest in a given state,
496
+ and `NOW()` / `current_user` resolve at insert regardless of state, so the
497
+ producer's guarantee covers the conditional claim by the same argument that
498
+ covers the unconditional one. An author who really means "the user must supply
499
+ this in this state" has a natural spelling for it: do not declare the default.
500
+
501
+ The suppression lands in the single evaluator both layers read,
502
+ `resolveFieldRuleState` — the same verdict that draws the required marker and
503
+ the one the submit-time check consults — so a field can never lose its asterisk
504
+ while still refusing the write. The classifier that answers "is this value the
505
+ producer's to supply" moved down to `@object-ui/core`
506
+ (`isRuntimeDefault` / `isServerOwnedValue`, re-exported from
507
+ `@object-ui/plugin-form`) so the renderer, the wizard's cross-step gate and the
508
+ create-form field builders all read one implementation rather than three.
509
+
510
+ **Edit mode is unchanged.** Defaults do not re-apply to an existing record, so
511
+ on a persisted row the token was already resolved at insert and blanking the
512
+ column is a real removal: `requiredWhen` enforces there exactly as authored.
513
+ Fields with no declared default, and fields whose default is a static literal
514
+ (which IS seeded into the control), are also unaffected in both modes.
515
+ - 2165d88: Rename four component-props types off the names `@objectstack/spec` starts owning in
516
+ 17.0.0, keeping the old spellings as deprecated aliases. No behaviour changes and no
517
+ importer breaks.
518
+
519
+ `@objectstack/spec/ui` exports `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`
520
+ and `ObjectKanbanProps` from 17.0.0, where each is the AUTHORED props document of the
521
+ matching element — a serialisable authoring surface (`z.input< typeof
522
+ ObjectGridPropsSchema >`). The same-named interfaces here are the RENDERERS' props: a live
523
+ `dataSource`, records pre-fetched by a parent, and the host callbacks. Two different things
524
+ under one word, so the local ones are renamed rather than derived, following the split this
525
+ repo already made for `PageHeaderProps` -> `PageHeaderComponentProps` and the
526
+ `Record*ComponentProps` family in `@object-ui/types`:
527
+
528
+ | package | new name | old name |
529
+ |---|---|---|
530
+ | `@object-ui/plugin-calendar` | `ObjectCalendarComponentProps` | `ObjectCalendarProps` |
531
+ | `@object-ui/plugin-form` | `ObjectFormComponentProps` | `ObjectFormProps` |
532
+ | `@object-ui/plugin-grid` | `ObjectGridComponentProps` | `ObjectGridProps` |
533
+ | `@object-ui/plugin-kanban` | `ObjectKanbanComponentProps` | `ObjectKanbanProps` |
534
+
535
+ Every old name is still exported from its package barrel as a `@deprecated` alias denoting
536
+ the SAME type, pinned per package by `spec-symbol-4650.test.ts`, so existing imports keep
537
+ compiling. New code should use the `ComponentProps` spelling.
538
+
539
+ `@object-ui/app-shell` carries no API change: its `SECRET_MASK` — the ADR-0100 credential
540
+ read mask, which 17.0.0 moves into `@objectstack/spec/data` — is renamed to
541
+ `OBJECTUI_SECRET_MASK` at its declaration in `views/metadata-admin/widgets.tsx`. That
542
+ constant is package-internal and is not re-exported from the barrel, so nothing published
543
+ changes; the rename exists so the local copy cannot be read as the spec's own definition
544
+ while this repo is still pinned below the release that exports it.
545
+ - Updated dependencies [88085e3]
546
+ - Updated dependencies [69251bf]
547
+ - Updated dependencies [57e668f]
548
+ - Updated dependencies [516663d]
549
+ - Updated dependencies [41ac1b7]
550
+ - Updated dependencies [1eaf0a1]
551
+ - Updated dependencies [a09bc33]
552
+ - Updated dependencies [460c4d0]
553
+ - Updated dependencies [0ae27f7]
554
+ - Updated dependencies [2533ec5]
555
+ - Updated dependencies [78c0f9a]
556
+ - Updated dependencies [bbe8b86]
557
+ - Updated dependencies [8477be5]
558
+ - Updated dependencies [279fb13]
559
+ - Updated dependencies [2e82ab2]
560
+ - Updated dependencies [ad07b65]
561
+ - Updated dependencies [41f498b]
562
+ - Updated dependencies [ef0d150]
563
+ - Updated dependencies [f34226e]
564
+ - Updated dependencies [564b605]
565
+ - Updated dependencies [e1d4251]
566
+ - Updated dependencies [40d3a33]
567
+ - Updated dependencies [8b9dc62]
568
+ - Updated dependencies [1184192]
569
+ - Updated dependencies [a2a9747]
570
+ - Updated dependencies [65e88e6]
571
+ - Updated dependencies [a1609a6]
572
+ - Updated dependencies [53f23bc]
573
+ - Updated dependencies [c4533dc]
574
+ - Updated dependencies [be60815]
575
+ - Updated dependencies [37f6844]
576
+ - Updated dependencies [93de4f6]
577
+ - Updated dependencies [2b50261]
578
+ - Updated dependencies [384f30d]
579
+ - Updated dependencies [ac600e5]
580
+ - Updated dependencies [97fba31]
581
+ - Updated dependencies [232f61a]
582
+ - Updated dependencies [d374caf]
583
+ - Updated dependencies [5673576]
584
+ - Updated dependencies [c1ef923]
585
+ - Updated dependencies [911ceaa]
586
+ - Updated dependencies [98eab36]
587
+ - Updated dependencies [af5e292]
588
+ - Updated dependencies [3fbbea1]
589
+ - Updated dependencies [0bffb18]
590
+ - Updated dependencies [800f455]
591
+ - Updated dependencies [5458414]
592
+ - Updated dependencies [3241559]
593
+ - Updated dependencies [7f96b10]
594
+ - Updated dependencies [167ec42]
595
+ - Updated dependencies [616a2a5]
596
+ - Updated dependencies [6c68b13]
597
+ - Updated dependencies [0046d8f]
598
+ - Updated dependencies [f1d4748]
599
+ - Updated dependencies [bea374e]
600
+ - Updated dependencies [b1119ec]
601
+ - Updated dependencies [5607092]
602
+ - Updated dependencies [9f23d2b]
603
+ - Updated dependencies [578e025]
604
+ - Updated dependencies [af025ee]
605
+ - Updated dependencies [d109a4d]
606
+ - Updated dependencies [598c89a]
607
+ - Updated dependencies [4a0bd17]
608
+ - Updated dependencies [b8b9af4]
609
+ - Updated dependencies [d8b9259]
610
+ - Updated dependencies [31676be]
611
+ - Updated dependencies [8c0d52e]
612
+ - Updated dependencies [aff10e2]
613
+ - Updated dependencies [70a774b]
614
+ - Updated dependencies [9ce096f]
615
+ - Updated dependencies [e05db88]
616
+ - Updated dependencies [7458a41]
617
+ - Updated dependencies [ad13d63]
618
+ - Updated dependencies [5ffcc14]
619
+ - Updated dependencies [d971e51]
620
+ - Updated dependencies [97abb24]
621
+ - Updated dependencies [deb157a]
622
+ - Updated dependencies [9c60144]
623
+ - Updated dependencies [e7747f1]
624
+ - Updated dependencies [d2ce342]
625
+ - Updated dependencies [9695da7]
626
+ - Updated dependencies [ac2f332]
627
+ - Updated dependencies [a777058]
628
+ - Updated dependencies [75444e3]
629
+ - Updated dependencies [58b8346]
630
+ - Updated dependencies [2d0bd16]
631
+ - Updated dependencies [a9e17b4]
632
+ - Updated dependencies [b8ce7dc]
633
+ - Updated dependencies [dad51e5]
634
+ - Updated dependencies [1c9c342]
635
+ - Updated dependencies [787c738]
636
+ - Updated dependencies [8396656]
637
+ - Updated dependencies [dbbd38a]
638
+ - Updated dependencies [61556dc]
639
+ - Updated dependencies [8871c14]
640
+ - Updated dependencies [93fe362]
641
+ - Updated dependencies [dfc6975]
642
+ - Updated dependencies [3cf4de0]
643
+ - Updated dependencies [c9dc811]
644
+ - Updated dependencies [144ef9b]
645
+ - Updated dependencies [138ab04]
646
+ - Updated dependencies [a0b9e91]
647
+ - Updated dependencies [99bd015]
648
+ - Updated dependencies [21e4585]
649
+ - @object-ui/types@17.6.0
650
+ - @object-ui/fields@17.6.0
651
+ - @object-ui/i18n@17.6.0
652
+ - @object-ui/react@17.6.0
653
+ - @object-ui/components@17.6.0
654
+ - @object-ui/core@17.6.0
655
+ - @object-ui/permissions@17.6.0
656
+
3
657
  ## 17.5.0
4
658
 
5
659
  ### Patch Changes