diff --git a/.changeset/form-section-pane.md b/.changeset/form-section-pane.md new file mode 100644 index 0000000000..b47d4daa91 --- /dev/null +++ b/.changeset/form-section-pane.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": minor +"@objectstack/example-showcase": patch +--- + +feat(spec): `FormSection.pane` — explicit split-pane placement (objectui#2153 follow-up) + +A `type: 'split'` form view had no way to say which pane a section renders in: +the renderer hardcoded "first section left, everything else right". That +positional rule is invisible in the metadata — nothing in the JSON records the +assignment — so reordering sections silently moved them across the divider, and +an author (human or AI) could not place two sections side by side on the left at +all. + +`FormSectionSchema` gains an optional `pane: 'primary' | 'secondary'`: + +- **Explicit and per-section**, so placement survives reordering and an agent + editing the view can see — and must preserve — where each section lives. +- **Omitted → the legacy rule** (first section `primary`, others `secondary`), + so existing keyless metadata keeps its exact layout. +- **Split-only, enforced loudly**: a `FormViewSchema` refinement rejects `pane` + on any other form type at parse (covering the legacy `groups` alias and the + defaulted `type: 'simple'`). "Accepted but ignored" is the failure mode this + key must never have — a silent no-op reads as working, especially to an AI + author. zod 4 keeps refinements through `.extend()`, so the flattened + runtime-overlay variant in `ViewMetadataSchema` enforces it too. +- Strict two-value enum, not free text — a typo (`'left'`) is a parse error. + +The `'split'` type's enum comment claimed "Master-Detail split"; master-detail +already has two homes (`subforms` on the form, related lists on record pages), +so the comment now states split's actual, non-redundant meaning: side-by-side +resizable panes with sections placed via `section.pane`. + +The showcase task form's `split` view previously declared a single section — +which renders as a plain (unsplit) form — and now demonstrates the feature: +two sections with explicit panes. + +Renderer support ships in ObjectUI (`SplitForm` → `FormSchema.fieldPanes`, +whose pane keys are already named `primary`/`secondary` — a 1:1 mapping). diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 2ac0cd981d..e273a5e1ed 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -182,6 +182,7 @@ Column footer summary configuration | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | | +| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | | **fields** | `string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … }[]` | ✅ | | diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 98f4e0be4e..02ffe5cbba 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -305,11 +305,14 @@ export const TaskViews = defineView({ ], }, - // split ── master-detail split pane ────────────────────────────────── + // split ── side-by-side resizable panes; `pane` places each section ── split: { type: 'split', data, - sections: [{ label: 'Task', columns: 1, fields: ['title', 'status', 'assignee'] }], + sections: [ + { name: 'split_task', label: 'Task', pane: 'primary', columns: 1, fields: ['title', 'status', 'assignee'] }, + { name: 'split_schedule', label: 'Schedule', pane: 'secondary', columns: 1, fields: ['start_date', 'due_date', 'progress'] }, + ], }, // drawer ── side panel quick edit ──────────────────────────────────── diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 0477710954..a477873c5a 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7906,6 +7906,7 @@ "ui/FormSection:fields", "ui/FormSection:label", "ui/FormSection:name", + "ui/FormSection:pane", "ui/FormSection:visibleOn", "ui/FormSection:visibleWhen", "ui/FormView:allowSkip", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index cb5a0bd782..d73fdc2cb5 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -591,6 +591,66 @@ describe('FormViewSchema', () => { sections: [{ fields: ['name'] }], })).not.toThrow(); }); + + // `section.pane` — explicit split placement. Explicit-per-section so the + // assignment is visible in the metadata and survives reordering (the legacy + // rule was positional: first section left, rest right — invisible, and a + // reorder silently moved sections across the divider). + describe('section.pane (split placement)', () => { + it('accepts pane assignments on a split form', () => { + const result = FormViewSchema.parse({ + type: 'split', + sections: [ + { label: 'Task', pane: 'primary', fields: ['title'] }, + { label: 'Schedule', pane: 'secondary', fields: ['due_date'] }, + // Omitted pane is fine — the renderer defaults by position. + { label: 'Notes', fields: ['notes'] }, + ], + }); + expect(result.sections?.map((s) => s.pane)).toEqual(['primary', 'secondary', undefined]); + }); + + it('rejects a typo pane value (strict enum, not free text)', () => { + expect(() => FormViewSchema.parse({ + type: 'split', + sections: [{ label: 'Task', pane: 'left', fields: ['title'] }], + })).toThrow(); + }); + + it('rejects pane on a non-split form instead of silently ignoring it', () => { + // "Accepted but ignored" is the failure mode this key must never have — + // especially for AI-authored metadata, where a no-op reads as working. + const result = FormViewSchema.safeParse({ + type: 'tabbed', + sections: [ + { label: 'Details', fields: ['name'] }, + { label: 'Advanced', pane: 'secondary', fields: ['settings'] }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.join('.') === 'sections.1.pane'); + expect(issue?.message).toMatch(/only valid on `type: 'split'`/); + } + }); + + it('rejects pane on the legacy `groups` alias the same way', () => { + const result = FormViewSchema.safeParse({ + type: 'simple', + groups: [{ label: 'Account', pane: 'primary', fields: ['account_name'] }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.join('.') === 'groups.0.pane')).toBe(true); + } + }); + + it('defaulted `type` counts as non-split (a forgotten type errors loudly)', () => { + expect(FormViewSchema.safeParse({ + sections: [{ label: 'Task', pane: 'primary', fields: ['title'] }], + }).success).toBe(false); + }); + }); }); describe('ViewSchema', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8c2a048913..15e498285b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -937,6 +937,19 @@ export const FormSectionSchema = lazySchema(() => z.object({ z.literal(3), z.literal(4), ]).default(1).transform(val => (typeof val === 'string' ? parseInt(val) : val) as 1 | 2 | 3 | 4), + /** + * Which pane of a split form this section renders in (`type: 'split'` only — + * any other form type rejects the key at parse, see the FormViewSchema + * refinement). Placement is explicit and PER SECTION so it survives + * reordering: with the old first-vs-rest positional rule the assignment was + * invisible in the metadata, and an edit that reordered sections silently + * moved them across the divider. Defaults by position when omitted: the + * first section renders in `primary`, every other section in `secondary` + * (exactly the legacy rule, so keyless metadata keeps its layout). + */ + pane: z.enum(['primary', 'secondary']).optional().describe( + "Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'.", + ), fields: z.array(z.union([ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config @@ -979,7 +992,7 @@ export const FormViewSchema = lazySchema(() => z.object({ 'simple', // Single column or sections 'tabbed', // Tabs 'wizard', // Step by step - 'split', // Master-Detail split + 'split', // Side-by-side resizable panes; sections placed via `section.pane` 'drawer', // Side panel 'modal' // Dialog ]).default('simple'), @@ -1115,6 +1128,27 @@ export const FormViewSchema = lazySchema(() => z.object({ 'Delete the key. The form renderer emits its own semantic markup; report gaps as ' + 'renderer issues rather than per-view attribute overrides.', ), +}).superRefine((view, ctx) => { + // `section.pane` is split-only vocabulary. On any other form type it would + // be a silent no-op — the worst failure mode for authored (and especially + // AI-authored) metadata, where "accepted but ignored" reads as working. + // Reject it loudly at parse instead. `.extend()` keeps this check (zod 4 + // attaches refinements to the schema), so the flattened runtime-overlay + // variant in ViewMetadataSchema enforces it too. + if (view.type === 'split') return; + for (const [key, sections] of [['sections', view.sections], ['groups', view.groups]] as const) { + sections?.forEach((section, index) => { + if (section?.pane != null) { + ctx.addIssue({ + code: 'custom', + path: [key, index, 'pane'], + message: + `\`pane\` places a section in a split pane and is only valid on \`type: 'split'\` ` + + `form views (this form is '${view.type}'). Remove the key or change the form type.`, + }); + } + }); + } })); /**