Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/12130-form-field-channel-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions .changeset/12242-fields-validation-rules.md
Original file line number Diff line number Diff line change
@@ -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.)
31 changes: 31 additions & 0 deletions .changeset/12242-types-kept-positions.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 6 additions & 5 deletions content/docs/blocks/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions content/docs/fields/lookup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +33,6 @@ const accountId: LookupFieldMetadata = {
reference: 'accounts',
reference_field: 'name',
descriptionField: 'industry',
idField: '_id',
multiple: false,
searchable: true,
allowCreate: true,
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions content/docs/fields/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
26 changes: 21 additions & 5 deletions content/docs/fields/user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 }],
};
Expand All @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean \| string>` | custom check; TypeScript-authored schemas only |

There is no `email` rule name — an email check is a `pattern`, which is exactly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<FormField, 'idField' | 'picker' | 'subtitle'> = {
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 (
Expand Down Expand Up @@ -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}
/>
</div>
<Button
Expand Down
Loading
Loading