diff --git a/.changeset/12130-form-field-channel-keys.md b/.changeset/12130-form-field-channel-keys.md index 6a6350ef3c..d6360a3a82 100644 --- a/.changeset/12130-form-field-channel-keys.md +++ b/.changeset/12130-form-field-channel-keys.md @@ -7,7 +7,7 @@ A form field declares the rest of the field metadata its widget reads off the `f 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. - **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. -- **Declared, by reference to the field metadata types:** `idField` (`LookupFieldMetadata`), `picker` and `subtitle` (`UserFieldMetadata`), read by the `lookup` and `user` widgets. +- **Declared on the `FormField` entry itself:** `idField?: string`, `picker?: 'search' | 'default'` and `subtitle?: string[]`, read by the `lookup` and `user` widgets. - **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). - **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. - **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. diff --git a/.changeset/12242-fields-validation-rules.md b/.changeset/12242-fields-validation-rules.md new file mode 100644 index 0000000000..9e67d89897 --- /dev/null +++ b/.changeset/12242-fields-validation-rules.md @@ -0,0 +1,16 @@ +--- +'@object-ui/fields': minor +--- + +`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. + +- 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). +- 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. + +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. + +`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. + +## ⚠️ BREAKING, priced as minor under the fixed group's version policy + +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.) diff --git a/.changeset/12242-types-kept-positions.md b/.changeset/12242-types-kept-positions.md new file mode 100644 index 0000000000..45bcd5c8ec --- /dev/null +++ b/.changeset/12242-types-kept-positions.md @@ -0,0 +1,31 @@ +--- +'@object-ui/types': minor +--- + +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. + +Moved off the authoring face (a host writes them; behaviour is unchanged): + +- `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. +- `LookupFieldMetadata`: FROM `idField?: string` → TO not declared. +- `UserFieldMetadata`: FROM `picker?: 'search' | 'default'` and `subtitle?: string[]` → TO not declared. +- `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. + +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): + +- `TextFieldMetadata`: FROM `pattern?: string | RegExp` and `pattern_message?: string` → TO refused. +- `BaseFieldMetadata`: FROM `validate?: FieldValidationFunction | FieldConstraints` → TO refused. `FieldValidationFunction` and `FieldConstraints` stay, marked `@deprecated`: no member declares them now. + +Type value: + +- `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. + +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. + +The `text`, `lookup` and `user` field docs pages, the form blocks page and the form plugin page say where each key lives now. + +## ⚠️ BREAKING, priced as minor under the fixed group's version policy + +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.) + +**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. diff --git a/content/docs/blocks/forms.mdx b/content/docs/blocks/forms.mdx index d82a1323d0..7c347c09bc 100644 --- a/content/docs/blocks/forms.mdx +++ b/content/docs/blocks/forms.mdx @@ -85,11 +85,12 @@ is decided by `required` on the field itself: `pattern` is the one rule that route cannot take from JSON: react-hook-form runs it only when the rule's `value` is a compiled `RegExp`, and no JSON document can -hold one. Declare the pattern on the object field's metadata instead (`pattern`, -a string, which `@object-ui/fields` compiles before the rule reaches the form), -or in a TypeScript-authored schema pass the real thing — -`pattern: { value: /.../, message: '...' }`. The full rule table is in the -[Form Plugin](/docs/plugins/plugin-form) reference. +hold one. In a TypeScript-authored schema pass the real thing — +`pattern: { value: /.../, message: '...' }`. For a form bound to an object, +declare the check on the object as a `format` validation rule +(see [Text Field](/docs/fields/text#format-validation)): the server runs it when +the record is saved, and an object field has no `pattern` key of its own. The +full rule table is in the [Form Plugin](/docs/plugins/plugin-form) reference. ### Add Submit Action diff --git a/content/docs/fields/lookup.mdx b/content/docs/fields/lookup.mdx index 28fe55b926..fc199b8199 100644 --- a/content/docs/fields/lookup.mdx +++ b/content/docs/fields/lookup.mdx @@ -17,8 +17,8 @@ The Lookup Field component provides a reference field for creating relationships A lookup field is authored as `LookupFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the reference -target, the display and id fields, an optional static option list, and the Record -Picker's column and paging configuration. Static options are `SelectOptionMetadata`, +target, the display and description fields, an optional static option list, and the +Record Picker's column and paging configuration. Static options are `SelectOptionMetadata`, and picker columns are `LookupColumnDef` — both exported, both checked here. ```ts @@ -33,7 +33,6 @@ const accountId: LookupFieldMetadata = { reference: 'accounts', reference_field: 'name', descriptionField: 'industry', - idField: '_id', multiple: false, searchable: true, allowCreate: true, @@ -62,6 +61,14 @@ definition that still spells one is folded onto the declared key when it is serv through the ObjectStack adapter or the console's metadata store; a definition handed to the widget any other way loses the setting — rename the key. +The record field a pick commits as the value, `id` unless told otherwise, is picker +configuration rather than object metadata. `FieldSchema` refuses an `idField` by +name, so a lookup field definition cannot carry it, and `LookupFieldMetadata` does +not declare it. A host sets it where it builds the picker: on a form entry +(`FormField.idField`), on an action parameter, or on a `master_detail` inline grid +column. A reference stored by machine name, such as an approval `position` stored as +`sys_position.name`, sets it to `name`. + When no data source is available the field falls back to a static option list: ```ts diff --git a/content/docs/fields/text.mdx b/content/docs/fields/text.mdx index 861b4d8012..9f80b76338 100644 --- a/content/docs/fields/text.mdx +++ b/content/docs/fields/text.mdx @@ -42,11 +42,30 @@ const productName: TextFieldMetadata = { required: true, minLength: 2, maxLength: 120, - pattern: '^[A-Za-z0-9 -]+$', - pattern_message: 'Letters, digits, spaces and hyphens only.', }; ``` +## Format Validation + +A regex check is declared on the **object**, not on the field. `@objectstack/spec` +has a validation rule for it: an entry of the object definition's `validations[]` +whose `type` is `'format'`. For the field above it carries: + +- `name`: the rule's own snake_case name, e.g. `'product_name_characters'`; +- `field`: the field it checks, `'product_name'`; +- `regex`: `'^[A-Za-z0-9 -]+$'`. It is not anchored for you, so write `^` and `$` + to match the whole value; +- `message`: `'Letters, digits, spaces and hyphens only.'`, the reason given when + the value is refused. + +The server runs the rule on every write that touches the field and refuses a +non-empty value the `regex` does not match, with the rule's `message`. `FieldSchema` +has no field-level `pattern` or `pattern_message`: it refuses both by name, and +`TextFieldMetadata` refuses them at compile time. + +The form does not pre-check this rule in the browser. A value that breaks it is +refused when the record is saved, and the rule's `message` is the reason given. + The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). diff --git a/content/docs/fields/user.mdx b/content/docs/fields/user.mdx index 10c7af0ed9..b737350651 100644 --- a/content/docs/fields/user.mdx +++ b/content/docs/fields/user.mdx @@ -24,9 +24,9 @@ it display-only. ## Field Schema A user field is authored as `UserFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the picker style, -the subtitle fields, the avatar field and the candidate filters. `user` is a lookup -specialised to the `sys_user` system object, so its filters are `LookupFilterDef`. +source of truth for the key set: it extends `BaseFieldMetadata` with the avatar field +and the candidate filters. `user` is a lookup specialised to the `sys_user` system +object, so its filters are `LookupFilterDef`. ```ts import type { UserFieldMetadata } from '@object-ui/types'; @@ -37,8 +37,6 @@ const owner: UserFieldMetadata = { label: 'Owner', required: true, multiple: false, - picker: 'search', - subtitle: ['primary_business_unit_id.name', 'email'], avatar_field: 'image', lookupFilters: [{ field: 'banned', operator: 'ne', value: true }], }; @@ -47,6 +45,24 @@ const owner: UserFieldMetadata = { The field stores the selected user's id (or an array of ids when `multiple` is set), not a user object; the picker resolves names and avatars from `sys_user` itself. +The picker is the search-first people picker, with each candidate's business unit and +email beside its name. Both are picker configuration rather than object metadata: +`FieldSchema` refuses `picker` and `subtitle` by name, so a user field definition +cannot carry them, and `UserFieldMetadata` does not declare them. A form entry may set +them (`FormField`); `picker: 'default'` opens the classic record-picker dialog instead: + +```ts +import type { FormField } from '@object-ui/types'; + +const reviewer: FormField = { + name: 'reviewer_id', + type: 'user', + label: 'Reviewer', + picker: 'search', + subtitle: ['primary_business_unit_id.name', 'email'], +}; +``` + The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index d3e3052db4..dc7325d664 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -127,7 +127,7 @@ array of `{ type, value, message }` entries: | `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** | | `minLength` / `maxLength` | `{ value: number; message: string }` | text length | | `min` / `max` | `{ value: number; message: string }` | numeric range | -| `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 | +| `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 | | `validate` | `(value) => boolean \| string \| Promise` | custom check; TypeScript-authored schemas only | There is no `email` rule name — an email check is a `pattern`, which is exactly diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index 4a3f6ec3d7..e19819ff03 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -51,6 +51,7 @@ import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation'; import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS } from '@objectstack/spec/identity'; import { useAdapter } from '@object-ui/react'; import { LookupField } from '@object-ui/fields'; +import type { FormField, LookupFieldMetadata } from '@object-ui/types'; import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js'; import { useMetadataClient } from '../useMetadata.js'; import { useObjectFields } from '../previews/useObjectFields.js'; @@ -569,6 +570,28 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, const adapter = useAdapter(); const [manual, setManual] = React.useState(false); const current = value != null ? String(value) : ''; + // The picker's carrier: the lookup's field metadata plus the picker + // configuration this host writes. `idField`, `picker` and `subtitle` are not + // field metadata (`FieldSchema` refuses all three by name, and the field + // metadata types stopped declaring them, objectui#12242); the form entry + // declares them, and `LookupField` reads them through that declaration. + const lookupCarrier: LookupFieldMetadata & Pick = { + type: 'lookup', + name: 'value', + reference: binding.object, + displayField: binding.displayField, + // `position` commits the machine name, the rest the row id. + idField: binding.valueField, + multiple: false, + // A directory row is never created from a flow-authoring picker. + // The spec's `allowCreate`, the only spelling `LookupField` reads + // (objectui#12157): its explicit `false` outranks the widget's + // default-on quick-create for a user-facing reference. + allowCreate: false, + placeholder, + ...(binding.picker ? { picker: binding.picker } : {}), + ...(binding.subtitle ? { subtitle: binding.subtitle } : {}), + }; if (!adapter || manual) { return ( @@ -610,26 +633,7 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, onChange={(v: unknown) => onPick(v == null ? '' : String(v))} readonly={disabled} dataSource={adapter} - field={{ - // `type` is the discriminator that selects LookupFieldMetadata — - // without it the literal is checked against the wrong union member - // and the lookup keys below are rejected. - type: 'lookup', - name: 'value', - reference: binding.object, - displayField: binding.displayField, - // `position` commits the machine name, the rest the row id. - idField: binding.valueField, - multiple: false, - // A directory row is never created from a flow-authoring picker. - // The spec's `allowCreate`, the only spelling `LookupField` reads - // (objectui#12157): its explicit `false` outranks the widget's - // default-on quick-create for a user-facing reference. - allowCreate: false, - placeholder, - ...(binding.picker ? { picker: binding.picker } : {}), - ...(binding.subtitle ? { subtitle: binding.subtitle } : {}), - }} + field={lookupCarrier} />