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
39 changes: 39 additions & 0 deletions .changeset/form-section-pane.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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; … }[]` | ✅ | |


Expand Down
7 changes: 5 additions & 2 deletions examples/app-showcase/src/ui/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
36 changes: 35 additions & 1 deletion packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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.`,
});
}
});
}
}));

/**
Expand Down
Loading