Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 5d3f437

Browse files
refactor(types,fields,app-shell): field metadata stops declaring host-written and field-level regex keys; geolocation leaves the union (objectui#12242) (#12293)
Part of #12242 Clause-②: yes Round two of the field metadata family close-out. It executes maintainer ruling A on the card (comment 6135661503: Q1 A, Q2 A, Q3 B), from `main` `74964ad6`. The authoring face of a field now describes only what a published object can carry. One position is **held, not executed**: `dueLike`, because the ruling's fork clause fired on it (see "The fork" below). That is why the first line is `Part of`, not `Fixes`: the card stays open for that one position. ## What changed - **`@object-ui/types`** (`field-types.ts`, `form.ts`): - **Q1, moved off the authoring face.** `BaseFieldMetadata.mobile_fullscreen`, `LookupFieldMetadata.idField`, and `UserFieldMetadata.picker` / `subtitle` are no longer declared. Each spot carries a "there is deliberately no X here" note that names where the key lives now. - `FormField` keeps `idField` / `picker` / `subtitle` at the same types. They were declared by reference to the removed members; the entry now declares them itself (`string`, `'search' | 'default'`, `string[]`). The ruling keeps the faces that already typed them. - **Q2, `never` tombstones.** `TextFieldMetadata.pattern` and `pattern_message`, and `BaseFieldMetadata.validate`, are now `never` tombstones. Each docblock names the spec's object-level `format` validation rule, `{ type: 'format', name, field, regex, message }`. `FieldValidationFunction` and `FieldConstraints` are marked `@deprecated` because no member declares them now, the same treatment round one gave `VisibilityCondition`. - **Q3, out of the union.** `GeolocationFieldMetadata` leaves `FieldMetadata`. The interface stays exported, marked `@deprecated`, and the `field:geolocation` widget registration is untouched. - `FieldValidationRules.pattern`'s docblock no longer says `buildValidationRules` compiles a string pattern. - **`@object-ui/fields`**: - `buildValidationRules` no longer reads `pattern` / `pattern_message` / `validate`. A `location` field's rule is now the stored-value check alone. No client pre-check of the object-level rule is added (the ruling did not take B). - New package-local hand-off: `LongTextCarrierHandoff` (`widgets/longTextCarrierHandoff.ts`, not exported from the index, following the `renderHandoff.ts` / `chartRenderHandoff.ts` pattern). `TextAreaField` and `RichTextField` read `mobile_fullscreen` through it, intersected into their existing declared casts. Its producer is still `ObjectForm`, unchanged. - `LookupField` reads `idField` / `picker` / `subtitle` through an annotated alias, typed as `Pick` of `FormField` over those three keys. `FormField` is the existing face that declares them. The reads were untyped (`any`) before; behaviour is unchanged. - **`@object-ui/app-shell`**, `FlowReferenceField` (the flow designer's record-lookup cell). It builds `LookupField`'s carrier with `idField` and the people-picker keys, taken from its own package-local `RecordLookupBinding`. The literal was typed as `LookupFieldMetadata` through the widget prop, so removing `idField` turned `app-shell` `type-check` red (TS2353). The literal is now typed as `LookupFieldMetadata` intersected with `Pick` of `FormField` over the three keys. This is the producer side of the hand-off, fixed on the producer. - **`@object-ui/plugin-grid`** (`relationalMetaKeys.ts`): the `widget-contract` verdict's docblock and the `idField` row's note now name `FormField` instead of `LookupFieldMetadata`. This is internal data, not exported, with no behaviour change. The derivation test's alias control now expects `LookupField`'s second alias (`pickerConfig`). - **Docs**: - `text.mdx` teaches the object-level `format` rule, written as a prose list (see Acceptance notes). - `lookup.mdx` and `user.mdx` drop the moved keys from their field metadata snippets and say where `idField` / `picker` / `subtitle` live now. `user.mdx` shows them on a typed `FormField`. - `blocks/forms.mdx`, `plugins/plugin-form.mdx` and the plugin-form README no longer say a field-level string pattern is compiled. ## The fork: `dueLike` is held The ruling's premise for Q1 is that the spec refuses each key and no published object can carry it. For `dueLike` that is no longer true on `@objectstack/spec`'s own `main`: - objectstack-ai/objectstack#22503 (merged 2026-10-09, commit `215e66204`, pending changeset `22227-field-deadline-due-like.md`) declares `dueLike` and `settledWhen` on `FieldSchema`, for `date` / `datetime` only. Its changeset names objectui's date cells as the reader. - The spec installed here (17.7.0) still refuses `dueLike` (`unrecognized_keys`), so the adoption arrives with the next spec release. - Three example apps in objectstack write `dueLike: true` on published object definitions: `app-crm`'s `activity`, `app-showcase`'s `task` and `app-todo`'s `task`. - Its readers take it off the object's field definition: `resolveDueLike` in `@object-ui/fields`, which the date and datetime cells call, and the record-detail enrichment copy. That is an authored key, which a runtime hand-off type cannot honestly carry. So `dueLike` is a reader no hand-off type can carry, and the key is moving toward the authoring face, not away from it. Per the fork clause, this run reports it and does not decide it. `DateFieldMetadata.dueLike`, `DateTimeFieldMetadata.dueLike`, `date.mdx`, `datetime.mdx`, `DetailViewFieldSchema.dueLike` and `schema-reference.md` are all unchanged. No silent C. ## Census of the kept rows (starting from PR objectui#12257's table) | Interface | Member / value | Installed `FieldSchema` (17.7.0) | Spec `main` | Non-test readers and producers | This PR | |---|---|---|---|---|---| | Base | `mobile_fullscreen` | `unrecognized_keys` | not declared | produced by `ObjectForm`; read by `TextAreaField`, `RichTextField`, and the form renderer's built-in `textarea` branch (through its own props type) | moved to `LongTextCarrierHandoff` | | Base | `validate` | `unrecognized_keys` | not declared | `buildValidationRules` | `never`; read removed | | Text | `pattern`, `pattern_message` | `unrecognized_keys` | object-level `format` rule only | `buildValidationRules` | `never`; reads removed | | Date, DateTime | `dueLike` | `unrecognized_keys` | **declared** (objectstack#22503) | `resolveDueLike` (date and datetime cells), `DetailViewFieldSchema.dueLike`, the detail enrichment copy | **held** (fork) | | Lookup | `idField` | `unrecognized_keys` | not on `FieldSchema` (only on `InlineGridColumnSchema`) | `LookupField`; produced by `FormField` entries, action parameters, `FlowReferenceField` and `GridField` inline columns | moved; `FormField` declares it | | User | `picker`, `subtitle` | `unrecognized_keys` | not declared | `LookupField`, and `UserField` (defaults); produced by `FormField` entries and `FlowReferenceField` | moved; `FormField` declares them | | Geolocation | type `geolocation` | `invalid_value` at `type` | not a `FieldType` | the `field:geolocation` widget | out of the union | `field-metadata-family-retired-12242.test.ts` re-derives the installed-spec column on every run, each row behind an accepting control. The other columns are a reading taken once. ### Fork-clause measurements - **`type: 'geolocation'` producers.** The query was the regex `type['"]?\s*:\s*['"]geolocation['"]`, excluding tests, specs, `content/**`, `docs/**`, markdown and changelogs: - objectui `74964ad6`: 1 hit, `GeolocationFieldMetadata`'s own `type` member. The control, the same query for `text`: 221 files. - objectstack `origin/main` `c9b5b24b0`: 0 hits; control: 80 files. - hotcrm `0f84c9c`: 0 hits; control: 16 files. - **Writers of the other Q1 keys** in objectstack (`packages/**`, `examples/**`, `apps/**`) and hotcrm. Every hit was read: - `mobile_fullscreen`: 0 in both. - `picker` / `subtitle`: none on a field definition. The hits are related-list `add.picker`, page-header `subtitle` and chart `subtitle`. - `idField`: only `InlineGridColumnSchema` and unrelated identifiers. - `dueLike`: the fork above. ## Export closure (Clause-②) Measured on the built `.d.ts`: base `74964ad6` from a scratch worktree, head from this branch. The comparison is per top-level declaration, as a token multiset with comments excluded, so the type printer's union-member order cannot read as a change. `@deprecated` is compared separately. - **Export names:** identical for every `exports` entry of `@object-ui/types`, `@object-ui/fields` and `@object-ui/plugin-grid`. No name is added or removed. - **`@object-ui/types` declarations changed:** - `BaseFieldMetadata`, `TextFieldMetadata`, `LookupFieldMetadata` and `UserFieldMetadata` (the members above). - `FieldMetadata` (one union member fewer). - `FormField`: the same three member types, now spelled out. `form-field-channel-keys-12130.test.ts` pins them `Equal` to `string` / `'search' | 'default'` / `string[]`, each with `undefined`. - `@deprecated` added on `FieldValidationFunction`, `FieldConstraints` and `GeolocationFieldMetadata`. - **`@object-ui/fields`:** one added file, `widgets/longTextCarrierHandoff.d.ts`. No emitted declaration file references it, and no export entry reaches it. Every other declaration is unchanged. - **`@object-ui/plugin-grid`:** no declaration changed. ## Tests and gates (at `31da2084`, from the repo root; heavy runs under the shared verify lock) - `type-check`, with the script name echoed and after the dependency-closure build (turbo, 28 of 28 tasks), exit 0 for each of `@object-ui/types`, `fields`, `core`, `components`, `plugin-form`, `plugin-detail`, `plugin-grid` and `app-shell`. `app-shell` was red once (the `FlowReferenceField` literal above) and green after that fix. `types` was re-run at `31da2084`: exit 0. - `vitest run`: - `packages/types/`, plus the two new `fields` pins: 375 files, 10239 tests passed (at `31da2084`). - `packages/fields/`: 252 files passed (1 skipped), 4031 tests passed (at `bf418ddc`; no `fields` source changed after it). - A targeted set of 28 files, 354 tests passed (at `bf418ddc`): `plugin-grid` `relationalMetaCopySet.derivation` and `lookupPickerKeys-7154`; the `plugin-form` location suites and `ObjectForm.mobileFullscreen`; `app-shell` `FlowReferenceField*` and `paramToField`; the `components` fullscreen-textarea suites; `schema-catalog` `form-field-channel-census-12130`, `form-field-channel-effects-12130` and `form-builtin-branch-dom-allowlist-12155`; `plugin-detail` `fieldEnrichment`. - `scripts/__tests__/` (at `bf418ddc`): 186 files passed, 1 red: `check-doc-component-types`, "this repository is green", on this branch's first `text.mdx`. That page now teaches the rule in prose, and the re-run of that file is green (at `155bd4e1`, with the `types` suite). - **Gates, each exit 0 at `31da2084`, with its own verdict line:** - `check:doc-types`: "Every documented component type is registered". - `check:doc-snippets`: "Every covered documentation snippet compiles against the built types" (after the scoped build it names, 37 of 37 tasks). - `check:doc-examples`: "Every covered `@example` compiles, or fails exactly as its ledger row declares". - `check:doc-json`, `check:doc-fences`, `check:doc-example-ids`, and `check-doc-links` ("Links are valid across 17 scan roots"). - `check:readme-exports`: OK, 0 fabricated. - `check:spec-symbols`, `check:self-import`, `check:phantom-deps`, `check:unreferenced-sources`, `check:handler-key-reads`, `check:esm-specifiers` (specifier leg), and `check:designer-field-key-parity` ("OK"). - `check:new-line-citations`: "0 new citation(s)". `check:control-bytes`: OK. - `check:changeset-claims`: report-only. It names round one's types changeset, whose paragraph about the family pin stays true: this PR adds rows and changes none. - `check:pending-changeset-literals`. - `check-changeset-presence`: "20 source file(s) of 5 released package(s) changed, and this change declares 2 changeset(s)". Also `check-changeset-no-major` and `check-changeset-fixed`. - `check:eager-closure`: 3131.3 KB of 3204.6 KB. - `check-governed-queue-guard --test` over the 28 changed paths: NOT GOVERNED. - **Targeted eslint** on the 19 changed `.ts` / `.tsx` files: 0 errors and 233 warnings. The 16 files that exist at base carry the identical per-file counts there, and the 3 new files carry 0 of each. - Population: `eslint.config.js`'s TS block (`**/*.{ts,tsx}`). - The count is read from `--format json`. - Invariance: the config enables no type-aware linting (no `project` / `projectService`), and no rule in `eslint-rules/` reads another file. So this diff cannot move any untouched file's verdict. ## Ablations (each from committed state, through objectstack's `ablation-replace.mjs`, wrap mode, under the lock) Each mutation hit its anchor, and the blob changed. Each restore proved blob equal to `HEAD` and an empty `git diff HEAD`. - **A1**, `pattern?: never` to `string | RegExp`: `tsc -p tsconfig.test.json` red, TS2344 on the tombstone assertion and TS2578 (unused `@ts-expect-error`) on `_pattern`. - **A2**, `idField?: string` put back on `LookupFieldMetadata`: red, TS2578 on `_idField` and TS2344 on the moved-off assertion. - **A3**, `GeolocationFieldMetadata` put back in the union: red, TS2578 on `_geolocation`. - **A4**, `buildValidationRules`' field-level `pattern` read put back: 2 rows of the new `fields` pin red ("expected { pattern: … } to be undefined", "expected [ 'maxLength', 'pattern' ] to deeply equal [ 'maxLength' ]"). - **A5**, the field-level `validate` read put back: 1 row red ("expected { validate: [Function validate] } to be undefined"). The `location` control row stayed green, as expected: that branch replaces `rules.validate` wholesale. The first attempt at A2 to A5 was a no-op: each replacement contained its own anchor, so the tool refused it, and the command never ran. Those readings were discarded and re-run with replacements that consume the anchor. ## Changesets - `12242-types-kept-positions.md`: `@object-ui/types` minor, BREAKING, FROM → TO for every member and the union. Patch round 1 corrects the pending objectui#12130 changeset's bullet about these keys in place. - `12242-fields-validation-rules.md`: `@object-ui/fields` minor, BREAKING for hosts that hand-write `pattern` / `validate` in code. Those hosts lose the client-side check; the server enforces the object's `format` rule on save. - `plugin-grid` (internal note text) and `app-shell` (the same carrier object, now built in a typed const before the render branch) carry no behaviour change and get no release entry; the presence gate counts their files under these two declarations. ## Acceptance notes (observed, not filed) - `text.mdx` teaches the `format` rule as a prose list, not a code fence. `check:doc-types` reads every fenced `type` value as a node type. Its per-file ledger (where round one exempted `object.mdx`'s `json_schema`) is outside this claim's file surface, and prose is outside that gate's question. - `ENRICHED_FIELD_METADATA_KEYS` (`plugin-detail`) still copies `idField`, `picker`, `subtitle` and `dueLike` off the served object definition. The installed spec refuses all four on a field, so for spec-compliant data those copies reach nothing. The ruling says behaviour is unchanged, so they stay. Carrier: none. - No in-repo path emits the form renderer's `validation.pattern` default message any more; `buildValidationRules` was its only producer. `check:i18n-dead-keys` (report-only, exit 0) already listed the pack key as needs-review before this change. Carrier: none. - `buildValidationRules` still reads `required_message`, `min_length_message`, `max_length_message`, `min_message` and `max_message`, which no type declares. The spec's answer on them was not measured here. Carrier: none. - `GeolocationFieldMetadata` stays exported, marked `@deprecated`, rather than removed. That follows the `VisibilityCondition` precedent, and the barrel (`index.ts`) is outside this claim's file surface. Removing it would be a separate decision. --- _Generated by [Claude Code](https://claude.ai/code/session_015YrgVw7e65cs1ndbebt46G)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d313eb commit 5d3f437

29 files changed

Lines changed: 646 additions & 283 deletions

‎.changeset/12130-form-field-channel-keys.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A form field declares the rest of the field metadata its widget reads off the `f
77
A hand-authored `form` hands each field widget the `fields[]` entry itself as its metadata carrier. objectui#11070 graded that channel from a source read and missed keys on it. This change measures it at runtime through the real `SchemaRenderer`: a census records every key of the entry a reader asks for by name, and each key it found read and undeclared is rendered with and without, to show the widget acting on it. The census is a test (`form-field-channel-census-12130.test.tsx` in `examples/schema-catalog/test/`), so a key a widget starts reading without a declaration turns it red.
88

99
- **Declared, by reference to `@objectstack/spec`'s `FieldSchema`:** `language` (the `code` widget), `maxSize` (`file`, `image`), and the lookup picker's `allowCreate`, `descriptionField`, `displayField`, `lookupColumns`, `lookupFilters` and `lookupPageSize` (`lookup`, `master_detail`, and for `displayField` / `lookupFilters` the `user` widget too). Each is the spec member itself, value checks included.
10-
- **Declared, by reference to the field metadata types:** `idField` (`LookupFieldMetadata`), `picker` and `subtitle` (`UserFieldMetadata`), read by the `lookup` and `user` widgets.
10+
- **Declared on the `FormField` entry itself:** `idField?: string`, `picker?: 'search' | 'default'` and `subtitle?: string[]`, read by the `lookup` and `user` widgets.
1111
- **Declared as the widget reads them, since no type declared them:** `capture` (`'environment' | 'user' | false`, the `file` widget's camera input), `crop` (`boolean`, `false` turns off the `image` widget's cropping) and `defaultName` (`string`, the name an empty `avatar` draws its initials from).
1212
- **TypeScript.** `FormField` gains each member, typed as above. Each used to fall to the index signature's `any`, so a typed literal with a value of the wrong type is now a compile error.
1313
- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): each key used to be refused with `unrecognized_keys`, and now parses. NARROWS on the tolerant face (`safeValidateSchema`), where each key used to be stripped unjudged: a value of the wrong type (a `maxSize` that is not a positive whole number, a `lookupFilters` operator the spec does not list, a `picker` other than `search` / `default`) is now refused, and the values are now kept. The face is flat: each key is accepted on every field type, and only the widgets named above read it.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/fields': minor
3+
---
4+
5+
`buildValidationRules` stops compiling a field-level `pattern` / `pattern_message` and `validate` (objectui#12242, maintainer ruling A). `@objectstack/spec`'s `FieldSchema` refuses all three keys by name, so no published object carries them, and `@object-ui/types` retires them as `never` tombstones.
6+
7+
- FROM: a field metadata object carrying `pattern` (a string or a `RegExp`) produced a `pattern` rule, with `pattern_message` as its message, and one carrying `validate` produced a `validate` rule (on a `location` field, beside the stored-value check).
8+
- TO: neither is read. A `location` field's rule is the stored-value check alone. An `email` or `url` field still gets its own format rule, and `minLength` / `maxLength` / `min` / `max` / `required` are unchanged.
9+
10+
A regex check on an object is its `format` validation rule, `{ type: 'format', name, field, regex, message }`, which the server runs on every write that touches the field and refuses with the rule's `message`. No client pre-check of that rule is added.
11+
12+
`mobile_fullscreen` is typed for `TextAreaField` and `RichTextField` on a package-local hand-off, since the field metadata types no longer declare it, and `LookupField` reads `idField` / `picker` / `subtitle` through `FormField`'s declaration of them. Both are type-only: the widgets read the same keys as before.
13+
14+
## ⚠️ BREAKING, priced as minor under the fixed group's version policy
15+
16+
A host that hand-writes field metadata in code with `pattern` or `validate` loses that client-side check on the form: the value is submitted, and only a `format` rule on the object refuses it, on the server. Declare the check on the object. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
The field metadata types stop declaring the keys a host writes at runtime and the field-level regex keys, and `geolocation` leaves the `FieldMetadata` union (objectui#12242, second round, maintainer ruling A). `@objectstack/spec`'s `FieldSchema` refuses every one of these keys and the type value by name, so no published object could carry them: the authoring face now describes only what a published object can carry.
6+
7+
Moved off the authoring face (a host writes them; behaviour is unchanged):
8+
9+
- `BaseFieldMetadata`: FROM `mobile_fullscreen?: boolean` → TO not declared. It is the per-field projection of the form-level `ObjectFormSchema.mobile.fullscreenLongText` that `ObjectForm` stamps on a long-text field's carrier. `@object-ui/fields` types it on a package-local hand-off, where its readers are.
10+
- `LookupFieldMetadata`: FROM `idField?: string` → TO not declared.
11+
- `UserFieldMetadata`: FROM `picker?: 'search' | 'default'` and `subtitle?: string[]` → TO not declared.
12+
- `FormField` keeps all three picker keys with the same types: FROM `idField?: LookupFieldMetadata['idField']`, `picker?: UserFieldMetadata['picker']`, `subtitle?: UserFieldMetadata['subtitle']` → TO `idField?: string`, `picker?: 'search' | 'default'`, `subtitle?: string[]`, declared on the entry itself.
13+
14+
Retired as `?: never` tombstones (the spec's regex check is the object's `format` validation rule, `{ type: 'format', name, field, regex, message }`, which the server runs on save):
15+
16+
- `TextFieldMetadata`: FROM `pattern?: string | RegExp` and `pattern_message?: string` → TO refused.
17+
- `BaseFieldMetadata`: FROM `validate?: FieldValidationFunction | FieldConstraints` → TO refused. `FieldValidationFunction` and `FieldConstraints` stay, marked `@deprecated`: no member declares them now.
18+
19+
Type value:
20+
21+
- `FieldMetadata`: FROM a union including `GeolocationFieldMetadata` (`type: 'geolocation'`) → TO a union without it. `geolocation` is not a spec `FieldType`; the spec's map-point type is `location`. `GeolocationFieldMetadata` is still exported, marked `@deprecated`, and the `field:geolocation` widget stays registered.
22+
23+
Not changed: `DateFieldMetadata.dueLike` and `DateTimeFieldMetadata.dueLike`. `@objectstack/spec`'s own source has since declared `dueLike` on `FieldSchema`, ahead of the spec version installed here.
24+
25+
The `text`, `lookup` and `user` field docs pages, the form blocks page and the form plugin page say where each key lives now.
26+
27+
## ⚠️ BREAKING, priced as minor under the fixed group's version policy
28+
29+
TypeScript that writes one of these keys on an annotated field metadata literal, or assigns a `type: 'geolocation'` literal to `FieldMetadata`, no longer compiles. Move `idField` / `picker` / `subtitle` to the form entry (`FormField`) or the action parameter that builds the picker. Declare a regex check on the object as a `format` validation rule. Use `location` for a map point. Drop `mobile_fullscreen`: `ObjectForm` stamps it from `mobile.fullscreenLongText`. `field-metadata-family-retired-12242.test.ts` pins each key and the type value, beside the installed spec's own answer. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)
30+
31+
**Clause-②: yes**: the authoring face loses four members, turns three into `never` tombstones and drops one union member; `FormField` keeps its three picker keys at the same types.

‎content/docs/blocks/forms.mdx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ is decided by `required` on the field itself:
8585

8686
`pattern` is the one rule that route cannot take from JSON: react-hook-form runs
8787
it only when the rule's `value` is a compiled `RegExp`, and no JSON document can
88-
hold one. Declare the pattern on the object field's metadata instead (`pattern`,
89-
a string, which `@object-ui/fields` compiles before the rule reaches the form),
90-
or in a TypeScript-authored schema pass the real thing —
91-
`pattern: { value: /.../, message: '...' }`. The full rule table is in the
92-
[Form Plugin](/docs/plugins/plugin-form) reference.
88+
hold one. In a TypeScript-authored schema pass the real thing —
89+
`pattern: { value: /.../, message: '...' }`. For a form bound to an object,
90+
declare the check on the object as a `format` validation rule
91+
(see [Text Field](/docs/fields/text#format-validation)): the server runs it when
92+
the record is saved, and an object field has no `pattern` key of its own. The
93+
full rule table is in the [Form Plugin](/docs/plugins/plugin-form) reference.
9394

9495
### Add Submit Action
9596

‎content/docs/fields/lookup.mdx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ The Lookup Field component provides a reference field for creating relationships
1717

1818
A lookup field is authored as `LookupFieldMetadata` (`@object-ui/types`), which is the
1919
source of truth for the key set: it extends `BaseFieldMetadata` with the reference
20-
target, the display and id fields, an optional static option list, and the Record
21-
Picker's column and paging configuration. Static options are `SelectOptionMetadata`,
20+
target, the display and description fields, an optional static option list, and the
21+
Record Picker's column and paging configuration. Static options are `SelectOptionMetadata`,
2222
and picker columns are `LookupColumnDef` — both exported, both checked here.
2323

2424
```ts
@@ -33,7 +33,6 @@ const accountId: LookupFieldMetadata = {
3333
reference: 'accounts',
3434
reference_field: 'name',
3535
descriptionField: 'industry',
36-
idField: '_id',
3736
multiple: false,
3837
searchable: true,
3938
allowCreate: true,
@@ -62,6 +61,14 @@ definition that still spells one is folded onto the declared key when it is serv
6261
through the ObjectStack adapter or the console's metadata store; a definition handed
6362
to the widget any other way loses the setting — rename the key.
6463

64+
The record field a pick commits as the value, `id` unless told otherwise, is picker
65+
configuration rather than object metadata. `FieldSchema` refuses an `idField` by
66+
name, so a lookup field definition cannot carry it, and `LookupFieldMetadata` does
67+
not declare it. A host sets it where it builds the picker: on a form entry
68+
(`FormField.idField`), on an action parameter, or on a `master_detail` inline grid
69+
column. A reference stored by machine name, such as an approval `position` stored as
70+
`sys_position.name`, sets it to `name`.
71+
6572
When no data source is available the field falls back to a static option list:
6673

6774
```ts

‎content/docs/fields/text.mdx‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,30 @@ const productName: TextFieldMetadata = {
4242
required: true,
4343
minLength: 2,
4444
maxLength: 120,
45-
pattern: '^[A-Za-z0-9 -]+$',
46-
pattern_message: 'Letters, digits, spaces and hyphens only.',
4745
};
4846
```
4947

48+
## Format Validation
49+
50+
A regex check is declared on the **object**, not on the field. `@objectstack/spec`
51+
has a validation rule for it: an entry of the object definition's `validations[]`
52+
whose `type` is `'format'`. For the field above it carries:
53+
54+
- `name`: the rule's own snake_case name, e.g. `'product_name_characters'`;
55+
- `field`: the field it checks, `'product_name'`;
56+
- `regex`: `'^[A-Za-z0-9 -]+$'`. It is not anchored for you, so write `^` and `$`
57+
to match the whole value;
58+
- `message`: `'Letters, digits, spaces and hyphens only.'`, the reason given when
59+
the value is refused.
60+
61+
The server runs the rule on every write that touches the field and refuses a
62+
non-empty value the `regex` does not match, with the rule's `message`. `FieldSchema`
63+
has no field-level `pattern` or `pattern_message`: it refuses both by name, and
64+
`TextFieldMetadata` refuses them at compile time.
65+
66+
The form does not pre-check this rule in the browser. A value that breaks it is
67+
refused when the record is saved, and the rule's `message` is the reason given.
68+
5069
The value being edited, and the `className` / `disabled` a host supplies, are **not**
5170
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
5271

‎content/docs/fields/user.mdx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ it display-only.
2424
## Field Schema
2525

2626
A user field is authored as `UserFieldMetadata` (`@object-ui/types`), which is the
27-
source of truth for the key set: it extends `BaseFieldMetadata` with the picker style,
28-
the subtitle fields, the avatar field and the candidate filters. `user` is a lookup
29-
specialised to the `sys_user` system object, so its filters are `LookupFilterDef`.
27+
source of truth for the key set: it extends `BaseFieldMetadata` with the avatar field
28+
and the candidate filters. `user` is a lookup specialised to the `sys_user` system
29+
object, so its filters are `LookupFilterDef`.
3030

3131
```ts
3232
import type { UserFieldMetadata } from '@object-ui/types';
@@ -37,8 +37,6 @@ const owner: UserFieldMetadata = {
3737
label: 'Owner',
3838
required: true,
3939
multiple: false,
40-
picker: 'search',
41-
subtitle: ['primary_business_unit_id.name', 'email'],
4240
avatar_field: 'image',
4341
lookupFilters: [{ field: 'banned', operator: 'ne', value: true }],
4442
};
@@ -47,6 +45,24 @@ const owner: UserFieldMetadata = {
4745
The field stores the selected user's id (or an array of ids when `multiple` is set),
4846
not a user object; the picker resolves names and avatars from `sys_user` itself.
4947

48+
The picker is the search-first people picker, with each candidate's business unit and
49+
email beside its name. Both are picker configuration rather than object metadata:
50+
`FieldSchema` refuses `picker` and `subtitle` by name, so a user field definition
51+
cannot carry them, and `UserFieldMetadata` does not declare them. A form entry may set
52+
them (`FormField`); `picker: 'default'` opens the classic record-picker dialog instead:
53+
54+
```ts
55+
import type { FormField } from '@object-ui/types';
56+
57+
const reviewer: FormField = {
58+
name: 'reviewer_id',
59+
type: 'user',
60+
label: 'Reviewer',
61+
picker: 'search',
62+
subtitle: ['primary_business_unit_id.name', 'email'],
63+
};
64+
```
65+
5066
The value being edited, and the `className` / `disabled` a host supplies, are **not**
5167
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
5268

‎content/docs/plugins/plugin-form.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ array of `{ type, value, message }` entries:
127127
| `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** |
128128
| `minLength` / `maxLength` | `{ value: number; message: string }` | text length |
129129
| `min` / `max` | `{ value: number; message: string }` | numeric range |
130-
| `pattern` | `{ value: RegExp; message: string }` | hand-authored schemas must pass a **RegExp**: react-hook-form applies a pattern only when its value is `instanceof RegExp`. It is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one, so a `pattern` string written straight into a plain `form` schema never runs |
130+
| `pattern` | `{ value: RegExp; message: string }` | hand-authored schemas must pass a **RegExp**: react-hook-form applies a pattern only when its value is `instanceof RegExp`, and nothing compiles a string into one, so a `pattern` string written straight into a plain `form` schema never runs. An object field has no `pattern` key: a regex check on an object is its `format` validation rule, which the server runs on save |
131131
| `validate` | `(value) => boolean \| string \| Promise<boolean \| string>` | custom check; TypeScript-authored schemas only |
132132

133133
There is no `email` rule name — an email check is a `pattern`, which is exactly

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx‎

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation';
5151
import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS } from '@objectstack/spec/identity';
5252
import { useAdapter } from '@object-ui/react';
5353
import { LookupField } from '@object-ui/fields';
54+
import type { FormField, LookupFieldMetadata } from '@object-ui/types';
5455
import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js';
5556
import { useMetadataClient } from '../useMetadata.js';
5657
import { useObjectFields } from '../previews/useObjectFields.js';
@@ -569,6 +570,28 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled,
569570
const adapter = useAdapter();
570571
const [manual, setManual] = React.useState(false);
571572
const current = value != null ? String(value) : '';
573+
// The picker's carrier: the lookup's field metadata plus the picker
574+
// configuration this host writes. `idField`, `picker` and `subtitle` are not
575+
// field metadata (`FieldSchema` refuses all three by name, and the field
576+
// metadata types stopped declaring them, objectui#12242); the form entry
577+
// declares them, and `LookupField` reads them through that declaration.
578+
const lookupCarrier: LookupFieldMetadata & Pick<FormField, 'idField' | 'picker' | 'subtitle'> = {
579+
type: 'lookup',
580+
name: 'value',
581+
reference: binding.object,
582+
displayField: binding.displayField,
583+
// `position` commits the machine name, the rest the row id.
584+
idField: binding.valueField,
585+
multiple: false,
586+
// A directory row is never created from a flow-authoring picker.
587+
// The spec's `allowCreate`, the only spelling `LookupField` reads
588+
// (objectui#12157): its explicit `false` outranks the widget's
589+
// default-on quick-create for a user-facing reference.
590+
allowCreate: false,
591+
placeholder,
592+
...(binding.picker ? { picker: binding.picker } : {}),
593+
...(binding.subtitle ? { subtitle: binding.subtitle } : {}),
594+
};
572595

573596
if (!adapter || manual) {
574597
return (
@@ -610,26 +633,7 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled,
610633
onChange={(v: unknown) => onPick(v == null ? '' : String(v))}
611634
readonly={disabled}
612635
dataSource={adapter}
613-
field={{
614-
// `type` is the discriminator that selects LookupFieldMetadata —
615-
// without it the literal is checked against the wrong union member
616-
// and the lookup keys below are rejected.
617-
type: 'lookup',
618-
name: 'value',
619-
reference: binding.object,
620-
displayField: binding.displayField,
621-
// `position` commits the machine name, the rest the row id.
622-
idField: binding.valueField,
623-
multiple: false,
624-
// A directory row is never created from a flow-authoring picker.
625-
// The spec's `allowCreate`, the only spelling `LookupField` reads
626-
// (objectui#12157): its explicit `false` outranks the widget's
627-
// default-on quick-create for a user-facing reference.
628-
allowCreate: false,
629-
placeholder,
630-
...(binding.picker ? { picker: binding.picker } : {}),
631-
...(binding.subtitle ? { subtitle: binding.subtitle } : {}),
632-
}}
636+
field={lookupCarrier}
633637
/>
634638
</div>
635639
<Button

0 commit comments

Comments
 (0)