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
24 changes: 24 additions & 0 deletions .changeset/25923-form-field-closed-face.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (runtime-interface-only ui/packages/types/src/form.ts#FormField, ui/packages/core/src/validation/server-owned-value.ts#FieldWithDeclaredDefault) Two published TypeScript interfaces each lose their open index signature and nothing else: no member is removed or renamed, no metadata key, accepted value, export or error code moves, the zod mirror `FormFieldSchema` and the strict authoring face are unchanged, and `objectstack migrate meta` has nothing to rewrite. The affected party is a TypeScript consumer, reached by its own compiler (excess-property error naming the undeclared key). -->

**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<string, unknown> | 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.
6 changes: 3 additions & 3 deletions ui/packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ function dependentLookupParents(f: FormFieldConfig | undefined): string[] {
if (typeof widget !== 'string' || !EXPANDABLE_FIELD_TYPES.has(normalizeFieldType(widget))) {
return [];
}
const carrier: Record<string, unknown> = f.field || (f as Record<string, unknown>);
const carrier: Record<string, unknown> | FormFieldConfig = f.field || f;
const inner = carrier.field;
const meta =
inner !== null && typeof inner === 'object' && ('reference' in inner || 'type' in inner)
Expand Down Expand Up @@ -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<string, unknown>;
field?: Record<string, unknown> | FormFieldConfig;
/** A dependent select's gate hint while its controlling field is unset (#2284). */
emptyHint?: string;
[key: string]: unknown;
Expand Down Expand Up @@ -4729,7 +4729,7 @@ const BuiltinSelectControl = React.forwardRef<HTMLButtonElement, BuiltinSelectCo
* wrote it until objectui#12130; `objectFormNumericStep-9574.test.tsx` pins
* both of its routes.
*/
function nativeNumberStep(carrier: Record<string, unknown> | undefined): number | 'any' {
function nativeNumberStep(carrier: RenderFieldProps['field']): number | 'any' {
const step = carrier?.step;
if (typeof step === 'number') return step;
const scale = carrier?.scale;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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". */
Expand Down Expand Up @@ -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', () => {
Expand Down
10 changes: 5 additions & 5 deletions ui/packages/core/src/validation/server-owned-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions ui/packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`
Expand Down
34 changes: 29 additions & 5 deletions ui/packages/plugin-form/src/fieldGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, unknown> = {
const row: SectionDividerRow = {
name: `__section_${key}`,
label: title,
type: 'section-divider',
Expand All @@ -355,5 +379,5 @@ export function projectSectionDivider(source: SectionDividerSource): FormField[]
row.collapsed = collapse.collapsed;
row.onToggle = collapse.onToggle;
}
return [row as FormField];
return [row];
}
6 changes: 4 additions & 2 deletions ui/packages/plugin-form/src/flatFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const sectioned = normalizeSectionField(name, ctx) as Record<string, unknown>;
// Spread into plain records: the pin enumerates whatever keys each
// producer wrote, declared on `FormField` or not.
const flat: Record<string, unknown> = { ...buildFlatFields({ ...ctx, fields: [name] })[0] };
const sectioned: Record<string, unknown> = { ...normalizeSectionField(name, ctx) };

expect(new Set(Object.keys(flat))).toEqual(new Set([...Object.keys(sectioned), 'group']));
for (const key of Object.keys(sectioned)) {
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/plugin-form/src/flatFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ const TABLE: Record<string, (f: (def: Record<string, unknown>) => 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),
Expand Down
9 changes: 8 additions & 1 deletion ui/packages/plugin-form/src/sectionFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
4 changes: 2 additions & 2 deletions ui/packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5721,8 +5721,8 @@ const SPEC_ONLY_KEY_ROWS: Record<string, { disposition: SpecOnlyDisposition; why
why: '`normalizeSectionField` writes it as the runtime `description`, which the form renders under the control',
},
'FormFieldSchema.immutable': {
disposition: 'carried, not rendered',
why: '`normalizeSectionField` copies it onto the runtime field; no form renderer reads `immutable`, so an edit-mode form leaves the control editable',
disposition: 'translated',
why: '`normalizeSectionField` writes it as the runtime `disabled` when the form edits an existing record (`isCreateFormMode` false), which the form renders as a locked control; a create form leaves it editable (objectstack#25923)',
},
'FormFieldSchema.keyField': {
disposition: 'carried, not rendered',
Expand Down
4 changes: 0 additions & 4 deletions ui/packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1939,10 +1939,6 @@ export interface FormField {
* {@link FieldMetadata} union.
*/
field?: Record<string, any>;
/**
* 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
Expand Down
Loading