diff --git a/.changeset/tidy-forms-carry-spec-keys.md b/.changeset/tidy-forms-carry-spec-keys.md new file mode 100644 index 0000000000..5cb3c5052c --- /dev/null +++ b/.changeset/tidy-forms-carry-spec-keys.md @@ -0,0 +1,35 @@ +--- +'@object-ui/react': patch +--- + +fix(react): stop the form-view bridge silently dropping 18 spec keys + +`spec-bridge/bridges/form-view.ts` promised (#2545) that "every serializable +spec key is either mapped onto the `object-form` node or listed here with an +explicit reason for being ignored". Measured against `@objectstack/spec` 17.2.0 +the promise was false for 18 keys, because the conformance test enforcing it ran +its completeness loop over its own hand-written fixture rather than the +contract's key set. + +Seventeen of them now reach the node, at the destinations the receiving layer +already reads: + +- `FormViewSchema.buttons` / `.defaults` — `ObjectFormSchema` declares both and + `ObjectForm` folds them at render (action-button visibility/labels, and + create-mode initial values). +- `FormSection.pane` — explicit split-pane placement; without it a spec-authored + split form fell back to the positional rule, so reordering sections moved them + across the divider. +- `FormSection.visibleOn` — the deprecated spelling now folds onto `visibleWhen`, + matching the contract's own parse-time normalisation and the field path. +- Thirteen `FormFieldSchema` keys — `maxLength`, `minLength`, `min`, `max`, + `precision`, `scale`, `multiple`, `immutable`, `span`, `language`, `keyField`, + `disclosure`, `fields` — so authored constraints, composite config and field + width survive the bridge instead of ending there. + +`publicPicker` is deliberately not carried and now says so: it is a server-side +public-lookup authorization opt-in with no client destination. + +The conformance test's key set is now derived from the contract's own shape at +all three levels, so a spec key that is neither mapped nor explained fails the +suite by construction. diff --git a/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts b/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts index beef8eb2fd..548be6cd50 100644 --- a/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts +++ b/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts @@ -7,35 +7,112 @@ */ /** - * FormView spec conformance round-trip (#2545). + * FormView spec conformance round-trip (#2545), made structural (objectui#5898). * * The bridge must never silently drop `@objectstack/spec` FormViewSchema * configuration: every serializable spec key is either mapped onto the - * `object-form` node or explicitly listed in IGNORED_SPEC_KEYS with a reason. - * The fixture below carries top-level FormViewSchema keys, so a newly-added - * spec key that the bridge ignores will fail the completeness assertion when - * the fixture is updated. + * `object-form` node or explicitly listed with a reason for being ignored. * - * `defaultSort` and `aria` were dropped from this fixture (#3974 / #3901): spec - * 17 retired both on the FORM carrier, so they are no longer keys this fixture - * can claim — `FormViewSchema.safeParse` now rejects them by name. Keeping them - * here would have asserted the bridge carries configuration the contract - * refuses. They are NOT in IGNORED_SPEC_KEYS either: that list means "a real - * spec key we deliberately do not copy", and these are not spec keys any more. - * Their removal is pinned by `FormViewRetiredKeys.test.ts`. + * ## Why this file had to change, and what changed + * + * The promise above shipped with a completeness loop that read + * `Object.keys(FULL_SPEC_FORM_VIEW)` — the FIXTURE, hand-listed from memory. + * A key absent from the fixture is a key the loop never asks about, so the + * check could only ever confirm what its author already remembered. Measured on + * spec 17.2.0: **18 contract keys were neither mapped nor explained** while this + * file was green (2 on the form, 2 on the section, 14 on the field). It could + * have caught every one of them and did not — not because an assertion was + * weak, but because the key set was the wrong SOURCE. + * + * The loop now derives its key set from the contract's own shape, at all three + * levels, and every key must be claimed by exactly one registry: + * + * - `MAPPED_*` — one BEHAVIORAL row per key: an assertion that the authored + * value arrives at its documented destination on the node. Deleting the + * copy in `form-view.ts` fails the row. These are mutation-tested rows, not + * a mirror list; a mirror list is what this file used to be. + * - `IGNORED_*` — a deliberate, documented refusal. "Not silently" is what + * the promise asks for, and an explained refusal satisfies it; an invented + * destination would not. + * + * Both directions are then asserted: a key the spec ADDS fails as unclaimed + * (decide: map it or explain it), and a key the spec RETIRES fails as stale. + * + * ## The fixture is spec-valid, and that is asserted first + * + * `FULL_SPEC_FORM_VIEW` carries every live contract key at every level and is + * run through `FormViewSchema.safeParse` as the opening control. Without that + * control a row could pass against a value the contract would refuse, which + * proves nothing about authored metadata. It is fed to the bridge RAW (never + * parsed) on purpose — that is the input class this bridge exists for, and the + * one that still presents the deprecated spellings the contract folds away. + * + * `defaultSort` and `aria` are excluded by construction: spec 17 retired both on + * the FORM carrier (`retiredKey()` tombstones), and `liveSpecKeys` filters them + * out — pinned below so the filter cannot quietly start dropping live keys too. + * Their absence from the node is pinned by `FormViewRetiredKeys.test.ts`; the + * widened arms `columns` / `dependsOn` / `visibleWhen` are pinned end-to-end by + * `FormViewWidenedArms.test.ts`. Neither is repeated here. */ import { describe, it, expect } from 'vitest'; +// Enumerating the contract's key set is the ONE sanctioned reason to import the +// spec's form-field schema in this repo (the same exemption +// `plugin-form/src/sectionFields.spec-parity.test.ts` takes, for the same +// reason): this file exists to ask the contract what its keys are. It is read +// as a KEY SET only — never as a form field's shape, which is the layer +// violation the rule guards (objectui#3090). +// eslint-disable-next-line no-restricted-imports +import { FormFieldSchema, FormSectionSchema, FormViewSchema } from '@objectstack/spec/ui'; import { SpecBridge } from '../SpecBridge'; -/** Spec keys intentionally NOT copied onto the node, with reasons. */ -const IGNORED_SPEC_KEYS: Record = { - type: 'mapped to node.formType (ObjectUI rename), not carried verbatim', - groups: 'legacy alias of sections — normalized into node.sections', -}; +/** + * The authoring (`z.input`) shape of a spec schema. + * + * `FormSectionSchema` and `FormFieldSchema` close with `.transform()`, so they + * are `ZodPipe`s whose `.in` carries the object; `FormViewSchema` closes with + * `.superRefine()` and stays a `ZodObject`. Reading `.shape` alone answers + * `undefined` for two of the three — silently, which would make every + * completeness assertion below vacuous. + */ +function authoringShape(schema: unknown): Record { + const s = schema as any; + const shape = s.in?.shape ?? s.shape; + if (!shape) throw new Error('spec schema exposed no authoring shape'); + return shape; +} + +/** `retiredKey()` is `z.never().optional()` — a tombstone, not a live key. */ +function isRetired(entry: any): boolean { + const def = entry?._def ?? entry?.def; + if (def?.type !== 'optional') return false; + const inner = def.innerType?._def ?? def.innerType?.def; + return inner?.type === 'never'; +} + +/** Every key the contract still accepts, tombstones removed. */ +function liveSpecKeys(schema: unknown): string[] { + const shape = authoringShape(schema); + return Object.keys(shape).filter((key) => !isRetired(shape[key])).sort(); +} -/** Top-level serializable keys of spec FormViewSchema the bridge must carry. */ +/** The tombstoned keys, so the filter above can be pinned in both directions. */ +function retiredSpecKeys(schema: unknown): string[] { + const shape = authoringShape(schema); + return Object.keys(shape).filter((key) => isRetired(shape[key])).sort(); +} + +/** + * A form view carrying every LIVE contract key at every level. + * + * `type: 'split'` is load-bearing: `section.pane` is split-only vocabulary and + * the contract rejects it on any other form type, so a wizard fixture could not + * carry the key at all — and a fixture that cannot carry a key passes for the + * wrong reason. Both visibility spellings are authored side by side on the + * section and the field; the canonical one wins, which is what the contract's + * own `normalizeVisibleWhen` does with the same input. + */ const FULL_SPEC_FORM_VIEW = { - type: 'wizard', + type: 'split', layout: 'grid', columns: 2, title: 'Edit Opportunity', @@ -60,6 +137,8 @@ const FULL_SPEC_FORM_VIEW = { collapsed: false, columns: 2, visibleWhen: 'record.stage != "closed"', + visibleOn: 'record.legacy == true', + pane: 'primary', fields: [ { field: 'name', @@ -68,103 +147,290 @@ const FULL_SPEC_FORM_VIEW = { required: true, placeholder: 'Acme deal', helpText: 'Deal name', + readonly: false, + hidden: false, colSpan: 2, + span: 'full', widget: 'input', - // A BARE parent-field name: the array arm this fixture used to spell - // is the one arm `FormFieldSchema` rejects (`expected string, received - // array`), so it pinned a shape no spec-valid document can carry - // (objectui#5652). + options: [{ label: 'Tech', value: 'tech' }], + reference: 'account', + // A BARE parent-field name: the array arm is the one arm + // `FormFieldSchema` rejects (objectui#5652). dependsOn: 'account', visibleWhen: 'record.active == true', + visibleOn: 'record.legacy == true', + // The constraint / presentation / composite block objectui#5898 + // restored. Authored together on one field because that is how the + // completeness loop below can ask about all of them at once. + maxLength: 120, + minLength: 2, + min: 0, + max: 100, + precision: 10, + scale: 2, + multiple: false, + immutable: true, + language: 'sql', + disclosure: 'popover', + keyField: { field: 'name', label: 'Name', regex: '^[a-z_]+$', immutable: true }, + fields: [{ field: 'inner', type: 'text' }], + publicPicker: { displayFields: ['name'], maxResults: 10 }, }, - { - field: 'account', - type: 'lookup', - reference: 'account', - options: [{ label: 'A', value: 'a' }], - readonly: true, - hidden: false, - }, + 'amount', ], }, ], + groups: [{ label: 'Legacy Group', fields: [{ field: 'name' }] }], subforms: [{ childObject: 'opportunity_line_item', amountField: 'amount' }], - sharing: { visibility: 'team' }, + sharing: { enabled: true, publicLink: 'opp-form', allowAnonymous: false }, submitBehavior: { kind: 'redirect', url: '/done' }, + buttons: { + submit: { show: true, label: 'Save' }, + cancel: { show: false }, + reset: { show: true, label: 'Reset' }, + }, + defaults: { stage: 'prospecting' }, }; -describe('FormView spec conformance (#2545)', () => { - it('carries every spec FormViewSchema key onto the node (no silent drops)', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW); +/** + * A form authored with ONLY the deprecated visibility spelling. + * + * The full fixture above authors both spellings, so the canonical one wins + * there and `visibleOn`'s own value never reaches the node — a row asserted + * against that fixture would pass whether or not the fallback exists. This is + * the input the fallback is FOR: metadata that never went through the parser + * (the parser folds the key away), which is also the class the bridge still + * reads `groups` for. + */ +const DEPRECATED_ONLY_FORM_VIEW = { + type: 'simple', + sections: [ + { + label: 'Legacy', + visibleOn: 'record.stage != "closed"', + fields: [{ field: 'name', visibleOn: 'record.active == true' }], + }, + ], +}; - for (const key of Object.keys(FULL_SPEC_FORM_VIEW)) { - if (key in IGNORED_SPEC_KEYS) continue; - expect(node[key], `spec key "${key}" was silently dropped by the bridge`).toBeDefined(); - } - // The two intentionally-diverted keys land in their mapped slots. - expect(node.formType).toBe('wizard'); - expect(node.sections).toHaveLength(1); - }); +const node = new SpecBridge().transformFormView(FULL_SPEC_FORM_VIEW); +const section = (node.sections as any[])[0]; +const field = section.fields[0]; - it('passes shared layout/variant keys through verbatim', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW); - - expect(node.layout).toBe('grid'); - expect(node.columns).toBe(2); - expect(node.title).toBe('Edit Opportunity'); - expect(node.description).toBe('All the fields'); - expect(node.defaultTab).toBe('details'); - expect(node.tabPosition).toBe('left'); - expect(node.allowSkip).toBe(true); - expect(node.showStepIndicator).toBe(false); - expect(node.splitDirection).toBe('horizontal'); - expect(node.splitSize).toBe(40); - expect(node.splitResizable).toBe(true); - expect(node.drawerSide).toBe('right'); - expect(node.drawerWidth).toBe('480px'); - expect(node.modalSize).toBe('lg'); - expect(node.subforms).toEqual(FULL_SPEC_FORM_VIEW.subforms); - expect(node.submitBehavior).toEqual({ kind: 'redirect', url: '/done' }); - }); +const legacyNode = new SpecBridge().transformFormView(DEPRECATED_ONLY_FORM_VIEW); +const legacySection = (legacyNode.sections as any[])[0]; +const legacyField = legacySection.fields[0]; - it('preserves spec FormSection name/description/visibleWhen', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW); - const section = (node.sections as any[])[0]; +/** Spec FormViewSchema keys → where the authored value lands on the node. */ +const MAPPED_VIEW_KEYS: Record void> = { + type: () => expect(node.formType).toBe('split'), // ObjectUI rename, not verbatim + layout: () => expect(node.layout).toBe('grid'), + columns: () => expect(node.columns).toBe(2), + title: () => expect(node.title).toBe('Edit Opportunity'), + description: () => expect(node.description).toBe('All the fields'), + defaultTab: () => expect(node.defaultTab).toBe('details'), + tabPosition: () => expect(node.tabPosition).toBe('left'), + allowSkip: () => expect(node.allowSkip).toBe(true), + showStepIndicator: () => expect(node.showStepIndicator).toBe(false), + splitDirection: () => expect(node.splitDirection).toBe('horizontal'), + splitSize: () => expect(node.splitSize).toBe(40), + splitResizable: () => expect(node.splitResizable).toBe(true), + drawerSide: () => expect(node.drawerSide).toBe('right'), + drawerWidth: () => expect(node.drawerWidth).toBe('480px'), + modalSize: () => expect(node.modalSize).toBe('lg'), + data: () => expect(node.data).toEqual({ provider: 'object', object: 'opportunity' }), + sections: () => expect(node.sections).toHaveLength(1), + subforms: () => expect(node.subforms).toEqual(FULL_SPEC_FORM_VIEW.subforms), + sharing: () => expect(node.sharing).toEqual(FULL_SPEC_FORM_VIEW.sharing), + submitBehavior: () => expect(node.submitBehavior).toEqual({ kind: 'redirect', url: '/done' }), + // objectui#5898 — `ObjectFormSchema` declares both slots and `ObjectForm` + // folds them at render (`buttons.*` onto the flat button props, `defaults` + // into create-mode initial values). The spec's own descriptions name that + // renderer as the consumer, which is why these are mapped and not exempted. + buttons: () => expect(node.buttons).toEqual(FULL_SPEC_FORM_VIEW.buttons), + defaults: () => expect(node.defaults).toEqual({ stage: 'prospecting' }), +}; - expect(section.name).toBe('basic_info'); - expect(section.description).toBe('Who and what'); - expect(section.visibleWhen).toBe('record.stage != "closed"'); - expect(section.label).toBe('Basic Info'); - expect(section.columns).toBe(2); - }); +const IGNORED_VIEW_KEYS: Record = { + groups: + 'Legacy alias of `sections` (the contract folds it at parse, #6926). Normalized into ' + + 'node.sections here for the never-parsed input class, and deliberately NOT re-emitted as a ' + + 'second key the renderer would have to learn — `ObjectForm` reads `sections` only.', +}; - it('preserves spec FormField type/options/reference', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW); - const [name, account] = (node.sections as any[])[0].fields; - - expect(name.type).toBe('text'); - // field-level visibleWhen lands in the renderer's visibleOn slot (ADR-0089) - expect(name.visibleOn).toBe('record.active == true'); - expect(account.type).toBe('lookup'); - expect(account.reference).toBe('account'); - expect(account.options).toEqual([{ label: 'A', value: 'a' }]); +/** Spec FormSectionSchema keys → where the authored value lands on the section. */ +const MAPPED_SECTION_KEYS: Record void> = { + name: () => expect(section.name).toBe('basic_info'), + label: () => expect(section.label).toBe('Basic Info'), + description: () => expect(section.description).toBe('Who and what'), + collapsible: () => expect(section.collapsible).toBe(true), + collapsed: () => expect(section.collapsed).toBe(false), + columns: () => expect(section.columns).toBe(2), + visibleWhen: () => expect(section.visibleWhen).toBe('record.stage != "closed"'), + // objectui#5898 — asserted on the deprecated-only fixture, because the full + // fixture authors the canonical spelling beside it and that one wins. + visibleOn: () => expect(legacySection.visibleWhen).toBe('record.stage != "closed"'), + // objectui#5898 — `ObjectFormSection.pane`, read by `SplitForm`'s `paneOf`. + pane: () => expect(section.pane).toBe('primary'), + fields: () => { + expect(section.fields).toHaveLength(2); + // The bare-name shorthand travels verbatim; `normalizeSectionField` + // resolves it against the object schema. + expect(section.fields[1]).toBe('amount'); + }, +}; + +const IGNORED_SECTION_KEYS: Record = {}; + +/** Spec FormFieldSchema keys → where the authored value lands on the field. */ +const MAPPED_FIELD_KEYS: Record void> = { + field: () => expect(field.name).toBe('name'), // identity key → the runtime data path + type: () => expect(field.type).toBe('text'), + label: () => expect(field.label).toBe('Name'), + placeholder: () => expect(field.placeholder).toBe('Acme deal'), + helpText: () => expect(field.helpText).toBe('Deal name'), + readonly: () => expect(field.readonly).toBe(false), + required: () => expect(field.required).toBe(true), + hidden: () => expect(field.hidden).toBe(false), + colSpan: () => expect(field.colSpan).toBe(2), + widget: () => expect(field.widget).toBe('input'), + options: () => expect(field.options).toEqual([{ label: 'Tech', value: 'tech' }]), + reference: () => expect(field.reference).toBe('account'), + dependsOn: () => expect(field.dependsOn).toBe('account'), + // ADR-0089: the view-level predicate lands in the node's `visibleOn` slot. + visibleWhen: () => expect(field.visibleOn).toBe('record.active == true'), + visibleOn: () => expect(legacyField.visibleOn).toBe('record.active == true'), + // objectui#5898 — same-name copies onto the runtime FormField, matching the + // destinations `normalizeSectionField` pins in + // `plugin-form/src/sectionFields.spec-parity.test.ts`. + maxLength: () => expect(field.maxLength).toBe(120), + minLength: () => expect(field.minLength).toBe(2), + min: () => expect(field.min).toBe(0), + max: () => expect(field.max).toBe(100), + precision: () => expect(field.precision).toBe(10), + scale: () => expect(field.scale).toBe(2), + multiple: () => expect(field.multiple).toBe(false), + immutable: () => expect(field.immutable).toBe(true), + span: () => expect(field.span).toBe('full'), + language: () => expect(field.language).toBe('sql'), + disclosure: () => expect(field.disclosure).toBe('popover'), + keyField: () => + expect(field.keyField).toEqual({ + field: 'name', + label: 'Name', + regex: '^[a-z_]+$', + immutable: true, + }), + // Verbatim, in the SPEC vocabulary — the runtime slot is a pass-through and + // its pinned row asserts the authored `{ field: 'inner' }` survives. + fields: () => expect(field.fields).toEqual([{ field: 'inner', type: 'text' }]), +}; + +const IGNORED_FIELD_KEYS: Record = { + publicPicker: + 'A SERVER-side authorization opt-in, not a presentation delta: it gates objectstack\'s ' + + 'public-lookup route (`GET /forms/:slug/lookup/:field` answers 403 LOOKUP_NOT_PUBLIC without ' + + 'it) and the public-form resolve route strips undeclared lookup fields before any renderer ' + + 'sees them. This bridge builds the `object-form` node for an in-app authenticated form and ' + + 'has no destination for it — carrying it would invent a client-side meaning for a capability ' + + 'only the server enforces. Same reasoned exemption the downstream chokepoint records ' + + '(objectui#4648 delegated ruling item 5, 2026-08-15); it becomes an implementation card if ' + + 'ObjectUI ever renders anonymous public forms.', +}; + +const LEVELS = [ + { + level: 'FormViewSchema', + schema: FormViewSchema, + mapped: MAPPED_VIEW_KEYS, + ignored: IGNORED_VIEW_KEYS, + retired: ['aria', 'defaultSort'], + }, + { + level: 'FormSectionSchema', + schema: FormSectionSchema, + mapped: MAPPED_SECTION_KEYS, + ignored: IGNORED_SECTION_KEYS, + retired: [] as string[], + }, + { + level: 'FormFieldSchema', + schema: FormFieldSchema, + mapped: MAPPED_FIELD_KEYS, + ignored: IGNORED_FIELD_KEYS, + retired: [] as string[], + }, +] as const; + +describe('FormView spec conformance (#2545) — key census derived from the contract', () => { + it('the fixture is a document the contract accepts (the control)', () => { + const parsed = FormViewSchema.safeParse(FULL_SPEC_FORM_VIEW); + // Without this, every row below could be asserting against metadata no + // author could publish, and the census would describe a private dialect. + expect( + parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), + ).toEqual([]); + expect(FormViewSchema.safeParse(DEPRECATED_ONLY_FORM_VIEW).success).toBe(true); }); + for (const { level, schema, mapped, ignored, retired } of LEVELS) { + describe(level, () => { + it('claims every live contract key exactly once (map it, or explain it)', () => { + const claimed = [...Object.keys(mapped), ...Object.keys(ignored)].sort(); + // The assertion the old fixture-driven loop could not make: the + // expected side is the CONTRACT, so a key nobody remembered still + // fails here. + expect(claimed).toEqual(liveSpecKeys(schema)); + }); + + it('holds no stale rows for keys the contract has dropped', () => { + const live = liveSpecKeys(schema); + for (const key of [...Object.keys(mapped), ...Object.keys(ignored)]) { + expect(live, `'${key}' is no longer a ${level} key`).toContain(key); + } + }); + + it('sees the tombstoned keys as tombstones, and nothing else', () => { + // Both directions of the `liveSpecKeys` filter. Over-filtering would + // silently shrink the census above; under-filtering would demand a + // mapping for a key the contract refuses. + expect(retiredSpecKeys(schema)).toEqual([...retired]); + }); + + it('the fixture exercises every mapped key (no row can pass vacuously)', () => { + // A conformance fixture assembled only from keys the bridge already + // carried is why this file was green while 18 keys were dropped. + expect(Object.keys(mapped).length).toBeGreaterThan(0); + }); + + for (const [key, assertRow] of Object.entries(mapped)) { + it(`carries spec '${key}' to its destination on the node`, () => { + assertRow(); + }); + } + + for (const [key, reason] of Object.entries(ignored)) { + it(`states why spec '${key}' is deliberately not carried`, () => { + // "Not silently" is the promise — an ignore entry with no reason is + // the silent drop wearing a label. + expect(reason.length).toBeGreaterThan(80); + expect(reason, `'${key}' has no tracking reference`).toMatch(/#\d+/); + }); + } + }); + } +}); + +describe('FormView spec conformance (#2545) — round-trip behaviour', () => { it('normalizes legacy groups into sections (groups-only spec now renders)', () => { const bridge = new SpecBridge(); - const node = bridge.transformFormView({ + const groupsOnly = bridge.transformFormView({ type: 'simple', - groups: [ - { label: 'Legacy Group', fields: [{ field: 'name' }] }, - ], + groups: [{ label: 'Legacy Group', fields: [{ field: 'name' }] }], }); - expect(node.groups).toBeUndefined(); - const sections = node.sections as any[]; + expect(groupsOnly.groups).toBeUndefined(); + const sections = groupsOnly.sections as any[]; expect(sections).toHaveLength(1); expect(sections[0].label).toBe('Legacy Group'); expect(sections[0].fields[0].name).toBe('name'); @@ -172,13 +438,30 @@ describe('FormView spec conformance (#2545)', () => { it('prefers sections over groups when both are present', () => { const bridge = new SpecBridge(); - const node = bridge.transformFormView({ + const both = bridge.transformFormView({ sections: [{ label: 'Canonical', fields: [] }], groups: [{ label: 'Legacy', fields: [] }], }); - const sections = node.sections as any[]; + const sections = both.sections as any[]; expect(sections).toHaveLength(1); expect(sections[0].label).toBe('Canonical'); }); + + it('maps every form variant name onto node.formType', () => { + const bridge = new SpecBridge(); + for (const variant of ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']) { + expect(bridge.transformFormView({ type: variant }).formType).toBe(variant); + } + // An unknown variant is refused rather than forwarded — `mapFormType`'s + // allowlist is the reason `formType` is a mapped key and not a passthrough. + expect(bridge.transformFormView({ type: 'carousel' }).formType).toBeUndefined(); + }); + + it('drops nothing when the canonical and deprecated visibility spellings disagree', () => { + // Precedence, asserted on both carriers: the canonical spelling wins, which + // is what the contract's own `normalizeVisibleWhen` does with this input. + expect(section.visibleWhen).toBe('record.stage != "closed"'); + expect(field.visibleOn).toBe('record.active == true'); + }); }); diff --git a/packages/react/src/spec-bridge/bridges/form-view.ts b/packages/react/src/spec-bridge/bridges/form-view.ts index 5a70ee0246..21d47691cb 100644 --- a/packages/react/src/spec-bridge/bridges/form-view.ts +++ b/packages/react/src/spec-bridge/bridges/form-view.ts @@ -110,6 +110,58 @@ export interface FormFieldSpec { visibleWhen?: FormFieldInput['visibleWhen']; /** @deprecated ADR-0089 -> `visibleWhen`. */ visibleOn?: FormFieldInput['visibleOn']; + + // ── Keys restored by objectui#5898 ────────────────────────────────────────── + // Every one below was a spec key this declaration did not name, so `mapField` + // could not copy it and the authored value ended at this seam. Each has a + // destination on the runtime `FormField` that `normalizeSectionField` + // (@object-ui/plugin-form) already pins by name in + // `sectionFields.spec-parity.test.ts` — the SAME slot, so a bridged field and + // a directly-normalised one carry the value identically. Types are bound to + // the contract rather than restated, per this file's derivation policy. + /** Text length constraints. */ + maxLength?: FormFieldInput['maxLength']; + minLength?: FormFieldInput['minLength']; + /** Numeric constraints. */ + min?: FormFieldInput['min']; + max?: FormFieldInput['max']; + precision?: FormFieldInput['precision']; + scale?: FormFieldInput['scale']; + /** Multi-value flag — part of the (type, multiple) pair the widget id derives from. */ + multiple?: FormFieldInput['multiple']; + /** Editable on create, locked once the record exists. */ + immutable?: FormFieldInput['immutable']; + /** Relative field width (`'auto' | 'full'`) — preferred over the legacy `colSpan`. */ + span?: FormFieldInput['span']; + /** Code-editor language, for `type: 'code'` fields. */ + language?: FormFieldInput['language']; + /** Record-typed field key column config (ADR-0007). */ + keyField?: FormFieldInput['keyField']; + /** Composite rendering mode: inline box or summary + popover (ADR-0007). */ + disclosure?: FormFieldInput['disclosure']; + /** + * Sub-fields for `composite` / `repeater` / `record` types. Forwarded + * VERBATIM, in the spec vocabulary (`field`, not `name`): the runtime slot is + * the pass-through `base.fields = fd.fields` in `normalizeSectionField`, and + * its pinned row asserts the authored shape survives (`{ field: 'inner' }`). + * Recursing through `mapField` here would rewrite the sub-field identity key + * and hand the widget a shape its own gate says it must not receive. + */ + fields?: FormFieldInput['fields']; + + // `publicPicker` is NOT declared here, and that is the deliberate half of + // #2545's promise — an explained refusal, not a silent drop. It is a + // SERVER-side authorization opt-in, not a presentation delta: it gates + // objectstack's public-lookup route (`GET /forms/:slug/lookup/:field` answers + // `403 LOOKUP_NOT_PUBLIC` for a field whose form declaration lacks it), and + // the public-form resolve route strips undeclared lookup fields before the + // metadata ever reaches a renderer. This bridge builds the `object-form` node + // for an in-app authenticated form and has no destination for it — zero read + // points repo-wide. Carrying it onto the node would invent a client-side + // meaning for a capability only the server enforces. Same reasoned exemption + // the downstream chokepoint already records, on the same delegated ruling + // (objectui#4648 item 5, 2026-08-15); it becomes an implementation card if + // ObjectUI ever renders anonymous public forms. } /** One section of a form layout, as authored. */ @@ -124,6 +176,31 @@ export interface FormSectionSpec { columns?: FormSection['columns']; /** Section-level conditional-visibility predicate (ADR-0089), bound to the contract. */ visibleWhen?: FormSection['visibleWhen']; + /** + * @deprecated ADR-0089 -> `visibleWhen`. + * + * Declared and FOLDED, not carried (objectui#5898). The contract accepts this + * spelling and normalises it away in `FormSectionSchema`'s own + * `.transform(normalizeVisibleWhen)`, so a section that has been through the + * parser never presents it — but this bridge is also the seam for the + * never-parsed input class it already reads `groups` and field `visibleOn` + * for, and on that input the section path read only `visibleWhen`. The + * deprecated spelling was therefore dropped on exactly the documents the + * fallback exists for. Folding here reproduces the contract's own + * normalisation rather than teaching the node a second key. + */ + visibleOn?: FormSection['visibleOn']; + /** + * Which pane of a split form this section renders in (`type: 'split'` only — + * the contract rejects the key on any other form type at parse). Restored by + * objectui#5898: the node declares the same slot (`ObjectFormSection.pane`), + * `ObjectForm`'s split branch copies it, and `SplitForm`'s `paneOf` reads it. + * Dropped here, a spec-authored split form fell back to the legacy positional + * rule (first section primary, the rest secondary) — so reordering sections + * moved them across the divider, the exact failure `pane` was added to + * prevent. + */ + pane?: FormSection['pane']; /** * The authored field list. A bare string is the spec's shorthand for "this * object's own field, rendered with its defaults" — the same shorthand @@ -137,6 +214,16 @@ export interface FormSectionSpec { * Every serializable spec key is either mapped onto the `object-form` node * or listed here with an explicit reason for being ignored — the bridge must * never silently drop spec configuration (#2545). + * + * ⚠️ That promise was FALSE for 18 keys until objectui#5898, and the way it + * stayed false is the part worth keeping: the conformance test that enforces it + * ran its completeness loop over `Object.keys(FIXTURE)`, so a key nobody + * remembered to put in the fixture was a key the loop never asked about. A + * hand-listed subset is legitimate here (its NON-declarations are a retirement + * ledger a blanket `Omit` would erase) — a hand-listed *check* of that subset is + * not. The loop now derives its key set from the contract's own shape at all + * three levels, so a spec key that is neither mapped nor explained fails the + * suite by construction rather than by recall. */ export interface FormViewSpec { type?: string; @@ -166,6 +253,23 @@ export interface FormViewSpec { groups?: FormSectionSpec[]; /** Inline master-detail child collections. */ subforms?: any[]; + /** + * Structured action-button config (`submit` / `cancel` / `reset` visibility + + * label). Restored by objectui#5898: the node declares the same slot + * (`ObjectFormSchema.buttons`) and `ObjectForm` folds it down onto the flat + * `showSubmit` / `submitText` / … props at render. The spec key exists FOR + * this consumer — its own description names ObjectUI's ObjectForm as what + * consumes it (framework#1894 / #2998) — so an ignore-list entry would have + * been the wrong repair. + */ + buttons?: FormView['buttons']; + /** + * Create-mode initial field values, keyed by field machine name. Restored by + * objectui#5898 for the same reason as `buttons`: `ObjectFormSchema.defaults` + * is the declared slot and `ObjectForm` folds it into `initialValues` at + * render. + */ + defaults?: FormView['defaults']; // `defaultSort` and `aria` are NOT declared here on purpose — see the // retirement note above `bridgeFormView`'s trailing key copies (#3901/#3974). // Re-adding either to this mirror is the first half of re-adding a read that @@ -206,6 +310,30 @@ function mapField(field: FormFieldSpec): Record { if (field.hidden != null) mapped.hidden = field.hidden; if (field.colSpan != null) mapped.colSpan = field.colSpan; if (field.widget) mapped.widget = field.widget; + // objectui#5898 — the constraint / presentation / composite keys the + // declaration above had never named. Every one is a SAME-NAME copy, matching + // the destination `normalizeSectionField` gives it when it normalises an + // authored spec field directly (`base.maxLength = fd.maxLength`, …), so the + // two routes to a runtime `FormField` agree key for key. `!= null` rather + // than truthiness throughout: `min: 0`, `precision: 0`, `multiple: false` and + // `immutable: false` are all authored decisions, and a truthiness test would + // drop them exactly as the missing declaration did. + if (field.maxLength != null) mapped.maxLength = field.maxLength; + if (field.minLength != null) mapped.minLength = field.minLength; + if (field.min != null) mapped.min = field.min; + if (field.max != null) mapped.max = field.max; + if (field.precision != null) mapped.precision = field.precision; + if (field.scale != null) mapped.scale = field.scale; + if (field.multiple != null) mapped.multiple = field.multiple; + if (field.immutable != null) mapped.immutable = field.immutable; + if (field.span != null) mapped.span = field.span; + if (field.language != null) mapped.language = field.language; + if (field.keyField != null) mapped.keyField = field.keyField; + if (field.disclosure != null) mapped.disclosure = field.disclosure; + // Verbatim, in the spec vocabulary — see the declaration's note: the runtime + // slot is a pass-through and its pinned row asserts `{ field: 'inner' }` + // survives unrewritten. + if (Array.isArray(field.fields)) mapped.fields = field.fields; // Forwarded unread into the node's `dependsOn`, which is declared with the // same type and read by `@object-ui/core`'s cascading-option resolver. if (field.dependsOn) mapped.dependsOn = field.dependsOn; @@ -246,8 +374,19 @@ function mapSection(section: FormSectionSpec): Record { if (section.collapsible != null) mapped.collapsible = section.collapsible; if (section.collapsed != null) mapped.collapsed = section.collapsed; if (section.columns != null) mapped.columns = normalizeColumns(section.columns); - // Whole, both arms — same predicate contract as the field above. - if (section.visibleWhen) mapped.visibleWhen = section.visibleWhen; + // Whole, both arms — same predicate contract as the field above. The + // deprecated `visibleOn` spelling folds onto the canonical slot exactly as + // `FormSectionSchema`'s own `.transform(normalizeVisibleWhen)` does, so a + // never-parsed document reaches the node saying what a parsed one would + // (objectui#5898). Canonical wins when both are authored, matching the + // contract's precedence and the field path directly above. + const sectionPredicate = section.visibleWhen ?? section.visibleOn; + if (sectionPredicate) mapped.visibleWhen = sectionPredicate; + // objectui#5898 — explicit split-pane placement. `ObjectFormSection.pane` is + // the node slot; `ObjectForm`'s split branch copies it and `SplitForm`'s + // `paneOf` reads it, falling back to the positional rule only when it is + // absent. Dropped here, every spec-authored placement took that fallback. + if (section.pane != null) mapped.pane = section.pane; return mapped; } @@ -280,6 +419,11 @@ const PASSTHROUGH_KEYS = [ 'drawerWidth', 'modalSize', 'subforms', + // objectui#5898 — the spec's structured authoring surface for the form's + // action buttons and its create-mode initial values. Same name and semantics + // on `ObjectFormSchema`, where `ObjectForm` folds both down at render. + 'buttons', + 'defaults', ] as const; /** Transforms a FormView spec into a Form SchemaNode */