Skip to content

Commit e8d0c21

Browse files
os-zhuangclaude
andauthored
feat(spec): FormSection.pane — explicit split-pane placement (objectui#2153 follow-up) (#4160)
* 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, so reordering sections silently moved them across the divider, and an author (human or AI) could not place two sections 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 positional rule (first section primary, others secondary), so keyless metadata keeps its exact layout; - split-only, enforced loudly: a FormViewSchema refinement rejects `pane` on any other form type at parse (legacy `groups` alias and defaulted `type: 'simple'` included). "Accepted but ignored" is the failure mode this key must never have — a silent no-op reads as working, especially to an AI author. Verified that zod 4 keeps refinements through `.extend()`, so the flattened runtime-overlay variant in ViewMetadataSchema enforces it too; - strict two-value enum — a typo ('left') is a parse error, not free text. The 'split' 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 non-redundant meaning: side-by-side resizable panes with sections placed via `section.pane`. The showcase task form's `split` view declared a single section — which renders as a plain, unsplit form — and now demonstrates the feature: two sections with explicit panes. `authorable-surface.json` regenerated (one new entry). Renderer support ships in ObjectUI (SplitForm → FormSchema.fieldPanes, whose pane keys are already named primary/secondary — a 1:1 mapping). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): regenerate the view reference for FormSection.pane check:docs gates generated reference docs against the spec; the new key needs its generated row committed alongside the schema change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 545d931 commit e8d0c21

6 files changed

Lines changed: 141 additions & 3 deletions

File tree

.changeset/form-section-pane.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/example-showcase": patch
4+
---
5+
6+
feat(spec): `FormSection.pane` — explicit split-pane placement (objectui#2153 follow-up)
7+
8+
A `type: 'split'` form view had no way to say which pane a section renders in:
9+
the renderer hardcoded "first section left, everything else right". That
10+
positional rule is invisible in the metadata — nothing in the JSON records the
11+
assignment — so reordering sections silently moved them across the divider, and
12+
an author (human or AI) could not place two sections side by side on the left at
13+
all.
14+
15+
`FormSectionSchema` gains an optional `pane: 'primary' | 'secondary'`:
16+
17+
- **Explicit and per-section**, so placement survives reordering and an agent
18+
editing the view can see — and must preserve — where each section lives.
19+
- **Omitted → the legacy rule** (first section `primary`, others `secondary`),
20+
so existing keyless metadata keeps its exact layout.
21+
- **Split-only, enforced loudly**: a `FormViewSchema` refinement rejects `pane`
22+
on any other form type at parse (covering the legacy `groups` alias and the
23+
defaulted `type: 'simple'`). "Accepted but ignored" is the failure mode this
24+
key must never have — a silent no-op reads as working, especially to an AI
25+
author. zod 4 keeps refinements through `.extend()`, so the flattened
26+
runtime-overlay variant in `ViewMetadataSchema` enforces it too.
27+
- Strict two-value enum, not free text — a typo (`'left'`) is a parse error.
28+
29+
The `'split'` type's enum comment claimed "Master-Detail split"; master-detail
30+
already has two homes (`subforms` on the form, related lists on record pages),
31+
so the comment now states split's actual, non-redundant meaning: side-by-side
32+
resizable panes with sections placed via `section.pane`.
33+
34+
The showcase task form's `split` view previously declared a single section —
35+
which renders as a plain (unsplit) form — and now demonstrates the feature:
36+
two sections with explicit panes.
37+
38+
Renderer support ships in ObjectUI (`SplitForm``FormSchema.fieldPanes`,
39+
whose pane keys are already named `primary`/`secondary` — a 1:1 mapping).

content/docs/references/ui/view.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ Column footer summary configuration
182182
| **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). |
183183
| **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. |
184184
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | |
185+
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
185186
| **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; … }[]` || |
186187

187188

examples/app-showcase/src/ui/views/task.view.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,14 @@ export const TaskViews = defineView({
305305
],
306306
},
307307

308-
// split ── master-detail split pane ──────────────────────────────────
308+
// split ── side-by-side resizable panes; `pane` places each section ──
309309
split: {
310310
type: 'split',
311311
data,
312-
sections: [{ label: 'Task', columns: 1, fields: ['title', 'status', 'assignee'] }],
312+
sections: [
313+
{ name: 'split_task', label: 'Task', pane: 'primary', columns: 1, fields: ['title', 'status', 'assignee'] },
314+
{ name: 'split_schedule', label: 'Schedule', pane: 'secondary', columns: 1, fields: ['start_date', 'due_date', 'progress'] },
315+
],
313316
},
314317

315318
// drawer ── side panel quick edit ────────────────────────────────────

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7884,6 +7884,7 @@
78847884
"ui/FormSection:fields",
78857885
"ui/FormSection:label",
78867886
"ui/FormSection:name",
7887+
"ui/FormSection:pane",
78877888
"ui/FormSection:visibleOn",
78887889
"ui/FormSection:visibleWhen",
78897890
"ui/FormView:allowSkip",

packages/spec/src/ui/view.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,66 @@ describe('FormViewSchema', () => {
591591
sections: [{ fields: ['name'] }],
592592
})).not.toThrow();
593593
});
594+
595+
// `section.pane` — explicit split placement. Explicit-per-section so the
596+
// assignment is visible in the metadata and survives reordering (the legacy
597+
// rule was positional: first section left, rest right — invisible, and a
598+
// reorder silently moved sections across the divider).
599+
describe('section.pane (split placement)', () => {
600+
it('accepts pane assignments on a split form', () => {
601+
const result = FormViewSchema.parse({
602+
type: 'split',
603+
sections: [
604+
{ label: 'Task', pane: 'primary', fields: ['title'] },
605+
{ label: 'Schedule', pane: 'secondary', fields: ['due_date'] },
606+
// Omitted pane is fine — the renderer defaults by position.
607+
{ label: 'Notes', fields: ['notes'] },
608+
],
609+
});
610+
expect(result.sections?.map((s) => s.pane)).toEqual(['primary', 'secondary', undefined]);
611+
});
612+
613+
it('rejects a typo pane value (strict enum, not free text)', () => {
614+
expect(() => FormViewSchema.parse({
615+
type: 'split',
616+
sections: [{ label: 'Task', pane: 'left', fields: ['title'] }],
617+
})).toThrow();
618+
});
619+
620+
it('rejects pane on a non-split form instead of silently ignoring it', () => {
621+
// "Accepted but ignored" is the failure mode this key must never have —
622+
// especially for AI-authored metadata, where a no-op reads as working.
623+
const result = FormViewSchema.safeParse({
624+
type: 'tabbed',
625+
sections: [
626+
{ label: 'Details', fields: ['name'] },
627+
{ label: 'Advanced', pane: 'secondary', fields: ['settings'] },
628+
],
629+
});
630+
expect(result.success).toBe(false);
631+
if (!result.success) {
632+
const issue = result.error.issues.find((i) => i.path.join('.') === 'sections.1.pane');
633+
expect(issue?.message).toMatch(/only valid on `type: 'split'`/);
634+
}
635+
});
636+
637+
it('rejects pane on the legacy `groups` alias the same way', () => {
638+
const result = FormViewSchema.safeParse({
639+
type: 'simple',
640+
groups: [{ label: 'Account', pane: 'primary', fields: ['account_name'] }],
641+
});
642+
expect(result.success).toBe(false);
643+
if (!result.success) {
644+
expect(result.error.issues.some((i) => i.path.join('.') === 'groups.0.pane')).toBe(true);
645+
}
646+
});
647+
648+
it('defaulted `type` counts as non-split (a forgotten type errors loudly)', () => {
649+
expect(FormViewSchema.safeParse({
650+
sections: [{ label: 'Task', pane: 'primary', fields: ['title'] }],
651+
}).success).toBe(false);
652+
});
653+
});
594654
});
595655

596656
describe('ViewSchema', () => {

packages/spec/src/ui/view.zod.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,19 @@ export const FormSectionSchema = lazySchema(() => z.object({
937937
z.literal(3),
938938
z.literal(4),
939939
]).default(1).transform(val => (typeof val === 'string' ? parseInt(val) : val) as 1 | 2 | 3 | 4),
940+
/**
941+
* Which pane of a split form this section renders in (`type: 'split'` only —
942+
* any other form type rejects the key at parse, see the FormViewSchema
943+
* refinement). Placement is explicit and PER SECTION so it survives
944+
* reordering: with the old first-vs-rest positional rule the assignment was
945+
* invisible in the metadata, and an edit that reordered sections silently
946+
* moved them across the divider. Defaults by position when omitted: the
947+
* first section renders in `primary`, every other section in `secondary`
948+
* (exactly the legacy rule, so keyless metadata keeps its layout).
949+
*/
950+
pane: z.enum(['primary', 'secondary']).optional().describe(
951+
"Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'.",
952+
),
940953
fields: z.array(z.union([
941954
z.string(), // Legacy: simple field name
942955
FormFieldSchema, // Enhanced: detailed field config
@@ -979,7 +992,7 @@ export const FormViewSchema = lazySchema(() => z.object({
979992
'simple', // Single column or sections
980993
'tabbed', // Tabs
981994
'wizard', // Step by step
982-
'split', // Master-Detail split
995+
'split', // Side-by-side resizable panes; sections placed via `section.pane`
983996
'drawer', // Side panel
984997
'modal' // Dialog
985998
]).default('simple'),
@@ -1115,6 +1128,27 @@ export const FormViewSchema = lazySchema(() => z.object({
11151128
'Delete the key. The form renderer emits its own semantic markup; report gaps as ' +
11161129
'renderer issues rather than per-view attribute overrides.',
11171130
),
1131+
}).superRefine((view, ctx) => {
1132+
// `section.pane` is split-only vocabulary. On any other form type it would
1133+
// be a silent no-op — the worst failure mode for authored (and especially
1134+
// AI-authored) metadata, where "accepted but ignored" reads as working.
1135+
// Reject it loudly at parse instead. `.extend()` keeps this check (zod 4
1136+
// attaches refinements to the schema), so the flattened runtime-overlay
1137+
// variant in ViewMetadataSchema enforces it too.
1138+
if (view.type === 'split') return;
1139+
for (const [key, sections] of [['sections', view.sections], ['groups', view.groups]] as const) {
1140+
sections?.forEach((section, index) => {
1141+
if (section?.pane != null) {
1142+
ctx.addIssue({
1143+
code: 'custom',
1144+
path: [key, index, 'pane'],
1145+
message:
1146+
`\`pane\` places a section in a split pane and is only valid on \`type: 'split'\` ` +
1147+
`form views (this form is '${view.type}'). Remove the key or change the form type.`,
1148+
});
1149+
}
1150+
});
1151+
}
11181152
}));
11191153

11201154
/**

0 commit comments

Comments
 (0)