diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 2a199fb07..aa5bca57c 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -815,21 +815,14 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple "fields": ["firstName", "lastName", "email", "phone", "company"], "sections": [ { - "title": "Basic Info", + "label": "Basic Info", "fields": ["firstName", "lastName", "email"] }, { - "title": "Details", + "label": "Details", "fields": ["phone", "company", "address"] } ], - "groups": [ - { - "title": "Contact Information", - "fields": ["firstName", "lastName", "email"], - "collapsible": true - } - ], "showSubmit": true, "submitText": "Create Contact", "showCancel": true, @@ -841,12 +834,12 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple |----------|------|-------------| | `objectName` | `string` | **Required.** ObjectQL object API name. | | `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. | -| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. | +| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. | | `recordId` | `string \| number` | Record ID for edit/view modes. | | `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). | | `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. | -| `sections` | `ObjectFormSection[]` | Tabbed/wizard sections. | -| `groups` | `array` | Collapsible field groups. | +| `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. | +| `groups` | `array` | **Deprecated.** Legacy alias of `sections` (spec defines `groups` as an alias); normalized into `sections` when `sections` is absent. Legacy shape: `title`→`label`, `defaultCollapsed`→`collapsed`. | | `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`, `"inline"`, `"grid"`. | | `columns` | `number` | Number of form columns. | | `submitText` / `cancelText` | `string` | Button labels. | @@ -854,6 +847,14 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple | `drawerSide` | `string` | Drawer position: `"top"`, `"bottom"`, `"left"`, `"right"`. | | `modalSize` | `string` | Modal size: `"sm"`, `"default"`, `"lg"`, `"xl"`, `"full"`. | +#### Spec alignment & extension keys + +`ObjectFormSchema` keys fall into three classes (#2545): + +- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). Metadata using these keys round-trips through the SpecBridge without loss. +- **ObjectUI extensions** — serializable extras with no spec backing yet: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `className`, `initialValues`, `fields`, `customFields`. Sanctioned and documented here; candidates for upstreaming into the spec are tracked in #2545. +- **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). + **Related:** [FormSchema](#formschema), [ObjectViewSchema](#objectviewschema) --- diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 8d952b24e..e8203f04b 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -83,13 +83,31 @@ export const ObjectForm: React.FC = ({ // (Tabbed/Wizard/Split/Drawer/Modal/Simple) transparently honour FLS. // Fail-open when no provider mounted (perms.isLoaded false). const schema = useMemo(() => { - if (!perms?.isLoaded) return rawSchema; + // #2545: spec FormViewSchema defines `groups` as a legacy alias of + // `sections`, and this renderer only ever consumes `sections` — normalize + // FIRST so groups-only metadata actually renders (it used to be silently + // ignored). Legacy shape maps `title`→`label`, `defaultCollapsed`→`collapsed`. + const legacyGroups = (rawSchema as any).groups; + const base: ObjectFormProps['schema'] = + !rawSchema.sections?.length && Array.isArray(legacyGroups) && legacyGroups.length + ? { + ...rawSchema, + sections: legacyGroups.map((g: any) => ({ + label: g.title ?? g.label, + description: g.description, + collapsible: g.collapsible, + collapsed: g.defaultCollapsed ?? g.collapsed, + fields: g.fields ?? [], + })), + } + : rawSchema; + if (!perms?.isLoaded) return base; const gateField = (f: any) => { if (!f?.name) return f; - const canRead = perms.checkField(rawSchema.objectName, f.name, 'read'); + const canRead = perms.checkField(base.objectName, f.name, 'read'); if (!canRead) return null; - const canWrite = perms.checkField(rawSchema.objectName, f.name, 'write'); - if (!canWrite && rawSchema.mode !== 'view') { + const canWrite = perms.checkField(base.objectName, f.name, 'write'); + if (!canWrite && base.mode !== 'view') { return { ...f, readOnly: true, disabled: true }; } return f; @@ -97,9 +115,9 @@ export const ObjectForm: React.FC = ({ const filterArr = (arr?: any[]) => Array.isArray(arr) ? arr.map(gateField).filter(Boolean) : arr; return { - ...rawSchema, - fields: filterArr(rawSchema.fields as any[]), - sections: rawSchema.sections?.map((s: any) => ({ + ...base, + fields: filterArr(base.fields as any[]), + sections: base.sections?.map((s: any) => ({ ...s, fields: filterArr(s.fields), })), diff --git a/packages/plugin-form/src/__tests__/groupsAlias.test.tsx b/packages/plugin-form/src/__tests__/groupsAlias.test.tsx new file mode 100644 index 000000000..044b8d437 --- /dev/null +++ b/packages/plugin-form/src/__tests__/groupsAlias.test.tsx @@ -0,0 +1,74 @@ +/** + * ObjectForm `groups` → `sections` normalization (#2545). + * + * `@objectstack/spec` FormViewSchema defines `groups` as a legacy alias of + * `sections`, but ObjectForm only ever consumed `sections` — so a schema that + * declared only `groups` silently rendered an ungrouped form. These tests lock + * in the normalization (legacy shape: `title`→`label`, + * `defaultCollapsed`→`collapsed`) and its precedence (`sections` wins). + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +function buildMockDataSource() { + return { + create: vi.fn(async (_obj: string, data: Record) => ({ id: '1', ...data })), + update: vi.fn(), + delete: vi.fn(), + findOne: vi.fn(), + find: vi.fn(async () => ({ data: [], total: 0 })), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + label: name, + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + }, + })), + } as any; +} + +describe('ObjectForm groups → sections normalization (#2545)', () => { + it('renders legacy groups-only schema as sections (was silently ignored)', async () => { + render( + , + ); + + // The legacy group's `title` must surface as a section label. + expect(await screen.findByText('Legacy Group Title')).toBeTruthy(); + }); + + it('prefers sections over groups when both are declared', async () => { + render( + , + ); + + expect(await screen.findByText('Canonical Section')).toBeTruthy(); + expect(screen.queryByText('Legacy Group')).toBeNull(); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 3059a0a6b..604c1eeed 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -888,6 +888,10 @@ export const ObjectView: React.FC = ({ description: schema.form?.description, fields: schema.form?.fields, customFields: schema.form?.customFields, + // #2545: `sections` is the spec-aligned key (it used to be dropped + // here); `groups` is its deprecated legacy alias, normalized to + // sections inside ObjectForm. + sections: schema.form?.sections, groups: schema.form?.groups, layout: schema.form?.layout, columns: schema.form?.columns, diff --git a/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts b/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts new file mode 100644 index 000000000..e89b6b93a --- /dev/null +++ b/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts @@ -0,0 +1,174 @@ +/** + * 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. + */ + +/** + * FormView spec conformance round-trip (#2545). + * + * 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 every top-level FormViewSchema key (spec 14.6.0 / + * 15.0.0 — identical key sets), so a newly-added spec key that the bridge + * ignores will fail the completeness assertion when the fixture is updated. + */ +import { describe, it, expect } from 'vitest'; +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', +}; + +/** Every top-level serializable key of spec FormViewSchema (14.6.0 / 15.0.0). */ +const FULL_SPEC_FORM_VIEW = { + type: 'wizard', + layout: 'grid', + columns: 2, + title: 'Edit Opportunity', + description: 'All the fields', + defaultTab: 'details', + tabPosition: 'left', + allowSkip: true, + showStepIndicator: false, + splitDirection: 'horizontal', + splitSize: 40, + splitResizable: true, + drawerSide: 'right', + drawerWidth: '480px', + modalSize: 'lg', + data: { provider: 'object', object: 'opportunity' }, + sections: [ + { + name: 'basic_info', + label: 'Basic Info', + description: 'Who and what', + collapsible: true, + collapsed: false, + columns: 2, + visibleWhen: 'record.stage != "closed"', + fields: [ + { + field: 'name', + type: 'text', + label: 'Name', + required: true, + placeholder: 'Acme deal', + helpText: 'Deal name', + colSpan: 2, + widget: 'input', + dependsOn: ['account'], + visibleWhen: 'record.active == true', + }, + { + field: 'account', + type: 'lookup', + reference: 'account', + options: [{ label: 'A', value: 'a' }], + readonly: true, + hidden: false, + }, + ], + }, + ], + subforms: [{ childObject: 'opportunity_line_item', amountField: 'amount' }], + defaultSort: [{ field: 'name', order: 'asc' }], + sharing: { visibility: 'team' }, + submitBehavior: { kind: 'redirect', url: '/done' }, + aria: { ariaLabel: 'Opportunity form', role: 'form' }, +}; + +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); + + 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); + }); + + 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' }); + }); + + 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]; + + 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); + }); + + 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' }]); + }); + + it('normalizes legacy groups into sections (groups-only spec now renders)', () => { + const bridge = new SpecBridge(); + const node = bridge.transformFormView({ + type: 'simple', + groups: [ + { label: 'Legacy Group', fields: [{ field: 'name' }] }, + ], + }); + + expect(node.groups).toBeUndefined(); + const sections = node.sections as any[]; + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe('Legacy Group'); + expect(sections[0].fields[0].name).toBe('name'); + }); + + it('prefers sections over groups when both are present', () => { + const bridge = new SpecBridge(); + const node = bridge.transformFormView({ + sections: [{ label: 'Canonical', fields: [] }], + groups: [{ label: 'Legacy', fields: [] }], + }); + + const sections = node.sections as any[]; + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe('Canonical'); + }); +}); diff --git a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts b/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts index f8cd10c5e..1717c9c6c 100644 --- a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts +++ b/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts @@ -312,12 +312,18 @@ describe('SpecBridge', () => { expect(node.sections[0].fields[0].label).toBe('age'); }); - it('passes groups through', () => { + it('normalizes legacy groups into sections (#2545)', () => { + // Spec: `groups` is a legacy alias of `sections`. The renderer only + // consumes `sections`, so the bridge folds groups into it instead of + // passing a dead `groups` key through. const node = bridgeFormView( { groups: [{ name: 'g1', label: 'Group 1' }] }, {}, ); - expect(node.groups).toEqual([{ name: 'g1', label: 'Group 1' }]); + expect(node.groups).toBeUndefined(); + expect(node.sections).toEqual([ + { name: 'g1', label: 'Group 1', fields: [] }, + ]); }); }); diff --git a/packages/react/src/spec-bridge/bridges/form-view.ts b/packages/react/src/spec-bridge/bridges/form-view.ts index 60692b4a0..c996486f9 100644 --- a/packages/react/src/spec-bridge/bridges/form-view.ts +++ b/packages/react/src/spec-bridge/bridges/form-view.ts @@ -11,6 +11,12 @@ import type { BridgeContext, BridgeFn } from '../types'; interface FormField { field: string; + /** Field type (spec FormFieldSchema reuses Data.FieldType; auto-infers widget). */ + type?: string; + /** Options for select/multiselect/radio/checkboxes fields. */ + options?: any[]; + /** Target object name for lookup/master_detail fields. */ + reference?: string; label?: string; placeholder?: string; helpText?: string; @@ -27,19 +33,51 @@ interface FormField { } interface FormSection { + /** Stable section identifier for i18n lookup (spec FormSectionSchema.name). */ + name?: string; label?: string; + description?: string; collapsible?: boolean; collapsed?: boolean; columns?: number; + /** Section-level conditional-visibility predicate (ADR-0089). */ + visibleWhen?: string; fields?: FormField[]; } +/** + * The subset of `@objectstack/spec` FormViewSchema the bridge consumes. + * 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). + */ interface FormViewSpec { type?: string; + layout?: string; + columns?: number; + title?: string; + description?: string; + // Tabbed (`type: 'tabbed'`) + defaultTab?: string; + tabPosition?: string; + // Wizard (`type: 'wizard'`) + allowSkip?: boolean; + showStepIndicator?: boolean; + // Split (`type: 'split'`) + splitDirection?: string; + splitSize?: number; + splitResizable?: boolean; + // Drawer (`type: 'drawer'`) + drawerSide?: string; + drawerWidth?: string; + // Modal (`type: 'modal'`) + modalSize?: string; data?: any; sections?: FormSection[]; - groups?: any; - // P1.2 additions + /** Legacy alias of `sections` (spec: "Legacy support → alias to sections"). */ + groups?: FormSection[]; + /** Inline master-detail child collections. */ + subforms?: any[]; defaultSort?: any; sharing?: any; aria?: { ariaLabel?: string; ariaDescribedBy?: string; role?: string }; @@ -52,6 +90,9 @@ function mapField(field: FormField): Record { label: field.label ?? field.field, }; + if (field.type) mapped.type = field.type; + if (field.options) mapped.options = field.options; + if (field.reference) mapped.reference = field.reference; if (field.placeholder) mapped.placeholder = field.placeholder; if (field.helpText) mapped.helpText = field.helpText; if (field.readonly != null) mapped.readonly = field.readonly; @@ -75,10 +116,13 @@ function mapSection(section: FormSection): Record { fields: (section.fields ?? []).map(mapField), }; + if (section.name) mapped.name = section.name; if (section.label) mapped.label = section.label; + if (section.description) mapped.description = section.description; if (section.collapsible != null) mapped.collapsible = section.collapsible; if (section.collapsed != null) mapped.collapsed = section.collapsed; if (section.columns != null) mapped.columns = section.columns; + if (section.visibleWhen) mapped.visibleWhen = section.visibleWhen; return mapped; } @@ -90,12 +134,39 @@ function mapFormType(type?: string): string | undefined { return validTypes.includes(type) ? type : undefined; } +/** + * Spec FormViewSchema keys carried onto the `object-form` node verbatim. + * All of them are declared with the same name (and semantics) on + * `ObjectFormSchema`, so no per-key mapping is needed — only presence checks. + */ +const PASSTHROUGH_KEYS = [ + 'layout', + 'columns', + 'title', + 'description', + 'defaultTab', + 'tabPosition', + 'allowSkip', + 'showStepIndicator', + 'splitDirection', + 'splitSize', + 'splitResizable', + 'drawerSide', + 'drawerWidth', + 'modalSize', + 'subforms', +] as const; + /** Transforms a FormView spec into a Form SchemaNode */ export const bridgeFormView: BridgeFn = ( spec: FormViewSpec, _context: BridgeContext, ): SchemaNode => { - const sections = (spec.sections ?? []).map(mapSection); + // Spec defines `groups` as a legacy alias of `sections`; normalize here so + // downstream renderers only ever see `sections` (ObjectForm never reads a + // `groups` key — before this normalization a groups-only spec silently + // rendered no sections at all, #2545). + const sections = (spec.sections ?? spec.groups ?? []).map(mapSection); const formType = mapFormType(spec.type); const node: SchemaNode = { @@ -107,7 +178,13 @@ export const bridgeFormView: BridgeFn = ( // P1.2 — formType mapping (tabbed, wizard, split, drawer, modal) if (formType) node.formType = formType; - if (spec.groups) node.groups = spec.groups; + + // #2545 — same-name spec keys (layout, title, tab/wizard/split/drawer/modal + // options, subforms) pass straight through onto the node. + for (const key of PASSTHROUGH_KEYS) { + if (spec[key] != null) node[key] = spec[key]; + } + if (spec.defaultSort) node.defaultSort = spec.defaultSort; if (spec.submitBehavior) node.submitBehavior = spec.submitBehavior; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 246bd0784..f1994aea3 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -829,6 +829,21 @@ export type SubmitBehavior = | { kind: 'continue' } | { kind: 'next-record' }; +/** + * Key taxonomy (#2545 — spec `FormViewSchema` alignment): + * + * - **[spec-aligned]** — same name & semantics as `@objectstack/spec` + * `FormViewSchema` (`title`, `description`, `layout`, `columns`, `sections`, + * `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, + * `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, + * `modalSize`, `subforms`, `submitBehavior`; `formType` ↔ spec `type`). + * - **[ObjectUI extension]** — serializable renderer extras with no spec + * backing yet (button visibility/labels, `className`, `initialValues`, + * `fields`/`customFields`, …). Candidates for upstreaming are tracked in + * #2545; until then they are sanctioned, documented extensions. + * - **[runtime-only]** — non-serializable runtime concerns that never belong + * in view metadata (`mode`, `recordId`, `open`, callbacks, …). + */ export interface ObjectFormSchema extends BaseSchema { type: 'object-form'; @@ -900,7 +915,15 @@ export interface ObjectFormSchema extends BaseSchema { sections?: ObjectFormSection[]; /** - * Field groups for organized layout (legacy, prefer sections) + * Field groups for organized layout. + * + * @deprecated Legacy alias of {@link sections} — `@objectstack/spec` + * FormViewSchema defines `groups` as "Legacy support → alias to sections", + * and the form renderer only consumes `sections`. Consumers (spec-bridge, + * ObjectForm) normalize `groups` into `sections` when `sections` is absent; + * new metadata should declare `sections` directly. Note the legacy shape + * differs from {@link ObjectFormSection}: `title`→`label`, + * `defaultCollapsed`→`collapsed`. */ groups?: Array<{ title?: string;