Skip to content

Commit ddcc7d6

Browse files
os-zhuangclaude
andauthored
fix(spec-bridge/form): #2545 stop dropping spec FormViewSchema keys; normalize legacy groups → sections (#2552)
From the #2545 evaluation against the published @objectstack/spec package (14.6.0 / 15.0.0 — identical FormViewSchema key sets): type-level alignment was already ~80% done, but the bridge pipeline silently dropped spec configuration and the legacy `groups` key was dead in the renderer. - spec-bridge form-view: pass through all same-name spec keys (layout, columns, title, description, defaultTab, tabPosition, allowSkip, showStepIndicator, split*, drawer*, modalSize, subforms); mapField now preserves type/options/reference; mapSection now preserves name (i18n anchor), description, and visibleWhen (ADR-0089) - groups → sections normalization at both consumers: the bridge folds spec `groups` (legacy alias per spec source) into `sections`; ObjectForm derives sections from a groups-only schema (title→label, defaultCollapsed→collapsed) — previously such metadata silently rendered an ungrouped form - ObjectView.buildFormSchema: forward `form.sections` (was dropped) - types: @deprecated on ObjectFormSchema.groups + key taxonomy header ([spec-aligned] / [ObjectUI extension] / [runtime-only]) - docs: ObjectFormSchema reference gains a "Spec alignment & extension keys" section; example fixed to use section `label` and drop the misleading groups block - tests: FormViewSpecConformance round-trip (every spec key mapped or explicitly ignored with reason), groups-alias rendering + precedence, updated the old pass-groups-through bridge test to the normalized behavior Refs #2545. Claude-Session: https://claude.ai/code/session_01Ln4gqtxXuymoQmyy4iRM2z Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77b40db commit ddcc7d6

8 files changed

Lines changed: 403 additions & 26 deletions

File tree

content/docs/api/schema-reference.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -815,21 +815,14 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple
815815
"fields": ["firstName", "lastName", "email", "phone", "company"],
816816
"sections": [
817817
{
818-
"title": "Basic Info",
818+
"label": "Basic Info",
819819
"fields": ["firstName", "lastName", "email"]
820820
},
821821
{
822-
"title": "Details",
822+
"label": "Details",
823823
"fields": ["phone", "company", "address"]
824824
}
825825
],
826-
"groups": [
827-
{
828-
"title": "Contact Information",
829-
"fields": ["firstName", "lastName", "email"],
830-
"collapsible": true
831-
}
832-
],
833826
"showSubmit": true,
834827
"submitText": "Create Contact",
835828
"showCancel": true,
@@ -841,19 +834,27 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple
841834
|----------|------|-------------|
842835
| `objectName` | `string` | **Required.** ObjectQL object API name. |
843836
| `mode` | `"create" \| "edit" \| "view"` | **Required.** Form interaction mode. |
844-
| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. |
837+
| `formType` | `string` | Layout type: `"simple"`, `"tabbed"`, `"wizard"`, `"split"`, `"drawer"`, `"modal"`. Aligned with `@objectstack/spec` `FormViewSchema.type`. |
845838
| `recordId` | `string \| number` | Record ID for edit/view modes. |
846839
| `fields` | `string[]` | Field API names to include (auto-resolved from object metadata). |
847840
| `customFields` | `FormField[]` | Manually defined fields that override auto-generated ones. |
848-
| `sections` | `ObjectFormSection[]` | Tabbed/wizard sections. |
849-
| `groups` | `array` | Collapsible field groups. |
841+
| `sections` | `ObjectFormSection[]` | Field sections (simple groups, tabs, or wizard steps depending on `formType`). Spec-aligned key. |
842+
| `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`. |
850843
| `layout` | `string` | Label layout: `"vertical"`, `"horizontal"`, `"inline"`, `"grid"`. |
851844
| `columns` | `number` | Number of form columns. |
852845
| `submitText` / `cancelText` | `string` | Button labels. |
853846
| `showSubmit` / `showCancel` / `showReset` | `boolean` | Toggle action buttons. |
854847
| `drawerSide` | `string` | Drawer position: `"top"`, `"bottom"`, `"left"`, `"right"`. |
855848
| `modalSize` | `string` | Modal size: `"sm"`, `"default"`, `"lg"`, `"xl"`, `"full"`. |
856849

