From 9d3fad05c2f35c0755659bab44f099f8262a785b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:28:24 +0000 Subject: [PATCH 1/5] refactor(types,fields): field metadata stops declaring host-written and field-level regex keys Ruling A on objectui#12242, round two. The authoring face of a field describes only what a published object can carry. - mobile_fullscreen, idField, picker and subtitle leave the field metadata types. mobile_fullscreen is typed on LongTextCarrierHandoff in @object-ui/fields; the three picker keys are declared on FormField, which LookupField reads them through. - pattern, pattern_message and validate retire as never tombstones, and buildValidationRules stops reading them. text.mdx teaches the object's format validation rule. - GeolocationFieldMetadata leaves the FieldMetadata union; the field:geolocation widget stays registered. - dueLike is held: @objectstack/spec main declares it on FieldSchema. Claude-Session: https://claude.ai/code/session_015YrgVw7e65cs1ndbebt46G Co-authored-by: Claude --- content/docs/blocks/forms.mdx | 11 +- content/docs/fields/lookup.mdx | 13 +- content/docs/fields/text.mdx | 28 +++- content/docs/fields/user.mdx | 26 ++- content/docs/plugins/plugin-form.mdx | 2 +- packages/fields/src/index.tsx | 36 ++--- packages/fields/src/widgets/LookupField.tsx | 15 +- packages/fields/src/widgets/RichTextField.tsx | 11 +- packages/fields/src/widgets/TextAreaField.tsx | 10 +- .../longTextCarrierHandoff-12242.test.tsx | 78 +++++++++ .../richtext-field-metadata-7083.test.tsx | 8 +- .../src/widgets/longTextCarrierHandoff.ts | 36 +++++ packages/plugin-form/README.md | 2 +- .../ObjectForm.locationStoredRange.test.tsx | 14 +- .../relationalMetaCopySet.derivation.test.ts | 9 +- .../plugin-grid/src/relationalMetaKeys.ts | 10 +- ...ield-metadata-family-retired-12242.test.ts | 145 ++++++++++++++++- .../field-metadata-mobile-fullscreen.test.ts | 97 ----------- .../form-field-channel-keys-12130.test.ts | 13 +- .../__tests__/form-field-zod-coverage.test.ts | 5 +- packages/types/src/field-types.ts | 150 +++++++++++------- packages/types/src/form.ts | 53 ++++--- packages/types/src/zod/form.zod.ts | 7 +- 23 files changed, 520 insertions(+), 259 deletions(-) create mode 100644 packages/fields/src/widgets/__tests__/longTextCarrierHandoff-12242.test.tsx create mode 100644 packages/fields/src/widgets/longTextCarrierHandoff.ts delete mode 100644 packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts 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..b0be239e88 100644 --- a/content/docs/fields/text.mdx +++ b/content/docs/fields/text.mdx @@ -42,11 +42,35 @@ 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, `type: 'format'`, which names the field, carries the +`regex` and the `message` to show. The server runs it on every write that touches +the field and refuses a non-empty value the `regex` does not match, with the rule's +`message`. The `regex` is not anchored for you: write `^` and `$` to match the +whole value. `FieldSchema` has no field-level `pattern` or `pattern_message`: it +refuses both by name, and `TextFieldMetadata` refuses them at compile time. + +```plaintext +// on the object definition +validations: [ + { + type: 'format', + name: 'product_name_characters', + field: 'product_name', + regex: '^[A-Za-z0-9 -]+$', + message: 'Letters, digits, spaces and hyphens only.' + } +] +``` + +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/fields/src/index.tsx b/packages/fields/src/index.tsx index b6006f4a41..656e1562ad 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -4077,6 +4077,14 @@ function buildLocationStoredValueValidator(field: any): (value: unknown) => true * Build validation rules from field metadata * @param field - Field metadata from ObjectStack * @returns Validation rule object compatible with react-hook-form + * + * ⛔ No field-level `pattern` / `pattern_message` / `validate` (objectui#12242, + * maintainer ruling A). `@objectstack/spec`'s `FieldSchema` refuses all three + * by name, so no published object carries them, and the field metadata types + * retire them as `never`. A regex check is the OBJECT's `format` validation + * rule (`validations[]`), which the server runs on every write that touches + * the field and refuses with the rule's message; this function adds no client + * pre-check of it. */ export function buildValidationRules(field: any): any { const rules: any = {}; @@ -4138,15 +4146,6 @@ export function buildValidationRules(field: any): any { }; } - // Pattern validation - if (field.pattern) { - rules.pattern = { - value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern, - message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined, - messageKey: 'validation.pattern', - }; - } - // Email validation if (field.type === 'email') { rules.pattern = { @@ -4165,27 +4164,18 @@ export function buildValidationRules(field: any): any { }; } - // Custom validation function - if (field.validate) { - rules.validate = field.validate; - } - // Stored-value validation for `location` (objectui#6744). See // `buildLocationStoredValueValidator` for what this rule is and what it is // deliberately not. // - // Emitted in react-hook-form's OBJECT form so a field-authored `validate` - // keeps running under its own key instead of being replaced — the same - // normalisation the form renderer applies when it adds its `required` entry - // (`packages/components/src/renderers/form/form.tsx`), spelled the same way + // Emitted in react-hook-form's OBJECT form, the shape the form renderer + // composes its own `required` entry into (`packages/components/src/renderers/form/form.tsx`), // so the two compose rather than clobber. RHF reports an object-form failure - // under its key, so this surfaces as `type: 'location'`. + // under its key, so this surfaces as `type: 'location'`. (It also used to + // keep a field-level `validate` running beside it; that key is retired, + // objectui#12242.) if (field.type === 'location') { - const authoredValidate = rules.validate; rules.validate = { - ...(typeof authoredValidate === 'function' - ? { validate: authoredValidate } - : (authoredValidate ?? {})), location: buildLocationStoredValueValidator(field), }; } diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 0b62c1a146..ba12217291 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -12,7 +12,7 @@ import { cn, import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; -import type { DataSource, QueryParams, LookupColumnDef, LookupFieldMetadata } from '@object-ui/types'; +import type { DataSource, FormField, QueryParams, LookupColumnDef, LookupFieldMetadata } from '@object-ui/types'; import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog.js'; import type { RecordPickerFilterColumn } from './RecordPickerDialog.js'; import { PeoplePicker } from './PeoplePicker.js'; @@ -308,6 +308,13 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro const fieldMeta = (innerField && typeof innerField === 'object' && ('reference' in innerField || 'type' in innerField)) ? innerField : lookupField; + // `idField`, `picker` and `subtitle` are picker configuration a HOST writes + // onto this carrier (a form entry, an action parameter, a flow binding, a + // `master_detail` inline grid column, `UserField`'s own defaults), never + // object metadata: `FieldSchema` refuses all three by name, so the field + // metadata types stopped declaring them (objectui#12242). They are read + // through the face that does declare them, the form entry's. + const pickerConfig: Pick | undefined = fieldMeta; const staticOptions: LookupOption[] = fieldMeta?.options || []; const multiple = fieldMeta?.multiple || false; @@ -318,7 +325,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro fieldMeta?.displayField || fieldMeta?.reference_field || undefined; const displayField = declaredDisplayField || DEFAULT_DISPLAY_FIELD; const descriptionField: string | undefined = fieldMeta?.descriptionField; - const idField = fieldMeta?.idField || 'id'; + const idField = pickerConfig?.idField || 'id'; // `reference` — `@objectstack/spec`'s `FieldSchema.reference` — is the only // target spelling read (objectui#11070 round 4). A served def that still // spells a legacy key was folded onto it at ingestion @@ -352,8 +359,8 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // Search-first PeoplePicker opt-in (user fields). When `picker === 'search'` // the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray) // instead of the classic table dialog. `subtitle`/`avatar_field` drive the rows. - const pickerVariant: string | undefined = fieldMeta?.picker; - const subtitleFields: string[] | undefined = fieldMeta?.subtitle; + const pickerVariant: string | undefined = pickerConfig?.picker; + const subtitleFields: string[] | undefined = pickerConfig?.subtitle; const avatarField: string = fieldMeta?.avatar_field ?? fieldMeta?.avatarField ?? 'image'; /** diff --git a/packages/fields/src/widgets/RichTextField.tsx b/packages/fields/src/widgets/RichTextField.tsx index 5d38fdd660..1167247d39 100644 --- a/packages/fields/src/widgets/RichTextField.tsx +++ b/packages/fields/src/widgets/RichTextField.tsx @@ -4,6 +4,7 @@ import { cn, Textarea, EmptyValue, CharacterCount, type FullscreenEditorAria } f import { useObjectTranslation } from '@object-ui/react'; import { FullscreenFieldEditor } from './FullscreenFieldEditor.js'; import { FieldWidgetComponentProps } from './types.js'; +import type { LongTextCarrierHandoff } from './longTextCarrierHandoff.js'; import { toDomProps, type FieldDomProps } from './toDomProps.js'; import { richTextCellRenderer } from './richTextDisplay.js'; @@ -342,11 +343,13 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop // what retired the deliberate `as unknown as MarkdownFieldMetadata` its pin // test needed for as long as the union had no branch to write it against. // The cast below names two of the three because it does not have to - // discriminate: every key this widget consumes (`rows`, `maxLength`, - // `mobile_fullscreen`, `placeholder`, `label`) is DECLARED on all three, so - // the two named already admit every read below — `rows` since the objectui#6140 Option A ruling + // discriminate: every authored key this widget consumes (`rows`, + // `maxLength`, `placeholder`, `label`) is DECLARED on all three, so the two + // named already admit every read below — `rows` since the objectui#6140 Option A ruling // (which is what retired the `as any` that used to launder this carrier). - const richField = field as MarkdownFieldMetadata | HtmlFieldMetadata; + // `mobile_fullscreen` is the one host-written key, typed on + // `LongTextCarrierHandoff` since objectui#12242. + const richField = field as (MarkdownFieldMetadata | HtmlFieldMetadata) & LongTextCarrierHandoff; const rows = richField?.rows || 8; /** * The authored ceiling — the spec's `maxLength`, the same single read diff --git a/packages/fields/src/widgets/TextAreaField.tsx b/packages/fields/src/widgets/TextAreaField.tsx index a141e59488..8e2df654ac 100644 --- a/packages/fields/src/widgets/TextAreaField.tsx +++ b/packages/fields/src/widgets/TextAreaField.tsx @@ -3,6 +3,7 @@ import type { TextareaFieldMetadata } from '@object-ui/types'; import { Textarea, EmptyValue, CharacterCount } from '@object-ui/components'; import { FullscreenFieldEditor } from './FullscreenFieldEditor.js'; import { FieldWidgetComponentProps } from './types.js'; +import type { LongTextCarrierHandoff } from './longTextCarrierHandoff.js'; import { toDomProps } from './toDomProps.js'; /** @@ -65,12 +66,13 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop // the moment a field toggles readonly. `maxLength` is derived here for the // same reason the ids are — proximity to the hook, not because the readonly // branch wants it. - // The declared metadata face — every key this widget reads off the carrier - // (`rows`, `maxLength`, `mobile_fullscreen`, `placeholder`, `label`) is a + // The declared metadata face — every authored key this widget reads off the + // carrier (`rows`, `maxLength`, `placeholder`, `label`) is a // `TextareaFieldMetadata` member, so the reads below are compiler-checked // (objectui#6140's de-cast sweep; the sibling `RichTextField` got the same - // treatment when its types gained `rows`). - const textareaField = field as TextareaFieldMetadata; + // treatment when its types gained `rows`). `mobile_fullscreen` is the one + // host-written key, typed on `LongTextCarrierHandoff` since objectui#12242. + const textareaField = field as TextareaFieldMetadata & LongTextCarrierHandoff; // The spec's `maxLength` (`FieldSchema.maxLength`, which // `TextareaFieldMetadata` declares by reference) is the one spelling read: // it gives the textarea its native stop and the counter its ceiling. The diff --git a/packages/fields/src/widgets/__tests__/longTextCarrierHandoff-12242.test.tsx b/packages/fields/src/widgets/__tests__/longTextCarrierHandoff-12242.test.tsx new file mode 100644 index 0000000000..c3f44b72f0 --- /dev/null +++ b/packages/fields/src/widgets/__tests__/longTextCarrierHandoff-12242.test.tsx @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12242 (maintainer ruling A) — `mobile_fullscreen` moved off the + * field metadata types onto `LongTextCarrierHandoff`, where its producer and + * its consumers meet. This file carries forward what + * `packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts` pinned + * for objectui#3245, whose premise the ruling reverses: the flag still has ONE + * carrier, the widget's `field`, but it is no longer declared on the authoring + * face `BaseFieldMetadata`. + * + * - The hand-off declares it as an optional boolean, not `any`. + * - The authoring face refuses it on a fresh literal: `@objectstack/spec`'s + * `FieldSchema` refuses it by name, so no published object carries it. + * - The carrier shape `ObjectForm` produces (the long-text metadata plus the + * stamped flag) compiles against the hand-off and is a valid `field`. + * - The widget still reads it: the expand affordance appears with the flag + * and not without it. Behaviour is unchanged. + * + * The compile-time rows run under this package's `type-check` + * (`tsc -p tsconfig.test.json`). + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { FieldMetadata, TextareaFieldMetadata } from '@object-ui/types'; + +import { TextAreaField } from '../TextAreaField'; +import type { LongTextCarrierHandoff } from '../longTextCarrierHandoff'; + +type Expect = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +export type assertionHandoffMember = [ + Expect, false>>, + Expect>, +]; + +/** The authoring face refuses the key on a fresh literal. */ +export const _authored: TextareaFieldMetadata = { + type: 'textarea', + name: 'body', + // @ts-expect-error — objectui#12242: `mobile_fullscreen` is host-written; it lives on `LongTextCarrierHandoff` + mobile_fullscreen: true, +}; + +/** The exact carrier `ObjectForm` produces: the long-text metadata plus the stamped flag. */ +const stamped: TextareaFieldMetadata & LongTextCarrierHandoff = { + type: 'textarea', + name: 'body', + label: 'Body', + mobile_fullscreen: true, +}; + +/** The control: the same metadata, not stamped. */ +const plain: TextareaFieldMetadata & LongTextCarrierHandoff = { type: 'textarea', name: 'body', label: 'Body' }; + +describe('mobile_fullscreen rides the long-text carrier hand-off (objectui#12242)', () => { + it('the stamped carrier is a valid widget `field`, and the widget draws the expand affordance', () => { + const field: FieldMetadata = stamped; + render( {}} field={field} />); + expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument(); + }); + + it('CONTROL — without the flag there is no affordance', () => { + render( {}} field={plain} />); + expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument(); + expect('mobile_fullscreen' in plain).toBe(false); + }); +}); diff --git a/packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx b/packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx index 24386c29bc..27725fc890 100644 --- a/packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx +++ b/packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx @@ -41,7 +41,10 @@ * * `type` (`resolveRichTextFieldType`, the discriminator), `rows` * (`richField?.rows || 8`), `placeholder`, `mobile_fullscreen` and `label` are - * the five keys the widget reads off this carrier on the `richtext` path; the + * the five keys the widget reads off this carrier on the `richtext` path + * (`mobile_fullscreen` is the host-written one, typed on + * `LongTextCarrierHandoff` since objectui#12242, so the literal below is + * annotated with both); the * readonly branch hands `field` to a `RICH_TEXT_CELL_RENDERERS` entry, and both * renderers there read `value` only. The sixth key, `maxLength`, is read by * the widget too (the native stop and the counter, objectui#8438) and by the @@ -60,6 +63,7 @@ import { FieldSchema } from '@objectstack/spec/data'; import type { FieldMetadata, RichtextFieldMetadata } from '@object-ui/types'; import { RichTextField } from '../RichTextField'; +import type { LongTextCarrierHandoff } from '../longTextCarrierHandoff'; /** * The whole derived read set in ONE annotated literal. @@ -68,7 +72,7 @@ import { RichTextField } from '../RichTextField'; * key, and judging each key is the half of this pin that a cast would have * silently satisfied. */ -const richtextField: RichtextFieldMetadata = { +const richtextField: RichtextFieldMetadata & LongTextCarrierHandoff = { type: 'richtext', name: 'doc', label: 'Release notes', diff --git a/packages/fields/src/widgets/longTextCarrierHandoff.ts b/packages/fields/src/widgets/longTextCarrierHandoff.ts new file mode 100644 index 0000000000..a2588d719a --- /dev/null +++ b/packages/fields/src/widgets/longTextCarrierHandoff.ts @@ -0,0 +1,36 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The key a HOST writes onto a long-text widget's `field` carrier at runtime — + * wider than the shape an author may WRITE, on purpose (objectui#12242, + * maintainer ruling A, under AGENTS.md #0.1 and the objectui#6356 pattern). + * + * `mobile_fullscreen` is a projection of the FORM-level setting + * `ObjectFormSchema.mobile.fullscreenLongText`. Its one producer is + * `ObjectForm` (`@object-ui/plugin-form`), which stamps it onto the carrier + * the form renderer hands each long-text widget (`f.field || f`) while it + * builds the form. Its consumers are `TextAreaField` and `RichTextField` here, + * which read it off `field` and nowhere else (objectui#3245), and the form + * renderer's built-in `textarea` branch in `@object-ui/components`, which + * reads it off its own props. Nobody authors it: `@objectstack/spec`'s + * `FieldSchema` refuses the key by name, so no published object can carry it, + * and `@object-ui/types`' field metadata types no longer declare it. + * + * ⛔ Deliberately NOT exported from this package's index, and ⛔ never to be + * added back to `@object-ui/types`: the authoring face refuses the key, and a + * public type naming it would invite the spelling that face refuses. + */ +export interface LongTextCarrierHandoff { + /** + * Draw the fullscreen-edit affordance (an "expand" button opening a + * full-height dialog) beside the inline editor. Absent when the form did + * not opt in. + */ + mobile_fullscreen?: boolean; +} diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 07f7eaf3cf..b41d8d9775 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -256,7 +256,7 @@ There is no `ValidationRule` type in this repo, under any spelling. | `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 }` | `message` is not optional when you author the rule by hand | | `min` / `max` | `{ value: number; message: string }` | numeric range | -| `pattern` | `{ value: string \| RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and it is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one | +| `pattern` | `{ value: RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and nothing compiles a string into one. 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/plugin-form/src/ObjectForm.locationStoredRange.test.tsx b/packages/plugin-form/src/ObjectForm.locationStoredRange.test.tsx index 0b9a3abd5a..8b11bb4b49 100644 --- a/packages/plugin-form/src/ObjectForm.locationStoredRange.test.tsx +++ b/packages/plugin-form/src/ObjectForm.locationStoredRange.test.tsx @@ -278,14 +278,15 @@ describe('buildValidationRules now compiles a `location` branch (objectui#6744)' expect(validate({ lat: 91, lng: 0 })).toBe(expectedMessage({ lat: 91, lng: 0 })); }); - it('a field-authored `validate` keeps running alongside it', () => { + it('is the only validator in the object form: a field-level `validate` is not compiled (objectui#12242)', () => { // The object form is react-hook-form's own way of holding several named - // validators, and it is the shape the form renderer already normalises to - // when it adds `required`. Composing rather than replacing is what keeps an - // authored rule from being silently dropped on location fields. + // validators, and it is the shape the form renderer normalises to when it + // adds `required`. A field-level `validate` used to ride in beside the + // location entry; the spec refuses that key by name, so it is no longer + // read, and the entry stands alone. const authored = () => 'authored says no'; const rules = buildValidationRules({ type: 'location', name: 'place', validate: authored }); - expect(rules.validate.validate).toBe(authored); + expect(Object.keys(rules.validate)).toEqual(['location']); expect(typeof rules.validate.location).toBe('function'); }); @@ -295,8 +296,5 @@ describe('buildValidationRules now compiles a `location` branch (objectui#6744)' expect(buildValidationRules({ type: 'text', name: 'title', required: true })) .toEqual({ required: true }); expect(buildValidationRules({ type: 'text', name: 'title' })).toBeUndefined(); - const authored = () => true; - expect(buildValidationRules({ type: 'text', name: 'title', validate: authored })) - .toEqual({ validate: authored }); }); }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index 5378cca64f..8c5e873fd6 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -279,9 +279,12 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest // alias because it was initialised from a member read. expect(reads.has('notOurs')).toBe(false); expect(reads.has('notOurs2')).toBe(false); - // The real consumer declares exactly one alias today; a second one is a new - // fact about the seam, not a silent widening of this sweep. - expect(receiverAliases(read('widgets/LookupField.tsx'), 'fieldMeta')).toEqual(['fieldMeta', 'cascadeMeta']); + // The real consumer declares exactly two aliases today; a third one is a + // new fact about the seam, not a silent widening of this sweep. + // objectui#12242 added `pickerConfig`, the `Pick` view of + // `fieldMeta` through which `idField` / `picker` / `subtitle` are read + // since the field metadata types stopped declaring them. + expect(receiverAliases(read('widgets/LookupField.tsx'), 'fieldMeta')).toEqual(['fieldMeta', 'pickerConfig', 'cascadeMeta']); expect(receiverAliases(read('widgets/UserField.tsx'), 'meta')).toEqual(['meta']); }); diff --git a/packages/plugin-grid/src/relationalMetaKeys.ts b/packages/plugin-grid/src/relationalMetaKeys.ts index e90b7f03c0..b92ea19d0d 100644 --- a/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/packages/plugin-grid/src/relationalMetaKeys.ts @@ -238,12 +238,14 @@ export type RelationalMetaVerdict = // `reference_to` (objectui#11070 round 4): the choke point no longer stamps // it and no consumer reads it, so the verdict names nothing. /** - * Declared on `@object-ui/types`' WIDGET metadata (`LookupFieldMetadata` / - * `UserFieldMetadata`) and emitted by in-repo producers, with no twin on + * Picker configuration declared on `@object-ui/types`' form entry + * (`FormField`) and emitted by in-repo producers, with no twin on * `FieldSchema` — and none needed. objectui#7155 created this category when * it converged the two dialects: `idField` carries objectstack#3508's * machine-name hydration, which is a picker capability and not an - * object-metadata one. + * object-metadata one. It was declared on the field metadata types + * (`LookupFieldMetadata`) until objectui#12242 moved it off that authoring + * face; the verdict is unchanged. */ | 'widget-contract' /** Read, but no producer can emit it — copying it would reach nothing. */ @@ -361,7 +363,7 @@ export const RELATIONAL_META_READ_SET: Readonly>, + Expect>, + Expect>, + Expect>, +]; + +// ── Round two: the field-level regex keys retire as `never` ───────────────── + +export type assertionRegexTombstones = [ + Expect>, + Expect>, + Expect>, +]; + +export const _pattern: TextFieldMetadata = { + type: 'text', + name: 'sku', + // @ts-expect-error — objectui#12242: `pattern` is retired; declare a `format` validation rule on the object + pattern: '^[A-Z]{3}-[0-9]{4}$', +}; + +export const _patternMessage: TextFieldMetadata = { + type: 'text', + name: 'sku', + // @ts-expect-error — objectui#12242: `pattern_message` is retired; the `format` rule carries the message + pattern_message: 'SKU must look like ABC-1234', +}; + +export const _validate: TextFieldMetadata = { + type: 'text', + name: 'sku', + // @ts-expect-error — objectui#12242: `validate` is retired; declare a `validations[]` rule on the object + validate: (value: unknown) => typeof value === 'string', +}; + +// ── Round two: `geolocation` is not a member of the union ─────────────────── + +export const _geolocation: FieldMetadata = { + // @ts-expect-error — objectui#12242: `geolocation` is not a spec field type; the spec's map point is `location` + type: 'geolocation', + name: 'site', +}; + +/** CONTROL for the row above: the spec's map-point type is a member. */ +export const _location: FieldMetadata = { type: 'location', name: 'site' }; + // ── The installed spec: what the type follows ──────────────────────────────── /** The `unrecognized_keys` issue naming `key`, if the parse refused it by name. */ @@ -222,6 +318,45 @@ describe('installed @objectstack/spec — the members the type retires (objectui expect(FieldSchema.safeParse(_currencyConfig).success).toBe(true); }); + it.each([ + ['textarea', 'mobile_fullscreen', true], + ['lookup', 'idField', 'name'], + ['user', 'picker', 'search'], + ['user', 'subtitle', ['email']], + ['text', 'pattern', '^[A-Z]{3}-[0-9]{4}$'], + ['text', 'pattern_message', 'SKU must look like ABC-1234'], + ['text', 'validate', { required: true }], + ] as const)('round two — on a `%s` field the spec refuses `%s` by name', (type, key, value) => { + const base = type === 'lookup' ? { reference: 'sys_position' } : {}; + // CONTROL: the same field without the key parses. + expect(FieldSchema.safeParse({ type, name: 'f', ...base }).success, type).toBe(true); + expect(refusedByName(FieldSchema.safeParse({ type, name: 'f', ...base, [key]: value }), key)).toBe(true); + }); + + it('round two — the spec refuses `geolocation` at `type`, and accepts `location`', () => { + expect(typeRefused(FieldSchema.safeParse({ type: 'geolocation', name: 'f' }))).toBe(true); + expect(FieldSchema.safeParse(_location).success).toBe(true); + }); + + it("round two — a regex check is the object's `format` rule: the object parses with it, and refuses the field-level key", () => { + const sku = { type: 'text', label: 'SKU', maxLength: 8 }; + const format = { + type: 'format', + name: 'sku_format', + field: 'sku', + regex: '^[A-Z]{3}-[0-9]{4}$', + message: 'SKU must look like ABC-1234', + }; + expect(ObjectSchema.safeParse({ name: 'product', label: 'Product', fields: { sku }, validations: [format] }).success).toBe(true); + const fieldLevel = ObjectSchema.safeParse({ name: 'product', label: 'Product', fields: { sku: { ...sku, pattern: format.regex } } }); + expect(fieldLevel.success).toBe(false); + expect( + !fieldLevel.success && fieldLevel.error.issues.some( + (i) => i.code === 'unrecognized_keys' && ((i as { keys?: string[] }).keys ?? []).includes('pattern'), + ), + ).toBe(true); + }); + it('the spec refuses `auto_number` and `object` at `type`, and accepts `autonumber` and `json`', () => { expect(typeRefused(FieldSchema.safeParse({ type: 'auto_number', name: 'f' }))).toBe(true); expect(typeRefused(FieldSchema.safeParse({ type: 'object', name: 'f' }))).toBe(true); diff --git a/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts b/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts deleted file mode 100644 index b888818721..0000000000 --- a/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * `mobile_fullscreen` has exactly ONE legal carrier: the field metadata - * (objectui#3245). - * - * The flag is a projection of the form-level - * `ObjectFormSchema.mobile.fullscreenLongText` setting, produced by `ObjectForm` - * (`@object-ui/plugin-form`) and read by `TextAreaField` (`@object-ui/fields`) - * off `FieldWidgetComponentProps.field` — whose type is the `FieldMetadata` - * union pinned below. It is deliberately NOT an `@objectstack/spec` property: - * nobody authors it on an object's field definition, the form runtime stamps it. - * - * Declaring it is the point. While it was undeclared, the producer stamped it - * onto the FormField through an `as FormField` cast and the consumer read it - * through an `as any` — two untyped ends that could not disagree out loud, and - * for the whole of the feature's life they DID disagree: the form renderer - * forwards `field: field.field || field`, so an auto-generated field handed the - * widget its raw metadata (flag-free) while `stripRegisteredFieldProps` dropped - * the prop copy. Every generated form silently lost the affordance, and no type - * anywhere could report it. - * - * The compile-time pins are the load-bearing part: `tsc -p tsconfig.test.json` - * (chained off this package's `type-check`) is what runs them. Deleting the - * declaration — or moving it off the base every `FieldMetadata` member extends — - * is a compile error, not a silently passing test. - */ - -import { describe, it, expect } from 'vitest'; -import type { BaseFieldMetadata, FieldMetadata, TextareaFieldMetadata } from '../index'; - -/* -------------------------------------------------------------------------- */ -/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ -/* -------------------------------------------------------------------------- */ - -type Assert = T; -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; -type IsAny = 0 extends 1 & T ? true : false; - -describe('FieldMetadata.mobile_fullscreen (objectui#3245)', () => { - it('is declared on the metadata base as an optional boolean', () => { - // Keeps the pins below from passing vacuously: an `any`-typed property - // would satisfy nothing while looking green. - type _NotAny = Assert, false>>; - - // Optional boolean — never required (no form is obliged to project it) and - // never widened. - type _OptionalBoolean = Assert< - Equal - >; - - expect(true).toBe(true); - }); - - it('is readable off the FieldMetadata union without narrowing first', () => { - // `FieldMetadata` is what `FieldWidgetComponentProps.field` resolves to, - // and `TextAreaField` reads the flag there. Declaring it on a single union - // member instead would make that read a compile error and push the next - // author straight back to the `as any` this issue exists to remove. - type _ReadableOffTheUnion = Assert< - Equal - >; - - // The long-text members inherit it like any other base property. - type _TextareaInheritsIt = Assert< - Equal - >; - - expect(true).toBe(true); - }); - - it('is assignable on a long-text field metadata object', () => { - // The exact shape `ObjectForm` produces: the stashed object-field metadata - // with the projected flag folded in. - const stamped: TextareaFieldMetadata = { - name: 'body', - type: 'textarea', - label: 'Body', - mobile_fullscreen: true, - }; - - expect(stamped.mobile_fullscreen).toBe(true); - }); - - it('is absent, not false, when the form did not opt in', () => { - const plain: TextareaFieldMetadata = { name: 'body', type: 'textarea' }; - - expect(plain.mobile_fullscreen).toBeUndefined(); - expect('mobile_fullscreen' in plain).toBe(false); - }); -}); diff --git a/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts index 84dc3928e3..f3ec56c54b 100644 --- a/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts +++ b/packages/types/src/__tests__/form-field-channel-keys-12130.test.ts @@ -34,7 +34,6 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import type { FormField } from '../form.js'; -import type { LookupFieldMetadata, UserFieldMetadata } from '../field-types.js'; import type { Field as SpecField } from '@objectstack/spec/data'; import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; @@ -208,11 +207,15 @@ export type assertionSpecMembersByReference = [ Expect>, ]; -/** The field metadata types' members, by reference; the rest as their widgets read them. */ +/** + * The rest as their widgets read them. `idField` / `picker` / `subtitle` were + * the field metadata types' members by reference until objectui#12242 moved + * them off that authoring face; this entry declares them itself now. + */ export type assertionLocalMembers = [ - Expect>, - Expect>, - Expect>, + Expect>, + Expect>, + Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 981fba84e1..921ece0a0e 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -115,8 +115,9 @@ const DECLARED_KEYS = [ // objectui#12130 — the rest of the field metadata the channel reads off the // entry, measured at runtime (`form-field-channel-census-12130.test.tsx` in // `examples/schema-catalog/test/`). The spec's `FieldSchema` members by - // reference, plus `idField` / `picker` / `subtitle` from the field metadata - // types and `capture` / `crop` / `defaultName` as their widgets read them. + // reference, plus `idField` / `picker` / `subtitle` (declared on the entry + // itself since objectui#12242 moved them off the field metadata types) and + // `capture` / `crop` / `defaultName` as their widgets read them. 'language', 'maxSize', 'capture', diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index c118240603..6e45440f78 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -98,31 +98,19 @@ export interface BaseFieldMetadata { */ system?: boolean; - /** - * Render a long-text field with a fullscreen-edit affordance (an "expand" - * button opening a full-height dialog) — the mobile UX for a textarea that - * would otherwise be a 4-row box wedged between other fields. - * - * **Not authored metadata, and deliberately NOT in `@objectstack/spec`.** - * It is a PROJECTION of the FORM-level setting - * `ObjectFormSchema.mobile.fullscreenLongText` onto the field metadata, - * with exactly one producer: `ObjectForm` (`@object-ui/plugin-form`) stamps - * it onto each long-text field's metadata carrier while building the form. - * Writing it on an object's field definition is meaningless — nothing - * publishes it and nothing else produces it. - * - * **Consumers**: `TextAreaField` and `RichTextField` (`@object-ui/fields`, - * the latter since objectui#3301 / PR #3302), which read it off `field` and - * nowhere else — `field` being the single metadata carrier since - * objectui#3233. It is declared here, on the type - * `FieldWidgetComponentProps.field` resolves to, so that the one legal - * location for the flag is a typed one rather than an untyped pun: before - * objectui#3245 it was stamped onto the FormField instead, where the form - * renderer's `field: field.field || field` forwarding could not see it and - * `stripRegisteredFieldProps` stripped the prop copy — so every - * auto-generated form silently lost the feature. + /* + * There is deliberately no `mobile_fullscreen` here (objectui#12242, ruling + * A). It is not authored metadata: it is a PROJECTION of the FORM-level + * `ObjectFormSchema.mobile.fullscreenLongText` that `ObjectForm` + * (`@object-ui/plugin-form`) stamps onto each long-text field's carrier + * while it builds the form, and `@objectstack/spec`'s `FieldSchema` refuses + * it by name, so no published object carries it. A key only the host + * writes is typed where its producer and its consumers meet, never on this + * authoring face (AGENTS.md #0.1): `LongTextCarrierHandoff` in + * `@object-ui/fields`, which `TextAreaField` and `RichTextField` read the + * flag through. The flag keeps its one carrier, the widget's `field` + * (objectui#3245); only its declaration moved. */ - mobile_fullscreen?: boolean; /** * Placeholder text @@ -172,10 +160,17 @@ export interface BaseFieldMetadata { visible_on?: never; /** - * Custom validation function or rules + * REFUSED BY NAME (objectui#12242): `@objectstack/spec`'s `FieldSchema` + * refuses `validate` with `unrecognized_keys`, and a field document is JSON, + * which cannot carry the function this member used to declare. A record's + * checks are the OBJECT's `validations[]` rules, which the server runs on + * the writes each rule names: `script` for a CEL predicate, `format` for a + * regex. `buildValidationRules` (`@object-ui/fields`) no longer reads this + * key. + * @deprecated Declare a `validations[]` rule on the object. Nothing reads this key. */ - validate?: FieldValidationFunction | FieldConstraints; - + validate?: never; + /* * There is deliberately no `depends_on` here (objectui#7357). It was * objectui's own snake_case twin of {@link BaseFieldMetadata.dependsOn} and @@ -241,12 +236,21 @@ export type VisibilityCondition = { }; /** - * Validation function type + * Validation function type. + * + * @deprecated No member declares it since objectui#12242: it typed the + * function arm of the retired `BaseFieldMetadata.validate`. A record's checks + * are the object's `validations[]` rules, run by the server. */ export type FieldValidationFunction = (value: any) => boolean | string | Promise; /** - * Validation rule type + * Validation rule type. + * + * @deprecated No member declares it since objectui#12242: it typed the rules + * arm of the retired `BaseFieldMetadata.validate`. Its `pattern` is not a + * field-level key either: an object's regex check is its `format` validation + * rule. */ export type FieldConstraints = { required?: boolean | string; @@ -279,8 +283,22 @@ export interface TextFieldMetadata extends BaseFieldMetadata { * the spec refuses by name and nothing reads. */ maxLength?: SpecField['maxLength']; - pattern?: string | RegExp; - pattern_message?: string; + /** + * REFUSED BY NAME (objectui#12242): `FieldSchema` refuses `pattern` with + * `unrecognized_keys` and declares no field-level regex. The spec's regex + * check is an OBJECT-level `validations[]` 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`. `buildValidationRules` no longer reads this key. + * @deprecated Declare a `format` validation rule on the object. Nothing reads this key. + */ + pattern?: never; + /** + * REFUSED BY NAME (objectui#12242), as {@link pattern}: the message a + * failed regex check shows is the `format` rule's own `message`. + * @deprecated Write the `format` rule's `message`. Nothing reads this key. + */ + pattern_message?: never; } /** @@ -394,11 +412,15 @@ export interface HtmlFieldMetadata extends BaseFieldMetadata { * - `rows` — `richField?.rows || 8`, the inline editor's height. Declared * here for the same reason objectui#6140 declared it on the two siblings: * the running widget honoured a key an annotated literal rejected. - * - `mobile_fullscreen`, `placeholder`, `label` — also read off this carrier - * (the expand affordance, the textarea placeholder, the dialog title), and - * all three already sit on {@link BaseFieldMetadata}, so they need no - * redeclaration here. Listed because "derived" has to name what it found, - * including the keys that turned out to need no line. + * - `placeholder`, `label` — also read off this carrier (the textarea + * placeholder, the dialog title), and both already sit on + * {@link BaseFieldMetadata}, so they need no redeclaration here. Listed + * because "derived" has to name what it found, including the keys that + * turned out to need no line. + * - `mobile_fullscreen` — the expand affordance, read off the same carrier + * but written there by the host (`ObjectForm`), never authored. It is + * typed on `LongTextCarrierHandoff` in `@object-ui/fields`, not on any + * field metadata type (objectui#12242). * * The readonly branch hands `field` whole to the `RICH_TEXT_CELL_RENDERERS` * entry for the type; both renderers there destructure `value` only, so the @@ -876,14 +898,17 @@ export interface LookupFieldMetadata extends BaseFieldMetadata { */ displayField?: string; - /** - * Record field committed as this lookup's VALUE. Defaults to `'id'`; set it - * when the reference is stored by another column — e.g. an approval - * `position` approver stores `sys_position.name`, because the engine routes - * by machine name and names stay portable across environments - * (objectstack-ai/objectstack#3508). + /* + * There is deliberately no `idField` here (objectui#12242, ruling A). The + * record field a picker commits as the lookup's VALUE (default `id`; an + * approval `position` approver stores `sys_position.name`, + * objectstack-ai/objectstack#3508) is picker configuration a HOST writes, + * not object metadata: `@objectstack/spec`'s `FieldSchema` refuses + * `idField` by name, so no published object carries it. Its producers are + * a form entry (`FormField.idField`), an action parameter, a flow binding + * and a `master_detail` inline grid column, and its consumer is + * `LookupField`, which reads it through the form entry's own declaration. */ - idField?: string; /** * Offer inline "create the referenced record" from an empty/zero-result @@ -1057,20 +1082,17 @@ export interface UserFieldMetadata extends BaseFieldMetadata { type: 'user'; multiple?: boolean; - /** - * Picker UI variant. - * - `'search'` — the search-first PeoplePicker (rich rows + selection tray). - * Default for `user` fields. - * - `'default'` — the classic table-based record-picker dialog. - */ - picker?: 'search' | 'default'; - - /** - * Dotted field paths shown as the candidate/row subtitle for disambiguation. - * Relation paths auto-expand (e.g. `primary_business_unit_id.name`). - * @example ['primary_business_unit_id.name', 'email'] + /* + * There is deliberately no `picker` and no `subtitle` here (objectui#12242, + * ruling A). The person picker's variant (`'search'`, the default, or the + * classic `'default'` dialog) and the record fields drawn beside each + * candidate are picker configuration a HOST writes, not object metadata: + * `@objectstack/spec`'s `FieldSchema` refuses both keys by name, so no + * published object carries them. `UserField` supplies both defaults + * itself; a form entry (`FormField.picker` / `FormField.subtitle`) and a + * flow binding may set them, and `LookupField` reads them through the form + * entry's own declaration. */ - subtitle?: string[]; /** Field holding the avatar image URL. Default `image`. */ avatar_field?: string; @@ -1335,6 +1357,19 @@ export interface AddressFieldMetadata extends BaseFieldMetadata { type: 'address'; } +/** + * `geolocation` field metadata — OUT of the {@link FieldMetadata} union since + * objectui#12242 (ruling A). `geolocation` is not a `@objectstack/spec` + * `FieldType`: `FieldSchema` refuses it at `type` with `invalid_value`, so no + * published object can declare it, and the authoring union no longer offers + * it. The spec's map-point field type is `location` + * ({@link LocationFieldMetadata}), which renders a different widget. + * + * The `field:geolocation` widget itself stays registered in + * `@object-ui/fields`; only the authoring face stops offering the type value. + * + * @deprecated Not a spec field type, and no longer a member of `FieldMetadata`. + */ export interface GeolocationFieldMetadata extends BaseFieldMetadata { type: 'geolocation'; } @@ -1407,7 +1442,8 @@ export type FieldMetadata = | SignatureFieldMetadata | QRCodeFieldMetadata | AddressFieldMetadata - | GeolocationFieldMetadata + // ⛔ No `GeolocationFieldMetadata` (objectui#12242): `geolocation` is not a + // spec `FieldType`, so a published object cannot declare it. | SliderFieldMetadata | RatingFieldMetadata | MasterDetailFieldMetadata; diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index b60e0a8c4e..4706ee923c 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -17,7 +17,7 @@ import type { BaseSchema, SchemaNode } from './base.js'; import type { SelectOptionBase } from './select-option.js'; -import type { GridFieldMetadata, LookupFieldMetadata, UserFieldMetadata } from './field-types.js'; +import type { GridFieldMetadata, UserFieldMetadata } from './field-types.js'; // objectui#11070 — the field-metadata members `FormField` declares by // reference to the spec's `FieldSchema`. Type-only: no runtime edge. import type { Field as SpecField } from '@objectstack/spec/data'; @@ -1594,13 +1594,15 @@ export interface FieldValidationRules { * `value instanceof RegExp` (verified against the installed 7.85.0 bundle), * so a string here type-checked and then validated NOTHING, silently: the * form looked validated while the rule never ran (objectui#5099, maintainer - * ruling 2026-08-18). String patterns belong to the metadata route's field - * declaration (`FieldSchema.pattern`), which `buildValidationRules` in - * `@object-ui/fields` compiles via `new RegExp(...)` before it reaches this - * shape. The renderer deliberately does NOT compile strings at its read - * point — that consumer-side tolerance would harden the ambiguous - * declaration into contract (AGENTS.md #0.1); the declaration is narrowed - * at the producer instead. + * ruling 2026-08-18). No path compiles a string into one: a regex declared + * as object METADATA is the object's `format` validation rule + * (`validations[]`), which the server runs on save — the field-level + * `pattern` that `buildValidationRules` in `@object-ui/fields` used to + * compile is retired (objectui#12242; `FieldSchema` refuses it by name). + * The renderer deliberately does NOT compile strings at its read point — + * that consumer-side tolerance would harden the ambiguous declaration into + * contract (AGENTS.md #0.1); the declaration is narrowed at the producer + * instead. */ pattern?: { value: RegExp; message: string }; /** @@ -2192,10 +2194,13 @@ export interface FormField { // with and without. Every member below is read by the widget or branch its // note names and changes what it does; where no note names a field type, it // is accepted and read by nothing. The spec's `FieldSchema` members are - // typed by reference (`step` among them), `idField` / `picker` / `subtitle` - // are the field metadata types' members by reference, and `capture`, - // `crop`, `defaultName`, `avatarField` and `className`, which no type - // declared for a form field, are typed as they are read. + // typed by reference (`step` among them), and `idField` / `picker` / + // `subtitle`, `capture`, `crop`, `defaultName`, `avatarField` and + // `className` are typed as they are read. `idField` / `picker` / `subtitle` + // were the field metadata types' members by reference until objectui#12242 + // moved them off that authoring face (the spec's `FieldSchema` refuses all + // three by name): this entry is the face that declares them now, and + // `LookupField` reads them through it. // // ⛔ Not declared: `defaultValue` and `defaultChecked`. No reader asks for // either, and neither becomes the field's value (the effects file measures @@ -2255,10 +2260,12 @@ export interface FormField { */ displayField?: SpecField['displayField']; /** - * Record field a `lookup` field's pick commits as the value (default `id`): - * {@link LookupFieldMetadata.idField}. + * Record field a `lookup` field's pick commits as the value (default `id`), + * e.g. `name` for a reference stored by machine name + * (objectstack-ai/objectstack#3508). Picker configuration, not object + * metadata: `FieldSchema` refuses it by name (objectui#12242). */ - idField?: LookupFieldMetadata['idField']; + idField?: string; /** * Columns of a `lookup` field's browse-all dialog. */ @@ -2273,15 +2280,19 @@ export interface FormField { lookupPageSize?: SpecField['lookupPageSize']; /** * Picker of a `lookup` or `user` field: `'search'` is the search-first - * people picker, which replaces the quick-select and the browse-all dialog: - * {@link UserFieldMetadata.picker}. + * people picker, which replaces the quick-select and the browse-all dialog; + * `'default'` is the classic record-picker dialog. A `user` field with no + * `picker` gets the search picker. Picker configuration, not object + * metadata: `FieldSchema` refuses it by name (objectui#12242). */ - picker?: UserFieldMetadata['picker']; + picker?: 'search' | 'default'; /** - * Record fields shown beside each candidate of the search picker - * (`picker: 'search'`): {@link UserFieldMetadata.subtitle}. + * Dotted record-field paths shown beside each candidate of the search + * picker (`picker: 'search'`); relation paths expand, e.g. + * `primary_business_unit_id.name`. Picker configuration, not object + * metadata: `FieldSchema` refuses it by name (objectui#12242). */ - subtitle?: UserFieldMetadata['subtitle']; + subtitle?: string[]; /** * Record field each candidate of the search picker (`picker: 'search'`) of * a `lookup` or `user` field draws its avatar image from (default `image`). diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 920602e94c..dc2d582e50 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -1163,9 +1163,10 @@ export const FormFieldSchema = z.object({ // entry, measured at runtime (the census and effects pins under // `examples/schema-catalog/test/`). The spec's `FieldSchema` members by // reference (`step` among them, so HTML's `'any'` is refused); `idField` / - // `picker` / `subtitle` stated once from the field metadata types' members - // the TS twin carries by reference; `capture`, `crop`, `defaultName`, - // `avatarField` and `className` typed as they are read. + // `picker` / `subtitle` stated once as the TS twin declares them (the entry + // is their declaring face since objectui#12242 moved them off the field + // metadata types); `capture`, `crop`, `defaultName`, `avatarField` and + // `className` typed as they are read. // `defaultValue` and `defaultChecked` stay undeclared: no reader asks for // either, and neither becomes the field's value (`FormSchema.defaultValues` // does). `avatar_field` stays undeclared: the second spelling of From bf418ddc73654151b1527eaf9c9edbdba074d025 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:37:12 +0000 Subject: [PATCH 2/5] refactor(app-shell,fields): the flow reference picker types its carrier on the hand-off, plus changesets FlowReferenceField builds LookupField's carrier with idField and the people-picker keys; it now types that literal as LookupFieldMetadata plus FormField's picker keys, since the field metadata types no longer declare them. buildValidationRules gains a pin for the retired field-level regex keys. Changesets for @object-ui/types and @object-ui/fields. Claude-Session: https://claude.ai/code/session_015YrgVw7e65cs1ndbebt46G Co-authored-by: Claude --- .changeset/12242-fields-validation-rules.md | 16 ++++++ .changeset/12242-types-kept-positions.md | 31 +++++++++++ .../inspectors/FlowReferenceField.tsx | 44 ++++++++------- ...Rules.fieldLevelRegexRetired-12242.test.ts | 54 +++++++++++++++++++ 4 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 .changeset/12242-fields-validation-rules.md create mode 100644 .changeset/12242-types-kept-positions.md create mode 100644 packages/fields/src/__tests__/buildValidationRules.fieldLevelRegexRetired-12242.test.ts 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..c0ad7a6316 --- /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. This supersedes the pending objectui#12130 note that the entry declares them by reference to the field metadata types. + +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/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} />