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
25 changes: 13 additions & 12 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -841,19 +834,27 @@ 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. |
| `showSubmit` / `showCancel` / `showReset` | `boolean` | Toggle action buttons. |
| `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)

---
Expand Down
32 changes: 25 additions & 7 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,41 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
// (Tabbed/Wizard/Split/Drawer/Modal/Simple) transparently honour FLS.
// Fail-open when no provider mounted (perms.isLoaded false).
const schema = useMemo<ObjectFormProps['schema']>(() => {
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;
};
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),
})),
Expand Down
74 changes: 74 additions & 0 deletions packages/plugin-form/src/__tests__/groupsAlias.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({ 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(
<ObjectForm
schema={{
type: 'object-form',
objectName: 'lead',
mode: 'create',
groups: [
{ title: 'Legacy Group Title', fields: ['name', 'email'] },
],
} as any}
dataSource={buildMockDataSource()}
/>,
);

// 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(
<ObjectForm
schema={{
type: 'object-form',
objectName: 'lead',
mode: 'create',
sections: [{ label: 'Canonical Section', fields: ['name'] }],
groups: [{ title: 'Legacy Group', fields: ['email'] }],
} as any}
dataSource={buildMockDataSource()}
/>,
);

expect(await screen.findByText('Canonical Section')).toBeTruthy();
expect(screen.queryByText('Legacy Group')).toBeNull();
});
});
4 changes: 4 additions & 0 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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');
});
});
10 changes: 8 additions & 2 deletions packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
]);
});
});

Expand Down
Loading
Loading