850+
#### Spec alignment & extension keys
851+
852+
`ObjectFormSchema` keys fall into three classes (#2545):
853+
854+
- **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.
855+
- **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.
856+
- **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`).
857+
857858
**Related:** [FormSchema](#formschema), [ObjectViewSchema](#objectviewschema)
858859

859860
---

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,23 +83,41 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
8383
// (Tabbed/Wizard/Split/Drawer/Modal/Simple) transparently honour FLS.
8484
// Fail-open when no provider mounted (perms.isLoaded false).
8585
const schema = useMemo<ObjectFormProps['schema']>(() => {
86-
if (!perms?.isLoaded) return rawSchema;
86+
// #2545: spec FormViewSchema defines `groups` as a legacy alias of
87+
// `sections`, and this renderer only ever consumes `sections` — normalize
88+
// FIRST so groups-only metadata actually renders (it used to be silently
89+
// ignored). Legacy shape maps `title`→`label`, `defaultCollapsed`→`collapsed`.
90+
const legacyGroups = (rawSchema as any).groups;
91+
const base: ObjectFormProps['schema'] =
92+
!rawSchema.sections?.length && Array.isArray(legacyGroups) && legacyGroups.length
93+
? {
94+
...rawSchema,
95+
sections: legacyGroups.map((g: any) => ({
96+
label: g.title ?? g.label,
97+
description: g.description,
98+
collapsible: g.collapsible,
99+
collapsed: g.defaultCollapsed ?? g.collapsed,
100+
fields: g.fields ?? [],
101+
})),
102+
}
103+
: rawSchema;
104+
if (!perms?.isLoaded) return base;
87105
const gateField = (f: any) => {
88106
if (!f?.name) return f;
89-
const canRead = perms.checkField(rawSchema.objectName, f.name, 'read');
107+
const canRead = perms.checkField(base.objectName, f.name, 'read');
90108
if (!canRead) return null;
91-
const canWrite = perms.checkField(rawSchema.objectName, f.name, 'write');
92-
if (!canWrite && rawSchema.mode !== 'view') {
109+
const canWrite = perms.checkField(base.objectName, f.name, 'write');
110+
if (!canWrite && base.mode !== 'view') {
93111
return { ...f, readOnly: true, disabled: true };
94112
}
95113
return f;
96114
};
97115
const filterArr = (arr?: any[]) =>
98116
Array.isArray(arr) ? arr.map(gateField).filter(Boolean) : arr;
99117
return {
100-
...rawSchema,
101-
fields: filterArr(rawSchema.fields as any[]),
102-
sections: rawSchema.sections?.map((s: any) => ({
118+
...base,
119+
fields: filterArr(base.fields as any[]),
120+
sections: base.sections?.map((s: any) => ({
103121
...s,
104122
fields: filterArr(s.fields),
105123
})),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* ObjectForm `groups` → `sections` normalization (#2545).
3+
*
4+
* `@objectstack/spec` FormViewSchema defines `groups` as a legacy alias of
5+
* `sections`, but ObjectForm only ever consumed `sections` — so a schema that
6+
* declared only `groups` silently rendered an ungrouped form. These tests lock
7+
* in the normalization (legacy shape: `title`→`label`,
8+
* `defaultCollapsed`→`collapsed`) and its precedence (`sections` wins).
9+
*/
10+
11+
import React from 'react';
12+
import { describe, it, expect, vi } from 'vitest';
13+
import { render, screen } from '@testing-library/react';
14+
import { registerAllFields } from '@object-ui/fields';
15+
import { ObjectForm } from '../ObjectForm';
16+
17+
registerAllFields();
18+
19+
function buildMockDataSource() {
20+
return {
21+
create: vi.fn(async (_obj: string, data: Record<string, unknown>) => ({ id: '1', ...data })),
22+
update: vi.fn(),
23+
delete: vi.fn(),
24+
findOne: vi.fn(),
25+
find: vi.fn(async () => ({ data: [], total: 0 })),
26+
getObjectSchema: vi.fn(async (name: string) => ({
27+
name,
28+
label: name,
29+
fields: {
30+
name: { type: 'text', label: 'Name' },
31+
email: { type: 'text', label: 'Email' },
32+
},
33+
})),
34+
} as any;
35+
}
36+
37+
describe('ObjectForm groups → sections normalization (#2545)', () => {
38+
it('renders legacy groups-only schema as sections (was silently ignored)', async () => {
39+
render(
40+
<ObjectForm
41+
schema={{
42+
type: 'object-form',
43+
objectName: 'lead',
44+
mode: 'create',
45+
groups: [
46+
{ title: 'Legacy Group Title', fields: ['name', 'email'] },
47+
],
48+
} as any}
49+
dataSource={buildMockDataSource()}
50+
/>,
51+
);
52+
53+
// The legacy group's `title` must surface as a section label.
54+
expect(await screen.findByText('Legacy Group Title')).toBeTruthy();
55+
});
56+
57+
it('prefers sections over groups when both are declared', async () => {
58+
render(
59+
<ObjectForm
60+
schema={{
61+
type: 'object-form',
62+
objectName: 'lead',
63+
mode: 'create',
64+
sections: [{ label: 'Canonical Section', fields: ['name'] }],
65+
groups: [{ title: 'Legacy Group', fields: ['email'] }],
66+
} as any}
67+
dataSource={buildMockDataSource()}
68+
/>,
69+
);
70+
71+
expect(await screen.findByText('Canonical Section')).toBeTruthy();
72+
expect(screen.queryByText('Legacy Group')).toBeNull();
73+
});
74+
});

packages/plugin-view/src/ObjectView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
888888
description: schema.form?.description,
889889
fields: schema.form?.fields,
890890
customFields: schema.form?.customFields,
891+
// #2545: `sections` is the spec-aligned key (it used to be dropped
892+
// here); `groups` is its deprecated legacy alias, normalized to
893+
// sections inside ObjectForm.
894+
sections: schema.form?.sections,
891895
groups: schema.form?.groups,
892896
layout: schema.form?.layout,
893897
columns: schema.form?.columns,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* FormView spec conformance round-trip (#2545).
11+
*
12+
* The bridge must never silently drop `@objectstack/spec` FormViewSchema
13+
* configuration: every serializable spec key is either mapped onto the
14+
* `object-form` node or explicitly listed in IGNORED_SPEC_KEYS with a reason.
15+
* The fixture below carries every top-level FormViewSchema key (spec 14.6.0 /
16+
* 15.0.0 — identical key sets), so a newly-added spec key that the bridge
17+
* ignores will fail the completeness assertion when the fixture is updated.
18+
*/
19+
import { describe, it, expect } from 'vitest';
20+
import { SpecBridge } from '../SpecBridge';
21+
22+
/** Spec keys intentionally NOT copied onto the node, with reasons. */
23+
const IGNORED_SPEC_KEYS: Record<string, string> = {
24+
type: 'mapped to node.formType (ObjectUI rename), not carried verbatim',
25+
groups: 'legacy alias of sections — normalized into node.sections',
26+
};
27+
28+
/** Every top-level serializable key of spec FormViewSchema (14.6.0 / 15.0.0). */
29+
const FULL_SPEC_FORM_VIEW = {
30+
type: 'wizard',
31+
layout: 'grid',
32+
columns: 2,
33+
title: 'Edit Opportunity',
34+
description: 'All the fields',
35+
defaultTab: 'details',
36+
tabPosition: 'left',
37+
allowSkip: true,
38+
showStepIndicator: false,
39+
splitDirection: 'horizontal',
40+
splitSize: 40,
41+
splitResizable: true,
42+
drawerSide: 'right',
43+
drawerWidth: '480px',
44+
modalSize: 'lg',
45+
data: { provider: 'object', object: 'opportunity' },
46+
sections: [
47+
{
48+
name: 'basic_info',
49+
label: 'Basic Info',
50+
description: 'Who and what',
51+
collapsible: true,
52+
collapsed: false,
53+
columns: 2,
54+
visibleWhen: 'record.stage != "closed"',
55+
fields: [
56+
{
57+
field: 'name',
58+
type: 'text',
59+
label: 'Name',
60+
required: true,
61+
placeholder: 'Acme deal',
62+
helpText: 'Deal name',
63+
colSpan: 2,
64+
widget: 'input',
65+
dependsOn: ['account'],
66+
visibleWhen: 'record.active == true',
67+
},
68+
{
69+
field: 'account',
70+
type: 'lookup',
71+
reference: 'account',
72+
options: [{ label: 'A', value: 'a' }],
73+
readonly: true,
74+
hidden: false,
75+
},
76+
],
77+
},
78+
],
79+
subforms: [{ childObject: 'opportunity_line_item', amountField: 'amount' }],
80+
defaultSort: [{ field: 'name', order: 'asc' }],
81+
sharing: { visibility: 'team' },
82+
submitBehavior: { kind: 'redirect', url: '/done' },
83+
aria: { ariaLabel: 'Opportunity form', role: 'form' },
84+
};
85+
86+
describe('FormView spec conformance (#2545)', () => {
87+
it('carries every spec FormViewSchema key onto the node (no silent drops)', () => {
88+
const bridge = new SpecBridge();
89+
const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW);
90+
91+
for (const key of Object.keys(FULL_SPEC_FORM_VIEW)) {
92+
if (key in IGNORED_SPEC_KEYS) continue;
93+
expect(node[key], `spec key "${key}" was silently dropped by the bridge`).toBeDefined();
94+
}
95+
// The two intentionally-diverted keys land in their mapped slots.
96+
expect(node.formType).toBe('wizard');
97+
expect(node.sections).toHaveLength(1);
98+
});
99+
100+
it('passes shared layout/variant keys through verbatim', () => {
101+
const bridge = new SpecBridge();
102+
const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW);
103+
104+
expect(node.layout).toBe('grid');
105+
expect(node.columns).toBe(2);
106+
expect(node.title).toBe('Edit Opportunity');
107+
expect(node.description).toBe('All the fields');
108+
expect(node.defaultTab).toBe('details');
109+
expect(node.tabPosition).toBe('left');
110+
expect(node.allowSkip).toBe(true);
111+
expect(node.showStepIndicator).toBe(false);
112+
expect(node.splitDirection).toBe('horizontal');
113+
expect(node.splitSize).toBe(40);
114+
expect(node.splitResizable).toBe(true);
115+
expect(node.drawerSide).toBe('right');
116+
expect(node.drawerWidth).toBe('480px');
117+
expect(node.modalSize).toBe('lg');
118+
expect(node.subforms).toEqual(FULL_SPEC_FORM_VIEW.subforms);
119+
expect(node.submitBehavior).toEqual({ kind: 'redirect', url: '/done' });
120+
});
121+
122+
it('preserves spec FormSection name/description/visibleWhen', () => {
123+
const bridge = new SpecBridge();
124+
const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW);
125+
const section = (node.sections as any[])[0];
126+
127+
expect(section.name).toBe('basic_info');
128+
expect(section.description).toBe('Who and what');
129+
expect(section.visibleWhen).toBe('record.stage != "closed"');
130+
expect(section.label).toBe('Basic Info');
131+
expect(section.columns).toBe(2);
132+
});
133+
134+
it('preserves spec FormField type/options/reference', () => {
135+
const bridge = new SpecBridge();
136+
const node = bridge.transformFormView(FULL_SPEC_FORM_VIEW);
137+
const [name, account] = (node.sections as any[])[0].fields;
138+
139+
expect(name.type).toBe('text');
140+
// field-level visibleWhen lands in the renderer's visibleOn slot (ADR-0089)
141+
expect(name.visibleOn).toBe('record.active == true');
142+
expect(account.type).toBe('lookup');
143+
expect(account.reference).toBe('account');
144+
expect(account.options).toEqual([{ label: 'A', value: 'a' }]);
145+
});
146+
147+
it('normalizes legacy groups into sections (groups-only spec now renders)', () => {
148+
const bridge = new SpecBridge();
149+
const node = bridge.transformFormView({
150+
type: 'simple',
151+
groups: [
152+
{ label: 'Legacy Group', fields: [{ field: 'name' }] },
153+
],
154+
});
155+
156+
expect(node.groups).toBeUndefined();
157+
const sections = node.sections as any[];
158+
expect(sections).toHaveLength(1);
159+
expect(sections[0].label).toBe('Legacy Group');
160+
expect(sections[0].fields[0].name).toBe('name');
161+
});
162+
163+
it('prefers sections over groups when both are present', () => {
164+
const bridge = new SpecBridge();
165+
const node = bridge.transformFormView({
166+
sections: [{ label: 'Canonical', fields: [] }],
167+
groups: [{ label: 'Legacy', fields: [] }],
168+
});
169+
170+
const sections = node.sections as any[];
171+
expect(sections).toHaveLength(1);
172+
expect(sections[0].label).toBe('Canonical');
173+
});
174+
});

packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,18 @@ describe('SpecBridge', () => {
312312
expect(node.sections[0].fields[0].label).toBe('age');
313313
});
314314

315-
it('passes groups through', () => {
315+
it('normalizes legacy groups into sections (#2545)', () => {
316+
// Spec: `groups` is a legacy alias of `sections`. The renderer only
317+
// consumes `sections`, so the bridge folds groups into it instead of
318+
// passing a dead `groups` key through.
316319
const node = bridgeFormView(
317320
{ groups: [{ name: 'g1', label: 'Group 1' }] },
318321
{},
319322
);
320-
expect(node.groups).toEqual([{ name: 'g1', label: 'Group 1' }]);
323+
expect(node.groups).toBeUndefined();
324+
expect(node.sections).toEqual([
325+
{ name: 'g1', label: 'Group 1', fields: [] },
326+
]);
321327
});
322328
});
323329

0 commit comments

Comments
 (0)