diff --git a/.changeset/25923-form-field-closed-face.md b/.changeset/25923-form-field-closed-face.md new file mode 100644 index 00000000000..bb2f30587a1 --- /dev/null +++ b/.changeset/25923-form-field-closed-face.md @@ -0,0 +1,24 @@ +--- +'@object-ui/types': patch +'@object-ui/core': patch +'@object-ui/components': patch +'@object-ui/plugin-form': patch +--- + +fix(types,core,components,plugin-form)!: `FormField` drops its open index signature, and a form-view field's `immutable` locks the control once the record exists + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** type-surface narrowing, compile-time only. + +- **`@object-ui/types`.** `FormField` no longer ends in `[key: string]: any`. A key no member declares — a misspelling included — is now a compile error on a fresh `FormField` literal, as it already was on every node type since `BaseSchema` closed. The declared members are unchanged, and so are the zod mirror `FormFieldSchema` and the strict face `StrictAnyComponentSchema`, which already refused undeclared keys. +- **`@object-ui/core`.** `FieldWithDeclaredDefault`, the parameter type of `isServerOwnedValue`, drops its `[key: string]: unknown` signature, so a `FormField` value is assignable to it. It still reads only `field.defaultValue`. +- **`@object-ui/components`.** The form renderer types the field's raw metadata carrier as `Record | FormField` (the stashed object field, else the field config itself), instead of reading the config through the signature. +- **`@object-ui/plugin-form`.** The host-written `group` key (copied from the object field for the field-group fallback) and the section-divider disclosure keys (`collapsible`, `collapsed`, `onToggle`) are typed on package-local hand-off types instead of riding the signature. + +**Fix (`@object-ui/plugin-form`).** A form-view field declaring `immutable: true` (spec `FormField.immutable`, "editable on create, locked once the record exists") is now drawn disabled when the form edits an existing record, and stays editable on a create form. It used to be copied onto the runtime field as `immutable`, which no renderer reads, so an edit form left the control editable. + +What to do: nothing if your `FormField` literals use only declared keys. Otherwise the compiler names each undeclared key on the literal that carries it (an excess-property error): correct a misspelling to the declared member, or, for a key only your host writes and reads, type it on a local intersection such as `FormField & { group?: string }` where its producer and reader live. diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 13342973af4..12f6085aba1 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -821,7 +821,7 @@ function dependentLookupParents(f: FormFieldConfig | undefined): string[] { if (typeof widget !== 'string' || !EXPANDABLE_FIELD_TYPES.has(normalizeFieldType(widget))) { return []; } - const carrier: Record = f.field || (f as Record); + const carrier: Record | FormFieldConfig = f.field || f; const inner = carrier.field; const meta = inner !== null && typeof inner === 'object' && ('reference' in inner || 'type' in inner) @@ -4369,7 +4369,7 @@ interface RenderFieldProps { /** react-hook-form's ref binding, handed to the built-in control by name. */ ref?: RefCallBack; /** The field's raw metadata object (`field.field`, else the field config). */ - field?: Record; + field?: Record | FormFieldConfig; /** A dependent select's gate hint while its controlling field is unset (#2284). */ emptyHint?: string; [key: string]: unknown; @@ -4729,7 +4729,7 @@ const BuiltinSelectControl = React.forwardRef | undefined): number | 'any' { +function nativeNumberStep(carrier: RenderFieldProps['field']): number | 'any' { const step = carrier?.step; if (typeof step === 'number') return step; const scale = carrier?.scale; diff --git a/ui/packages/core/src/validation/__tests__/server-owned-value.test.ts b/ui/packages/core/src/validation/__tests__/server-owned-value.test.ts index 33d8ff6384a..027f81622e7 100644 --- a/ui/packages/core/src/validation/__tests__/server-owned-value.test.ts +++ b/ui/packages/core/src/validation/__tests__/server-owned-value.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest'; import { DEFAULT_VALUE_TOKENS } from '@objectstack/spec/data'; +import type { FormField } from '@object-ui/types'; import { isRuntimeDefault, isServerOwnedValue } from '../server-owned-value.js'; /** A CEL Expression envelope — the non-token half of "the server resolves it". */ @@ -71,7 +72,8 @@ describe('isServerOwnedValue (#4085)', () => { it('a field declaring no default is never server-owned', () => { expect(isServerOwnedValue(withDefault(undefined), true)).toBe(false); - expect(isServerOwnedValue({ name: 'title' }, true)).toBe(false); + const declaresNoStash: FormField = { name: 'title' }; + expect(isServerOwnedValue(declaresNoStash, true)).toBe(false); }); it('a hand-authored FormField carries no object metadata, so it is untouched', () => { diff --git a/ui/packages/core/src/validation/server-owned-value.ts b/ui/packages/core/src/validation/server-owned-value.ts index e3cb4c914a1..6eb65224071 100644 --- a/ui/packages/core/src/validation/server-owned-value.ts +++ b/ui/packages/core/src/validation/server-owned-value.ts @@ -85,16 +85,16 @@ export function isRuntimeDefault(v: unknown): boolean { /** * A runtime `FormField` as far as this module is concerned: the resolved - * object-field metadata stash, and whatever else the field carries. + * object-field metadata stash. * * Stated structurally rather than as `@object-ui/types`' `FormField` so the - * one key this module reads is visible in the signature — and with an index - * signature so a caller (or a test) may hand over an object literal carrying - * the rest of the field without tripping TypeScript's excess-property check. + * one key this module reads is visible in the signature. No index signature: + * `FormField` declares its keys and carries none (objectstack#25923), and an + * interface without one is not assignable to a type that has one — a + * `FormField` value is accepted here because it declares this `field` slot. */ export interface FieldWithDeclaredDefault { field?: { defaultValue?: unknown } | null; - [key: string]: unknown; } /** diff --git a/ui/packages/plugin-form/src/ObjectForm.tsx b/ui/packages/plugin-form/src/ObjectForm.tsx index 07490f37651..ce7f25b1ab0 100644 --- a/ui/packages/plugin-form/src/ObjectForm.tsx +++ b/ui/packages/plugin-form/src/ObjectForm.tsx @@ -45,7 +45,7 @@ import { filterSystemFields, sectionedGridLayout, } from './autoLayout'; -import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; +import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse, type FormFieldWithGroup } from './fieldGroups'; import { mergeCustomFields } from './customFieldsMerge'; import { hasSectionGroupReference, resolveSectionGroupReferences } from './sectionGroups'; import { @@ -958,7 +958,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource const field = objectSchema.fields?.[name]; if (field) { // Auto-generate field from schema - const formField: FormField = { + const formField: FormFieldWithGroup = { name: name, label: fieldLabel(schema.objectName, name, field.label || fieldName), // (type, multiple) decides the widget, not the type alone: a `select` diff --git a/ui/packages/plugin-form/src/fieldGroups.ts b/ui/packages/plugin-form/src/fieldGroups.ts index a5873669728..9d184dab376 100644 --- a/ui/packages/plugin-form/src/fieldGroups.ts +++ b/ui/packages/plugin-form/src/fieldGroups.ts @@ -21,6 +21,30 @@ import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import type { FormField, ObjectFormSection } from '@object-ui/types'; +/** + * A runtime `FormField` carrying its object field's group membership. + * + * `group` is written by the HOST, never authored on a form field: the + * object-bound form copies it from the object field (`Field.group`, the key of + * one of the object's `fieldGroups`) and {@link deriveFieldGroupSections} reads + * it back. So it is typed here, where its producer and consumer live, and not + * on the `FormField` authoring face (ui AGENTS.md #0.1, objectstack#25923). + */ +export type FormFieldWithGroup = FormField & { group?: string }; + +/** + * A `section-divider` row as {@link projectSectionDivider} writes it: a + * runtime `FormField` plus the disclosure control the HOST resolves for it + * (`resolveSectionCollapse`). The form renderer's divider branch reads the + * three keys off the row; none of them is authored on a form field, so they + * stay off the `FormField` authoring face (ui AGENTS.md #0.1, objectstack#25923). + */ +type SectionDividerRow = FormField & { + collapsible?: boolean; + collapsed?: boolean; + onToggle?: () => void; +}; + /** * Derive form sections from an object's declared `fieldGroups` and each * rendered field's `group`. @@ -49,7 +73,7 @@ export function deriveFieldGroupSections( // Pseudo-def over the rendered fields: membership is the only input the // derivation needs per field. fields: Object.fromEntries( - fields.map((f) => [f.name, { group: (f as { group?: unknown }).group }]), + fields.map((f) => [f.name, { group: (f as FormFieldWithGroup).group }]), ), }); if (!derived) return null; @@ -135,7 +159,7 @@ export interface SectionDividerSource { /** The authored blurb (spec `FormSection.description`). */ description?: string; /** ADR-0089 `FormSection.visibleWhen` (#6111), unevaluated. */ - visibleWhen?: unknown; + visibleWhen?: FormField['visibleWhen']; /** * The fault direction of {@link visibleWhen}, copied onto the row unchanged * (`ObjectFormSection.visibleWhenFault`, objectui#13055). @@ -335,11 +359,11 @@ export function projectSectionDivider(source: SectionDividerSource): FormField[] ...(visibleWhenFault !== undefined ? { visibleWhenFault } : {}), fields: members, colSpan: DIVIDER_COL_SPAN, - } as unknown as FormField, + } satisfies SectionDividerRow, ]; } - const row: Record = { + const row: SectionDividerRow = { name: `__section_${key}`, label: title, type: 'section-divider', @@ -355,5 +379,5 @@ export function projectSectionDivider(source: SectionDividerSource): FormField[] row.collapsed = collapse.collapsed; row.onToggle = collapse.onToggle; } - return [row as FormField]; + return [row]; } diff --git a/ui/packages/plugin-form/src/flatFields.test.ts b/ui/packages/plugin-form/src/flatFields.test.ts index 467ed4850b6..f7ad91e449d 100644 --- a/ui/packages/plugin-form/src/flatFields.test.ts +++ b/ui/packages/plugin-form/src/flatFields.test.ts @@ -135,8 +135,10 @@ describe('buildFlatFields WARNS on an unresolvable top-level member (route 1, ob describe('buildFlatFields ↔ normalizeSectionField parity (#4755)', () => { it('agrees key-by-key with the sectioned producer, adding only `group`', () => { for (const name of Object.keys(OBJECT_SCHEMA.fields)) { - const flat = buildFlatFields({ ...ctx, fields: [name] })[0] as Record; - const sectioned = normalizeSectionField(name, ctx) as Record; + // Spread into plain records: the pin enumerates whatever keys each + // producer wrote, declared on `FormField` or not. + const flat: Record = { ...buildFlatFields({ ...ctx, fields: [name] })[0] }; + const sectioned: Record = { ...normalizeSectionField(name, ctx) }; expect(new Set(Object.keys(flat))).toEqual(new Set([...Object.keys(sectioned), 'group'])); for (const key of Object.keys(sectioned)) { diff --git a/ui/packages/plugin-form/src/flatFields.ts b/ui/packages/plugin-form/src/flatFields.ts index bed99ebf337..08e2b9c1928 100644 --- a/ui/packages/plugin-form/src/flatFields.ts +++ b/ui/packages/plugin-form/src/flatFields.ts @@ -36,6 +36,7 @@ import type { FormField } from '@object-ui/types'; import { fromObjectSchema, warnUnresolvedTopLevelField, type SectionFieldsContext } from './sectionFields'; import { mergeCustomFields } from './customFieldsMerge'; +import type { FormFieldWithGroup } from './fieldGroups'; export interface FlatFieldsContext extends SectionFieldsContext { /** @@ -92,6 +93,6 @@ export function buildFlatFields(ctx: FlatFieldsContext): FormField[] { // it the moment explicit sections exist, so stamping `group` onto // section-built fields would add a key with no reader. group: (field as any).group, - }; + } satisfies FormFieldWithGroup; }); } diff --git a/ui/packages/plugin-form/src/sectionFields.spec-parity.test.ts b/ui/packages/plugin-form/src/sectionFields.spec-parity.test.ts index f900ab7f626..2a582c83d66 100644 --- a/ui/packages/plugin-form/src/sectionFields.spec-parity.test.ts +++ b/ui/packages/plugin-form/src/sectionFields.spec-parity.test.ts @@ -106,7 +106,14 @@ const TABLE: Record) => any) => void> placeholder: (f) => expect(f({ placeholder: 'Type…' }).placeholder).toBe('Type…'), helpText: (f) => expect(f({ helpText: 'Hint' }).description).toBe('Hint'), readonly: (f) => expect(f({ readonly: true }).disabled).toBe(true), - immutable: (f) => expect(f({ immutable: true }).immutable).toBe(true), + immutable: (f) => { + // Locked once the record exists: an edit form draws it disabled, a create + // form leaves it editable, and no `immutable` key is carried (objectstack#25923). + const edit = normalizeSectionField({ field: 'ghost', immutable: true }, { ...ctx, mode: 'edit', recordId: 'r1' }) as any; + expect(edit.disabled).toBe(true); + expect(edit.immutable).toBeUndefined(); + expect(f({ immutable: true }).disabled).toBeFalsy(); + }, required: (f) => expect(f({ required: true }).required).toBe(true), hidden: (f) => expect(f({ hidden: true }).hidden).toBe(true), colSpan: (f) => expect(f({ colSpan: 2 }).colSpan).toBe(2), diff --git a/ui/packages/plugin-form/src/sectionFields.ts b/ui/packages/plugin-form/src/sectionFields.ts index d3ec4a28597..0b1a34f6739 100644 --- a/ui/packages/plugin-form/src/sectionFields.ts +++ b/ui/packages/plugin-form/src/sectionFields.ts @@ -414,7 +414,14 @@ function resolveSectionEntry( ); } if (fd.readonly != null) base.disabled = fd.readonly || base.disabled; - if (fd.immutable != null) base.immutable = fd.immutable; + // `immutable`: editable on create, locked once the record exists (spec + // FormField.immutable). It lands on `disabled`, the slot `readonly` above + // locks a control through, and asks the create-mode question `required` + // asks (`isCreateFormMode`), so a form seeded as create never locks it and an + // edit form always does. It used to be copied through as `immutable`, a key + // no renderer reads, so an edit form left the control editable + // (objectstack#25923). + if (fd.immutable === true && !isCreateFormMode(ctx)) base.disabled = true; if (fd.hidden != null) base.hidden = fd.hidden; if (fd.colSpan != null) base.colSpan = fd.colSpan; if (fd.span != null) base.span = fd.span; diff --git a/ui/packages/types/src/__tests__/form-field-closed-face-25923.test.ts b/ui/packages/types/src/__tests__/form-field-closed-face-25923.test.ts new file mode 100644 index 00000000000..d202a810ec2 --- /dev/null +++ b/ui/packages/types/src/__tests__/form-field-closed-face-25923.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FormField` carries no index signature, so a key no member declares stops + * type-checking — the same compile-fail pin `base-schema-closed-face-8347` + * holds for `BaseSchema`, for the form field (objectstack#25923, seat ruling + * letter A on that card). + * + * The signature (`[key: string]: any`) admitted every key, so a misspelled + * `labell` compiled and a host-written key (`group`, read by plugin-form's + * field-group derivation) rode it undeclared. The host keys now live on + * package-local hand-off types where their producer and consumer sit + * (ui AGENTS.md #0.1), never on this authoring face. + * + * TYPE-level rows: `tsc -p tsconfig.test.json`, chained from this package's + * `type-check` script, is their reader. An `@ts-expect-error` that stops + * matching an error is itself an error (TS2578), so a signature put back on + * `FormField` turns both refusal rows red. Every refusal row is a FRESH + * literal, the only shape TypeScript excess-checks (the bound + * `base-schema-closed-face-8347` pins). The vitest leg only keeps the + * bindings alive. + */ + +import { describe, it, expect } from 'vitest'; +import type { FormField } from '../form'; + +describe('FormField is a closed authoring face (objectstack#25923)', () => { + it('refuses a misspelled key and a host-written key; the declared spelling compiles', () => { + // @ts-expect-error — `labell` is no FormField member + const misspelt: FormField = { name: 'title', labell: 'Title' }; + // @ts-expect-error — `group` is host-written; plugin-form types it on `FormFieldWithGroup` + const hostKey: FormField = { name: 'title', group: 'contact_info' }; + const declared: FormField = { name: 'title', label: 'Title', step: 1, className: 'w-full' }; + + expect([misspelt.name, hostKey.name, declared.label]).toEqual(['title', 'title', 'Title']); + }); +}); diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9b2cba9b0a0..5e59fb11521 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -5721,8 +5721,8 @@ const SPEC_ONLY_KEY_ROWS: Record; - /** - * Additional field-specific props - */ - [key: string]: any; /** * Column span for grid layouts (1-4). Legacy — prefer `span`. * Aligns with @objectstack/spec FormField.colSpan. The renderer clamps it to