diff --git a/.changeset/section-field-group-reference.md b/.changeset/section-field-group-reference.md new file mode 100644 index 0000000000..d913ea6c5c --- /dev/null +++ b/.changeset/section-field-group-reference.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855) + +Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855): +「直接处理b」. + +ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in +one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches +were whole-takeover shapes with no way back to it: a custom record page's +`record:details` `properties.sections` and a view-level `form.sections` each +enumerated their members by hand, so an author who reached for either had to +hand-copy the same membership fact a second and third time. Nothing linked the +copies to the declaration, so every field added to the object afterwards made +them quietly staler — measured on a real app as three disagreeing groupings of +one object, with the detail page missing two fields the form showed. + +**A section may now name the group instead.** On both surfaces: + +```ts +sections: [ + { group: 'contact_info' }, // members + presentation derived + { label: 'Notes', fields: ['note'] }, // enumerated, unchanged +] +``` + +Members (every visible field whose `Field.group` points at the key, in +field-declaration order) and the group's own presentation (label, icon, +description, `collapse`, `visibleWhen`, and the drop when a group has no visible +members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in +section land. + +**The mixing rule**, declared once for both surfaces and pinned: + +- `group` and `fields` are mutually exclusive; a section declaring neither is + refused (before this change it was unrepresentable, because `fields` was + required). +- A group-referencing section carries no key the group already declares — + `name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and + its deprecated `visibleOn` spelling) are refused beside `group`, each with the + pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the + absence of one. The surface keys the group says nothing about — `columns`, + `pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual. +- Across sections, both kinds coexist in declared array order; a + group-referencing section occupies one slot and expands in place. +- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a + wizard step has no slot for either (the #13704 refusals, reached through the + object's declaration instead of the step's own keys). + +**Existence is checked by reference diagnostics, not at parse.** The key names +something on a different schema, so the spec door takes any well-formed +snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint` +reports a dangling one as `page-section-group-unknown` (`record:details`) or +`form-section-group-unknown` (form views, both the canonical `sections` and the +legacy `groups` bucket), advisory like every other dangling-reference finding in +that family, with the object's declared groups listed in the hint. + +The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the +derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two +referencing surfaces cannot drift into accepting different keys. + +**Type-surface note for consumers.** `fields` becomes optional on both section +shapes (that is what makes `group` the other way to declare the same fact), so +`z.infer` now types it `… | undefined`. A consumer that reads `section.fields` +unconditionally must handle the reference form; every in-repo reader already +guards it. No authored metadata changes shape, and nothing that parsed before +stops parsing — `fields: []` included. + +The renderer half (objectui) is tracked separately; until it lands, a +group-referencing section is declared and diagnosed but not yet rendered. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b2150be331..323002e68b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -47,7 +47,7 @@ nothing to do with elevation. | Declaration | What it is | This page? | |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ | -| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | +| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | | `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ | | `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8a5460426e..0203ae721e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -302,7 +302,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. | +| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. | | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | @@ -654,7 +654,7 @@ External datasource binding (ADR-0015) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. | +| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. | | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9149aa32b7..3bdc82eedc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -753,7 +753,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | | **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. | +| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | | **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). | @@ -767,7 +767,8 @@ Sort field and direction pair | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.._sections..label`; a nameless section renders its authored label in every locale | | **label** | `string \| Record` | optional | Section heading (omit for an untitled, borderless section) | | **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. | -| **fields** | `string[]` | ✅ | Field names rendered in this section, in order | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `icon`, `description`, `collapsible`, `defaultCollapsed`). Must name a declared group — checked by reference diagnostics. | +| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. | | **hideEmpty** | `boolean` | optional | Hide this section's empty fields (renderer default: on — and a section whose fields are ALL empty then renders nothing at all: no heading, no skeleton). Set `false` to render empty rows, keeping the section's label skeleton on an all-empty record (e.g. a brand-new one). | | **collapsible** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). | | **showBorder** | `boolean` | optional | Draw this section's card chrome (renderer default: derived — on for a titled section, off for an untitled one). Set `false` for a borderless titled section, or `true` for a bordered untitled one. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index d3847cfa86..074a21b371 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -336,7 +336,8 @@ View filter rule | **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 (default: `1`) | | | **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' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormSection.fields[number]` @@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **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 (default: `1`) | | | **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' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.groups[number]` @@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **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 (default: `1`) | | | **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' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.subforms[number]` diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 28d07afb47..28316b5544 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -206,6 +206,7 @@ export { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, + FORM_SECTION_GROUP_UNKNOWN, } from './validate-form-layout.js'; export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; @@ -429,9 +430,21 @@ export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED, + PAGE_SECTION_GROUP_UNKNOWN, } from './validate-page-field-bindings.js'; export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js'; +// [#13855] The shared field-group reference half both layout surfaces resolve +// against — exported so an out-of-repo consumer (cloud graph-lint, the AI +// authoring path) can ask the same question of the same index rather than +// rebuilding it from `objects[].fieldGroups` by hand. +export { + indexObjectFieldGroups, + sectionGroupRefs, + checkSectionGroupRefs, +} from './object-field-groups.js'; +export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js'; + export { validateComponentProps, COMPONENT_PROPS_UNKNOWN_KEY, diff --git a/packages/lint/src/object-field-groups.ts b/packages/lint/src/object-field-groups.ts new file mode 100644 index 0000000000..6779ac6870 --- /dev/null +++ b/packages/lint/src/object-field-groups.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Field-group REFERENCE integrity — the cross-schema half of #13855. + * + * A layout section may name a declared field group instead of enumerating its + * members: `{ group: 'contact_info' }` on a `record:details` section or on a + * form view's `sections[]`. Membership and presentation are then derived from + * the object's `fieldGroups` entry with that key (`deriveFieldGroupLayout`, + * ADR-0085 §5). + * + * The key names something on a DIFFERENT schema, so the spec door deliberately + * cannot answer whether it resolves — it takes any well-formed snake_case key, + * exactly as `UserFilterFieldSchema.field` does (*"must exist — checked by + * reference diagnostics"*). This module is that check's shared half: both + * host rules ask one question of one index rather than growing two answers that + * happen to agree. + * + * ## Why a dangling key must be reported at all + * + * `deriveFieldGroupLayout` resolves a section's members by looking the key up + * among the object's declared groups. A key that matches nothing yields no + * members, and since the reference form and the enumerated form are mutually + * exclusive at parse, the section has no other member source — so the WHOLE + * section silently disappears from the rendered page. That is the same + * silent-skip consequence the two host rules' field-existence findings carry, + * one grain up. + * + * ## Severity: `warning`, matching the family + * + * Both host rules declare `warning` for a dangling reference, for the reason + * their module notes give: the consumer degrades (it skips what it cannot + * resolve) rather than failing, and the `error` limb in + * `validate-page-field-bindings` is reserved for a reference that reaches a + * QUERY, where the silent result is indistinguishable from "there is no data". + * A section that does not render is loud to look at and touches no query, so it + * sits with its siblings rather than gating. (The judgement is stated here + * rather than left implicit because it is a judgement — see the PR body for + * #13855, where it is offered for contract review.) + */ + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); + return []; +} + +/** + * object name → its DECLARED field-group keys. + * + * Declared, not derived: `deriveFieldGroupLayout` drops a group no visible field + * joins, and a reference to a declared-but-empty group is a different (and + * data-dependent) finding from a reference to a group that was never declared. + * This index answers only the second question — the one with a closed oracle. + * + * `fieldGroups` is an ARRAY on `ObjectSchema`, and `asArray` additionally + * resolves the name-keyed map shape that raw (non-`defineStack`) metadata can + * carry, the same tolerance every other index in this package extends. + */ +export function indexObjectFieldGroups(stack: unknown): Map> { + const index = new Map>(); + if (!isRec(stack)) return index; + for (const obj of asArray(stack.objects)) { + const name = strName(obj.name); + if (!name) continue; + const keys = new Set(); + for (const group of asArray(obj.fieldGroups)) { + // `asArray` supplies `name` for the map shape; the declared key spelling + // is `key`, so read it first and fall back to the synthesized map key. + const key = strName(group.key) ?? strName(group.name); + if (key) keys.add(key); + } + index.set(name, keys); + } + return index; +} + +/** One `section.group` reference, with the path that located it. */ +export interface SectionGroupRef { + /** The referenced field-group key, as authored. */ + key: string; + /** Config path of the `group` key itself. */ + path: string; +} + +/** + * Pull `group` references out of a `sections`-shaped value. + * + * `sep` joins the index onto `basePath` the way the calling surface addresses + * itself — `.` for metadata config paths, and the react surface's ` › ` if it + * ever grows a section-bearing block. + */ +export function sectionGroupRefs(sections: unknown, basePath: string, sep = '.'): SectionGroupRef[] { + if (!Array.isArray(sections)) return []; + const out: SectionGroupRef[] = []; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!isRec(section)) continue; + const key = strName(section.group); + if (key) out.push({ key, path: `${basePath}[${i}]${sep}group` }); + } + return out; +} + +/** The finding shape both host rules already declare, structurally. */ +export interface SectionGroupFinding { + severity: 'warning'; + rule: string; + where: string; + path: string; + message: string; + hint: string; +} + +/** + * Check one batch of group references against `objectName`'s declared groups. + * + * Bails out when the object is not defined in this stack — it may come from + * another installed package, and a group cannot be judged on a schema we cannot + * see. That is the same skip {@link checkFieldRefs} takes, and for the same + * reason: silence about an unknowable object beats a finding the author cannot + * act on. + */ +export function checkSectionGroupRefs( + refs: readonly SectionGroupRef[], + objectName: string | undefined, + objectFieldGroups: ReadonlyMap>, + where: string, + rule: string, +): SectionGroupFinding[] { + const findings: SectionGroupFinding[] = []; + if (!objectName) return findings; + const declared = objectFieldGroups.get(objectName); + if (!declared) return findings; // cross-package object — unknowable here + for (const ref of refs) { + if (declared.has(ref.key)) continue; + findings.push({ + severity: 'warning', + rule, + where, + path: ref.path, + message: + `section references field group "${ref.key}", which object "${objectName}" does not ` + + 'declare — the section inherits its members from that group, so it resolves to no ' + + 'fields and the whole section silently does not render', + hint: + `Fix the key, or declare it: \`fieldGroups: [{ key: '${ref.key}', label: '…' }]\` on ` + + `${objectName}, with each member field carrying \`group: '${ref.key}'\`. ` + + (declared.size > 0 + ? `Declared groups on ${objectName}: ${[...declared].sort().join(', ')}.` + : `${objectName} declares no field groups at all, so no section on it can reference one ` + + 'yet — enumerate the members with `fields` until it does.'), + }); + } + return findings; +} diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index d6f71d77b6..ea6b60dc05 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -6,6 +6,7 @@ import { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, + FORM_SECTION_GROUP_UNKNOWN, } from './validate-form-layout.js'; type AnyRec = Record; @@ -14,6 +15,28 @@ const objects = [ { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, ]; +/** + * [#13855] The same object, plus the declared field groups a section may now + * reference. Kept separate from `objects` so every pre-existing test above + * still runs against an object with NO groups — which is also the state the + * `declares no field groups at all` hint is written for. + */ +const groupedObjects = [ + { + name: 'contract', + fieldGroups: [ + { key: 'basics', label: 'Basics' }, + { key: 'money', label: 'Money' }, + ], + fields: { + name: { group: 'basics' }, + amount: { group: 'money' }, + status: {}, + notes: {}, + }, + }, +]; + describe('validateFormLayout (#2578)', () => { it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => { const stack = { @@ -116,6 +139,83 @@ describe('validateFormLayout (#2578)', () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +// #13855 — `section.group` names a field group on the bound object. +// +// The spec door takes any well-formed snake_case key (the cross-schema +// existence question is not one a section schema can answer — the +// `UserFilterFieldSchema.field` precedent), so THIS is where a dangling key is +// reported. A miss is total, not partial: the reference form and the enumerated +// form are mutually exclusive at parse, so a key that resolves to nothing +// leaves the section with no member source at all and it does not render. +// ─────────────────────────────────────────────────────────────────────────── + +describe('#13855 — dangling `section.group` references', () => { + const viewWith = (sections: unknown, bucket: 'sections' | 'groups' = 'sections') => ({ + objects: groupedObjects, + views: [{ + name: 'contract_form', + data: { provider: 'object', object: 'contract' }, + [bucket]: sections, + }], + }); + + it('is clean when the key names a declared group', () => { + expect(validateFormLayout(viewWith([{ group: 'basics' }, { group: 'money' }]))).toEqual([]); + }); + + it('flags a key that names no declared group, with the declared set in the hint', () => { + const findings = validateFormLayout(viewWith([{ group: 'contact_info' }])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FORM_SECTION_GROUP_UNKNOWN); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('views[0].sections[0].group'); + expect(findings[0].message).toContain('field group "contact_info"'); + // The consequence, in the words the severity is argued from. + expect(findings[0].message).toContain('silently does not render'); + expect(findings[0].hint).toContain('Declared groups on contract: basics, money.'); + }); + + it('reads the LEGACY `groups` bucket too — a rule silent on the alias is half a rule', () => { + const findings = validateFormLayout(viewWith([{ group: 'nope' }], 'groups')); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_SECTION_GROUP_UNKNOWN}@views[0].groups[0].group`, + ]); + }); + + it('tells an author with NO declared groups what to do instead', () => { + // `objects` (not `groupedObjects`) declares no `fieldGroups` at all. + const findings = validateFormLayout({ + objects, + views: [{ name: 'f', data: { provider: 'object', object: 'contract' }, sections: [{ group: 'basics' }] }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('declares no field groups at all'); + }); + + it('says nothing about an object this stack does not define', () => { + // Same skip the field-existence rule takes: the object may come from + // another installed package, and a group cannot be judged on a schema we + // cannot see. A finding here would be one the author cannot act on. + const findings = validateFormLayout({ + objects: groupedObjects, + views: [{ name: 'f', data: { provider: 'object', object: 'from_another_package' }, sections: [{ group: 'nope' }] }], + }); + expect(findings).toEqual([]); + }); + + it('reports the group miss alongside — not instead of — the field misses', () => { + const findings = validateFormLayout(viewWith([ + { group: 'ghost_group' }, + { fields: ['name', 'ghost_field'] }, + ])); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_SECTION_GROUP_UNKNOWN}@views[0].sections[0].group`, + `${FORM_FIELD_UNKNOWN}@views[0].sections[1].fields[1]`, + ]); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // #6251 — `views[]` is a view CONTAINER, and both rules above were unreachable // on the shape real apps actually ship. diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index 8b1388e55e..68da1f742c 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -30,10 +30,25 @@ */ import { collectionEntries } from './collection-entries.js'; +// [#13855] The field-group reference question, shared with +// `validate-page-field-bindings` so both layout escape hatches resolve a +// `section.group` against one index and report the same way. +import { + checkSectionGroupRefs, + indexObjectFieldGroups, + sectionGroupRefs, +} from './object-field-groups.js'; import { formViewSites, viewObjectName } from './view-walk.js'; export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged'; +/** + * [#13855] A section's `group` names a field group the bound object does not + * declare. The reference form inherits the section's whole membership from that + * group, so a dangling key leaves the section with no members and it does not + * render at all. Advisory like both rules above — see `object-field-groups.ts`. + */ +export const FORM_SECTION_GROUP_UNKNOWN = 'form-section-group-unknown'; export type FormLayoutSeverity = 'error' | 'warning'; @@ -115,6 +130,8 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { : []; objectFields.set(name, new Set(fields)); } + // [#13855] object name → its declared field-group keys, for `section.group`. + const objectFieldGroups = indexObjectFieldGroups(stack); for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { // A container names itself with `name`, or binds with `object` — and an @@ -143,6 +160,19 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { for (const bucket of ['sections', 'groups'] as const) { const sections = Array.isArray(site.view[bucket]) ? (site.view[bucket] as unknown[]) : []; + // ── (c) [#13855] a section's `group` names a declared field group ── + // Both buckets, for the reason (a) reads both: a rule that judges only + // the canonical spelling is silent on the legacy one. + findings.push( + ...checkSectionGroupRefs( + sectionGroupRefs(sections, `${site.path}.${bucket}`), + objName, + objectFieldGroups, + where, + FORM_SECTION_GROUP_UNKNOWN, + ), + ); + for (let s = 0; s < sections.length; s++) { const sec = sections[s]; const secFields = isRec(sec) && Array.isArray(sec.fields) ? (sec.fields as unknown[]) : []; diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 21d3f82f6c..405b5edfdb 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -4,9 +4,11 @@ import { describe, it, expect } from 'vitest'; import { validatePageFieldBindings, checkFieldRefs, + componentSectionGroupRefs, indexObjectFields, PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED, + PAGE_SECTION_GROUP_UNKNOWN, } from './validate-page-field-bindings.js'; import { indexUnprovisionedAnchors } from './system-fields.js'; @@ -127,6 +129,96 @@ describe('validatePageFieldBindings — record:details real authored shape', () }); }); +// ─────────────────────────────────────────────────────────────────────────── +// #13855 — a section may REFERENCE a declared field group instead of +// enumerating its members. The key names something on a different schema, so +// the spec door takes any well-formed one and the existence question lands +// here, with the field-existence family it belongs to. +// ─────────────────────────────────────────────────────────────────────────── + +describe('validatePageFieldBindings — dangling `section.group` (#13855)', () => { + /** `baseStack()` with declared field groups on `crm_lead`. */ + const groupedStack = () => { + const stack = baseStack(); + (stack.objects[0] as Record).fieldGroups = [ + { key: 'overview', label: 'Overview' }, + { key: 'money', label: 'Money' }, + ]; + return stack; + }; + + const detailsWith = (sections: unknown[]) => + [{ type: 'record:details', properties: { sections } }]; + + it('is clean when the key names a declared group', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }, { group: 'money' }]))], + }); + expect(findings).toEqual([]); + }); + + it('flags a key that names no declared group', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }, { group: 'contact_info' }]))], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PAGE_SECTION_GROUP_UNKNOWN); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.sections[1].group'); + expect(findings[0].message).toContain('field group "contact_info"'); + expect(findings[0].message).toContain('silently does not render'); + expect(findings[0].hint).toContain('Declared groups on crm_lead: money, overview.'); + }); + + it('reports the group miss alongside — not instead of — a field miss in a sibling section', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([ + { group: 'ghost_group' }, + { label: 'Money', fields: ['amount', 'nonexistent_field'] }, + ]))], + }); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${PAGE_FIELD_UNKNOWN}@pages[0].regions[0].components[0].properties.sections[1].fields[1]`, + `${PAGE_SECTION_GROUP_UNKNOWN}@pages[0].regions[0].components[0].properties.sections[0].group`, + ]); + }); + + it('tells an author on an object with NO declared groups what to do instead', () => { + const findings = validatePageFieldBindings({ + ...baseStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }]))], + }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('declares no field groups at all'); + }); + + it('says nothing about an object this stack does not define', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [{ + name: 'external_detail', + object: 'from_another_package', + regions: [{ name: 'main', components: detailsWith([{ group: 'nope' }]) }], + }], + }); + expect(findings).toEqual([]); + }); + + it('reads the group refs through the ONE descriptor table the field refs use', () => { + // `componentSectionGroupRefs` walks `COMPONENT_FIELD_SPECS[…].nestedSections` + // — the same list `componentFieldRefs` walks. A component that grows + // sections is covered by both checks in one edit, rather than gaining the + // field check and silently missing this one. + expect(componentSectionGroupRefs('record:details', { sections: [{ group: 'a' }, { fields: ['x'] }] }, 'P')) + .toEqual([{ key: 'a', path: 'P.sections[0].group' }]); + // Unregistered component types stay skipped, silently, exactly as for fields. + expect(componentSectionGroupRefs('record:line_items', { sections: [{ group: 'a' }] }, 'P')).toBeNull(); + }); +}); + describe('validatePageFieldBindings — related lists bind the related object', () => { it('checks columns against objectName, not the page object', () => { const findings = validatePageFieldBindings({ diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 55ed8bfd78..704c7d4c19 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -66,6 +66,14 @@ export const PAGE_FIELD_UNKNOWN = 'page-field-unknown'; * #8116's severity reasoning, unchanged. */ export const PAGE_FIELD_UNPROVISIONED = 'page-field-unprovisioned'; +/** + * [#13855] A section's `group` names a field group the bound object does not + * declare. The reference form inherits the section's whole membership from that + * group, so a dangling key leaves the section with no members and it does not + * render at all. Always `warning` — see `object-field-groups.ts` for why this + * sits with the family's advisory findings rather than gating. + */ +export const PAGE_SECTION_GROUP_UNKNOWN = 'page-section-group-unknown'; export type PageFieldSeverity = 'error' | 'warning'; @@ -99,6 +107,14 @@ import { unprovisionedAnchorCause, unprovisionedAnchorHint, } from './system-fields.js'; +// [#13855] The field-group reference question, shared with `validate-form-layout` +// so both layout escape hatches resolve a `section.group` against one index. +import { + checkSectionGroupRefs, + indexObjectFieldGroups, + sectionGroupRefs, + type SectionGroupRef, +} from './object-field-groups.js'; function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; @@ -263,6 +279,30 @@ export function componentFieldRefs( return refs; } +/** + * [#13855] The `group` references a component's section props hold — the same + * `nestedSections` list {@link componentFieldRefs} walks, asked the other + * question. Read from that one descriptor table on purpose: a component that + * grows sections is covered by both checks in one edit, instead of gaining the + * field check and silently missing the group one. + * + * `null` for a type with no descriptor, matching {@link componentFieldRefs}. + */ +export function componentSectionGroupRefs( + type: string, + props: AnyRec, + basePath: string, + sep = '.', +): SectionGroupRef[] | null { + const spec = COMPONENT_FIELD_SPECS[type]; + if (!spec) return null; + const refs: SectionGroupRef[] = []; + for (const key of spec.nestedSections ?? []) { + refs.push(...sectionGroupRefs(props[key], `${basePath}${sep}${key}`, sep)); + } + return refs; +} + /** A `record:related_list` props bag, split by which object each batch resolves against. */ export interface RelatedListFieldRefs { /** The related (child) object this list renders — `properties.objectName`. */ @@ -443,6 +483,8 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { // [#8340] The provenance index alongside the existence one — same keying, // asked on the path where the existence check stays silent. const unprovisionedAnchors = indexUnprovisionedAnchors(stack); + // [#13855] object name → its declared field-group keys, for `section.group`. + const objectFieldGroups = indexObjectFieldGroups(stack); const pages = asArray(stack.pages); for (let pi = 0; pi < pages.length; pi++) { @@ -476,6 +518,18 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const refs = componentFieldRefs(type, props, base); if (!refs) continue; // unregistered / non-field component — skip silently checkRefs(refs, objectName, where); + + // [#13855] The other question a section can raise: not "is this field + // real?" but "is this GROUP real?". Same walk, same object binding. + findings.push( + ...checkSectionGroupRefs( + componentSectionGroupRefs(type, props, base) ?? [], + objectName, + objectFieldGroups, + where, + PAGE_SECTION_GROUP_UNKNOWN, + ), + ); } // ── interfaceConfig (list pages) ── diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ff491722a3..37a12f0bde 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -245,6 +245,7 @@ "ExternalTable (type)", "ExternalTableParsed (type)", "ExternalTableSchema (const)", + "FIELD_GROUP_KEY_PATTERN (const)", "FIELD_GROUP_SYSTEM_FIELDS (const)", "FIELD_KEY_GUIDANCE (const)", "FIELD_MASKING_PRESETS (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 25267ec513..4e2856ceb3 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -442,6 +442,7 @@ "ui/FormSection:columns", "ui/FormSection:description", "ui/FormSection:fields", + "ui/FormSection:group", "ui/FormSection:label", "ui/FormSection:name", "ui/FormSection:pane", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 07b1b97cf9..ccf45dd1a2 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -245,6 +245,7 @@ "ExternalTable": "src/data/external-catalog.zod.ts#ExternalTable (type)", "ExternalTableParsed": "src/data/external-catalog.zod.ts#ExternalTableParsed (type)", "ExternalTableSchema": "src/data/external-catalog.zod.ts#ExternalTableSchema (const)", + "FIELD_GROUP_KEY_PATTERN": "src/data/field-group-layout.ts#FIELD_GROUP_KEY_PATTERN (const)", "FIELD_GROUP_SYSTEM_FIELDS": "src/data/field-group-layout.ts#FIELD_GROUP_SYSTEM_FIELDS (const)", "FIELD_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#FIELD_KEY_GUIDANCE (const)", "FIELD_MASKING_PRESETS": "src/data/field.zod.ts#FIELD_MASKING_PRESETS (const)", diff --git a/packages/spec/src/data/field-group-layout.ts b/packages/spec/src/data/field-group-layout.ts index 85ded1c036..54baee44ae 100644 --- a/packages/spec/src/data/field-group-layout.ts +++ b/packages/spec/src/data/field-group-layout.ts @@ -34,6 +34,21 @@ * flat/auto layout. */ +/** + * The field-group KEY grammar — lowercase snake_case, one declaration. + * + * Declared here rather than inline on `ObjectFieldGroupSchema.key` because the + * key is now written on TWO surfaces: the group that DECLARES it + * (`ObjectFieldGroupSchema.key`) and the layout sections that REFERENCE it + * (`FormSectionSchema.group`, `RecordDetailsProps.sections[].group` — #13855). + * Two spellings of one grammar is the failure `identifiers.zod.ts` records from + * the other side: the reference surface would accept a key the declaring + * surface refuses, so the reference could never resolve and the section would + * render nothing with nothing reported. This module is the ADR-0085 §5 single + * source for the grouping semantics, so it is where the key's shape belongs. + */ +export const FIELD_GROUP_KEY_PATTERN = /^[a-z_][a-z0-9_]*$/; + /** Collapse behaviour of a derived section (mirrors ObjectFieldGroupSchema.collapse). */ export type FieldGroupCollapse = 'none' | 'expanded' | 'collapsed'; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f0e6512b72..bce89b9a24 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -15,6 +15,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { retiredKey } from '../shared/retired-key'; +import { FIELD_GROUP_KEY_PATTERN } from './field-group-layout'; export const ApiMethod = z.enum([ 'get', 'list', // Read 'create', 'update', 'delete', // Write @@ -1157,10 +1158,16 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ 'Write `visibleWhen: ` — FALSE hides the whole group, header included.', }, }, { - /** Group key — referenced by `Field.group` to assign a field to this group. Must be snake_case. */ - key: z.string().regex(/^[a-z_][a-z0-9_]*$/, { + /** + * Group key — referenced by `Field.group` to assign a field to this group, + * and (since #13855) by a layout section's `group` to inherit the whole + * group. Must be snake_case; the grammar is single-sourced as + * `FIELD_GROUP_KEY_PATTERN` beside the derivation it belongs to, so the + * declaring surface and the referencing surfaces cannot drift apart. + */ + key: z.string().regex(FIELD_GROUP_KEY_PATTERN, { message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', - }).describe('Group machine key (snake_case). Referenced by Field.group.'), + }).describe('Group machine key (snake_case). Referenced by Field.group, and by a layout section\'s `group`.'), /** Human-readable label displayed as the group header. */ label: z.string().describe('Group display label'), diff --git a/packages/spec/src/shared/section-group-reference.ts b/packages/spec/src/shared/section-group-reference.ts new file mode 100644 index 0000000000..16e68e091a --- /dev/null +++ b/packages/spec/src/shared/section-group-reference.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Section → field-group REFERENCE — the shared mixing rule for both layout + * escape hatches (#13855, maintainer ruling 2026-08-31: 「直接处理b」). + * + * ## What this closes + * + * ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled + * in ONE place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape + * hatches — a custom record page's `record:details` `properties.sections`, and a + * view-level `form.sections` — were whole-takeover shapes: every section + * enumerated its members by hand, with zero mechanical link back to the + * declaration. An author who reached for either had to hand-copy the same + * membership fact a second and third time, and every field added to the object + * afterwards made those copies quietly staler. Measured on a real app: three + * disagreeing groupings of one object, with the detail page missing two fields + * the form showed. + * + * A section may now name a declared group instead: `{ group: 'contact_info' }`. + * Membership and the group's own presentation (label, icon, description, + * collapse, `visibleWhen`, and the empty-group drop) come from + * `deriveFieldGroupLayout` — the section re-declares none of it. + * + * ## The mixing rule, in one place because it is the same rule twice + * + * The two section vocabularies are not identical (a form section has + * `visibleWhen`/`pane`; a detail section has `hideEmpty`/`showBorder`/ + * `headerColor`), but the RULE over them is, so it is declared once and each + * surface passes its own key names in. Two copies of a mixing rule is how one + * gets fixed and the other does not. + * + * 1. **A section declares its members exactly one way.** `group` and `fields` + * are mutually exclusive, and a section that declares NEITHER is refused — + * it has no body at all, and before this shape existed that was + * unrepresentable because `fields` was required. + * 2. **A group-referencing section carries no key the group already declares.** + * Not a precedence rule — the ABSENCE of one. Letting a section restate + * `label` / `collapse` / `visibleWhen` beside `group` would put group + * presentation back in section land, which is the one thing ADR-0085 §5 + * single-sources; and two writable spellings of one fact is the hand-copy + * this whole reference form exists to remove. Refused at parse with the + * pointer to the `fieldGroups` entry that owns the key. + * + * ⚠️ This is the CONSERVATIVE direction on purpose. Refusing now and + * allowing section-level overrides later is additive; shipping overrides and + * withdrawing them later is a breaking change. The alternative reading — + * section keys override the group's — is named in the PR body for contract + * review rather than decided here. + * 3. **Across sections, both kinds coexist in declared array order.** A + * group-referencing section occupies exactly one slot and expands in place + * to that group's derived members. Nothing about ordering changes: `sections` + * is still read top to bottom. + * + * ## What this module deliberately does NOT do + * + * It never resolves the key. A section schema cannot see the object's + * `fieldGroups` — that is a cross-schema reference, and this repo has one + * channel for those: reference diagnostics (the `UserFilterFieldSchema.field` + * precedent, *"must exist — checked by reference diagnostics"*). Parse accepts + * any well-formed key; `page-section-group-unknown` / `form-section-group-unknown` + * in `@objectstack/lint` report one that resolves to nothing. + */ + +import { z } from 'zod'; + +import { FIELD_GROUP_KEY_PATTERN } from '../data/field-group-layout'; + +/** + * The `group` key as a layout section writes it — the same grammar the + * declaring `ObjectFieldGroupSchema.key` enforces, read from the one pattern + * both share so the reference cannot accept a key the declaration refuses. + * + * Existence is NOT checked here (see the module note): a well-formed key that + * names no declared group parses, and reference diagnostics report it. + */ +export const SectionGroupKeySchema = z.string().regex(FIELD_GROUP_KEY_PATTERN, { + message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', +}); + +/** The section keys this rule reads, as any surface's section may carry them. */ +interface SectionLike { + group?: unknown; + fields?: unknown; + [key: string]: unknown; +} + +export interface SectionGroupReferenceOptions { + /** Prose name of the section surface, e.g. ``'this `record:details` section'``. */ + surface: string; + /** + * Keys whose value `deriveFieldGroupLayout` supplies from the group — refused + * beside `group`, whatever they are set to. + */ + derivedKeys: readonly string[]; + /** + * Derived keys carrying a schema `.default(false)`, so by the time an + * object-level refinement runs an authored `false` is indistinguishable from + * the default. Only `true` is refused — the same asymmetry, for the same + * reason, as the wizard step-key refusals in `view.zod.ts`. + */ + trueOnlyDerivedKeys?: readonly string[]; +} + +/** + * The shared mixing rule as a `superRefine` body. Attach to a section shape + * whose `group` and `fields` are both optional: + * + * ```ts + * strictObject({ … }, { group: SectionGroupKeySchema.optional(), fields: […].optional(), … }) + * .superRefine(sectionGroupReferenceRefinement({ surface: '…', derivedKeys: […] })) + * ``` + */ +export function sectionGroupReferenceRefinement( + options: SectionGroupReferenceOptions, +): (section: SectionLike, ctx: z.RefinementCtx) => void { + const { surface, derivedKeys, trueOnlyDerivedKeys = [] } = options; + return (section, ctx) => { + if (!section || typeof section !== 'object') return; + const groupKey = typeof section.group === 'string' ? section.group : undefined; + const hasGroup = groupKey !== undefined && groupKey.length > 0; + // PRESENCE, not non-emptiness: `fields: []` is an authored (if empty) + // enumeration and parsed before this key existed, so it must keep parsing. + const hasFields = Array.isArray(section.fields); + + if (hasGroup && hasFields) { + ctx.addIssue({ + code: 'custom', + path: ['group'], + message: + '`group` and `fields` are mutually exclusive on ' + surface + '. `group` DERIVES the ' + + "member list from the object's declared `fieldGroups` entry (`deriveFieldGroupLayout`, " + + 'ADR-0085 §5); `fields` enumerates it by hand. Writing both makes two sources for one ' + + 'fact — the hand-copy this reference form exists to remove, and the copy is what goes ' + + "stale as the object gains fields. Keep `group: '" + groupKey + "'` to inherit the " + + 'group, or drop `group` and keep the enumerated `fields`.', + }); + return; + } + + if (!hasGroup && !hasFields) { + ctx.addIssue({ + code: 'custom', + path: ['fields'], + message: + 'A section must declare its members exactly one way, and ' + surface + ' declares ' + + "neither: write `fields: ['a', 'b']` to enumerate them, or `group: ''` " + + "to inherit the object's declared `fieldGroups` entry (membership and the group's own " + + 'presentation are derived by `deriveFieldGroupLayout`, ADR-0085 §5). A section with ' + + 'neither has no body and renders nothing.', + }); + return; + } + + if (!hasGroup) return; + + for (const key of derivedKeys) { + if (section[key] === undefined) continue; + ctx.addIssue({ code: 'custom', path: [key], message: derivedKeyMessage(key, groupKey, surface) }); + } + for (const key of trueOnlyDerivedKeys) { + if (section[key] !== true) continue; + ctx.addIssue({ code: 'custom', path: [key], message: derivedKeyMessage(key, groupKey, surface) }); + } + }; +} + +function derivedKeyMessage(key: string, groupKey: string | undefined, surface: string): string { + return ( + '`' + key + '` cannot be combined with `group` on ' + surface + '. A group-referencing ' + + "section takes its presentation from the object's `fieldGroups` entry for `" + + (groupKey ?? '') + '` — `deriveFieldGroupLayout` (ADR-0085 §5) is the single source of ' + + 'the label, icon, description, collapse state and `visibleWhen` a group renders with, so ' + + 'restating one here would be a second writable spelling of the same fact. Set `' + key + + '` on that `fieldGroups` entry instead (it then applies on every surface the group renders ' + + 'on), or drop `group` and enumerate `fields` to author this section standalone.' + ); +} diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 97678b04ea..14ed30b8dc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -222,6 +222,9 @@ import { retiredKey } from '../shared/retired-key'; import { SortItemSchema } from '../shared/enums.zod'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; +// [#13855] The section → field-group reference form, shared with +// `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches. +import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference'; /** * What silently happened to an undeclared prop before these shapes were closed @@ -843,6 +846,13 @@ export const RecordDetailsProps = strictObject({ sections: z.array(strictObject({ surface: 'this `record:details` section', history: PROPS_HISTORY, + // Both are the author reaching for the #13855 reference form with the word + // the neighbouring surface uses: the object declares `fieldGroups`, and the + // field points back with `group`. Neither is a typo edit distance reaches. + aliases: { + fieldGroup: 'group', + groupKey: 'group', + }, }, { /** * Stable section identifier, snake_case. This is the i18n anchor: the @@ -867,8 +877,37 @@ export const RecordDetailsProps = strictObject({ * #5611 is fixing, so the shape that documents itself truthfully wins. */ columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'), - /** Field names shown in this section, in order. */ - fields: z.array(z.string()).describe('Field names rendered in this section, in order'), + /** + * [#13855] Reference a declared field GROUP instead of enumerating members + * — the delta form ruled 2026-08-31 (maintainer: 「直接处理b」). + * + * `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with + * that key: its members (every visible field whose `Field.group` points at + * it, in field-declaration order) and its own presentation (label, icon, + * description, `collapse`, `visibleWhen`, and the drop when the group has no + * visible members) all come from `deriveFieldGroupLayout` (ADR-0085 §5). + * The section restates none of it — see + * {@link sectionGroupReferenceRefinement} for the mixing rule and why the + * keys the group owns are refused here rather than given a precedence. + * + * Existence is NOT a parse question: the key names something on a DIFFERENT + * schema, so it follows the `UserFilterFieldSchema.field` precedent — parse + * takes any well-formed key and `page-section-group-unknown` (`@objectstack/lint`) + * reports one that resolves to no declared group. + */ + group: SectionGroupKeySchema.optional().describe( + 'Field group key (snake_case) whose members and presentation this section inherits, from the object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `icon`, `description`, `collapsible`, `defaultCollapsed`). Must name a declared group — checked by reference diagnostics.', + ), + /** + * Field names shown in this section, in order. + * + * Optional since #13855 — and optional ONLY in the sense that `group` is the + * other way to declare the same fact. A section carrying neither is refused + * (see {@link sectionGroupReferenceRefinement}), so no section reaches a + * renderer without a member source, which is what the previously-required + * key guaranteed. + */ + fields: z.array(z.string()).optional().describe('Field names rendered in this section, in order. Omit only when `group` supplies the members instead.'), /** * The three presentation keys the renderer has honoured all along, * declared at last (#11289, maintainer ruling 2026-08-23 — direction 1: @@ -945,7 +984,19 @@ export const RecordDetailsProps = strictObject({ * renderer's own fallback. */ headerColor: z.enum(['muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10']).optional().describe('Section-header background tint, from the closed six-token vocabulary rendered by objectui\'s `record:details` header (`muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header.'), - })).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`.'), + }).superRefine(sectionGroupReferenceRefinement({ + surface: 'this `record:details` section', + // Exactly the keys `deriveFieldGroupLayout` fills from the group. `name` is + // in the list because the derived section's `key` IS the group key, and the + // group key is already this surface's i18n anchor + // (`objects.._sections..label`) — a second name would give the + // same section two lookup identities. + // + // NOT in the list, deliberately: `columns`, `hideEmpty`, `showBorder`, + // `headerColor`. Those are how THIS page lays the section out and the group + // declares nothing about them, so there is no second source to create. + derivedKeys: ['name', 'label', 'icon', 'description', 'collapsible', 'defaultCollapsed'], + }))).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object\'s `fieldGroups` entry (ADR-0085 §5).'), fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'), /** * Field names to omit from the body, applied to both `fields` and every diff --git a/packages/spec/src/ui/section-group-reference.test.ts b/packages/spec/src/ui/section-group-reference.test.ts new file mode 100644 index 0000000000..e1568c5b20 --- /dev/null +++ b/packages/spec/src/ui/section-group-reference.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13855 — a layout section may REFERENCE a declared field group instead of + * enumerating its members (maintainer ruling 2026-08-31: 「直接处理b」). + * + * ## What is being pinned + * + * The delta form, on both layout escape hatches, through their real doors: + * `ComponentPropsMap['record:details']` for a custom record page's sections, + * and `FormViewSchema` for a view-level `form.sections`. Both take + * `{ group: '' }` in place of `fields`, and both refuse the combinations + * the mixing rule makes meaningless. + * + * ## Why the refusals assert MESSAGES, not just failure + * + * Same rule as `view-form-features-root.test.ts`: a bare + * `expect(success).toBe(false)` carries one bit, and each of these refusals has + * two — *that* the shape is refused, and *what the author is told to write + * instead*. The prescription is the whole point of refusing at the authoring + * door rather than letting the section render empty, so it is pinned. + * + * ## Acceptance is pinned as hard as refusal + * + * Three accept pins carry the weight the refusals cannot: + * + * 1. the reference form parses, and the KEY SURVIVES the parse (a section that + * accepted `group` and dropped it on the floor would satisfy every refusal + * pin below while delivering nothing — and on the form surface the value has + * to survive a `.transform()` and an `.overwrite()` fold to get out); + * 2. the enumerated form is untouched — `fields` going optional is a widening + * for `group`'s sake and must not have loosened anything else; + * 3. the surface keys the group does NOT declare still ride beside `group` + * (`columns`, `pane`, `hideEmpty`, `showBorder`, `headerColor`). A refusal + * list written one key too wide is indistinguishable from a correct one + * until exactly this parses. + * + * Plus the control every accept widening owes: an unknown sibling key is still + * refused, so the shape did not go strict-less on the way through. + */ + +import { describe, it, expect } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; +import { FormViewSchema } from './view.zod'; + +type Issue = { code: string; path: Array; message: string }; + +const RecordDetails = ComponentPropsMap['record:details']; + +function detailsIssues(value: unknown): Issue[] { + const r = RecordDetails.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []) as Issue[]; +} + +function detailsAccept(value: unknown): Record { + const r = RecordDetails.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); + return r.data as Record; +} + +/** A minimal form view carrying `sections`, so only the section shape varies. */ +function formWith(sections: unknown[], extra: Record = {}): unknown { + return { type: 'simple', sections, ...extra }; +} + +function formIssues(value: unknown): Issue[] { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []) as Issue[]; +} + +function formAccept(value: unknown): Record { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); + return r.data as Record; +} + +function at(issues: Issue[], path: Array): Issue { + const issue = issues.find(i => JSON.stringify(i.path) === JSON.stringify(path)); + expect(issue, `expected an issue at ${JSON.stringify(path)}, got ${JSON.stringify(issues)}`).toBeDefined(); + return issue!; +} + +describe('record:details section — the field-group reference form (#13855)', () => { + it('accepts a group reference and CARRIES THE KEY THROUGH the parse', () => { + const parsed = detailsAccept({ sections: [{ group: 'contact_info' }] }); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + // The other half of "carried": nothing invented a member list for it. + expect(sections[0].fields).toBeUndefined(); + }); + + it('keeps the enumerated form working unchanged', () => { + const parsed = detailsAccept({ + sections: [{ name: 'billing', label: 'Billing', fields: ['amount', 'due_at'], columns: 2 }], + }); + const sections = parsed.sections as Array>; + expect(sections[0].fields).toEqual(['amount', 'due_at']); + // `fields: []` parsed before `group` existed and must keep parsing — the + // mixing rule tests PRESENCE, never non-emptiness. + expect(RecordDetails.safeParse({ sections: [{ fields: [] }] }).success).toBe(true); + }); + + it('lets both kinds of section coexist, in declared order', () => { + const parsed = detailsAccept({ + sections: [{ group: 'contact_info' }, { label: 'Notes', fields: ['note'] }, { group: 'billing' }], + }); + const sections = parsed.sections as Array>; + expect(sections.map(s => s.group ?? s.label)).toEqual(['contact_info', 'Notes', 'billing']); + }); + + it('accepts the surface keys the group does NOT declare beside `group`', () => { + const parsed = detailsAccept({ + sections: [{ + group: 'contact_info', + columns: 2, + hideEmpty: false, + showBorder: true, + headerColor: 'muted', + }], + }); + const section = (parsed.sections as Array>)[0]; + expect(section.columns).toBe(2); + expect(section.hideEmpty).toBe(false); + expect(section.showBorder).toBe(true); + expect(section.headerColor).toBe('muted'); + }); + + it('refuses `group` beside `fields` — two sources for one fact', () => { + const issue = at(detailsIssues({ sections: [{ group: 'contact_info', fields: ['email'] }] }), [ + 'sections', 0, 'group', + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('`group` and `fields` are mutually exclusive'); + // The prescription: which one to keep, and why the pair is wrong. + expect(issue.message).toContain('deriveFieldGroupLayout'); + expect(issue.message).toContain("Keep `group: 'contact_info'`"); + }); + + it('refuses a section that declares NEITHER — it would render nothing', () => { + const issue = at(detailsIssues({ sections: [{ label: 'Orphan' }] }), ['sections', 0, 'fields']); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('must declare its members exactly one way'); + expect(issue.message).toContain('renders nothing'); + }); + + it.each([ + ['name', 'contact'], + ['label', 'Contact'], + ['icon', 'user'], + ['description', 'How to reach them'], + ['collapsible', true], + ['defaultCollapsed', true], + ])('refuses `%s` beside `group` — the group owns it', (key, value) => { + const issue = at(detailsIssues({ sections: [{ group: 'contact_info', [key]: value }] }), [ + 'sections', 0, key, + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain(`\`${key}\` cannot be combined with \`group\``); + // Points at the entry that owns the key, not merely at the refusal. + expect(issue.message).toContain('`fieldGroups` entry for `contact_info`'); + expect(issue.message).toContain('ADR-0085 §5'); + }); + + it('still refuses an unknown sibling key (the widening did not go strict-less)', () => { + const issues = detailsIssues({ sections: [{ group: 'contact_info', collapsedByDefault: true }] }); + const messages = issues.map(i => i.message).join('\n'); + expect(messages).toContain('collapsedByDefault'); + }); + + it('points a near-miss spelling of the new key at `group`', () => { + const messages = detailsIssues({ sections: [{ fieldGroup: 'contact_info' }] }) + .map(i => i.message).join('\n'); + expect(messages).toContain('fieldGroup'); + expect(messages).toContain('group'); + }); +}); + +describe('form.sections — the field-group reference form (#13855)', () => { + it('accepts a group reference and carries the key through transform AND fold', () => { + // The value has to survive `FormSectionSchema`'s `.transform`, the form + // view's `.superRefine`, and the `groups → sections` `.overwrite()` fold. + const parsed = formAccept(formWith([{ group: 'contact_info' }])); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + expect(sections[0].fields).toBeUndefined(); + }); + + it('carries the key through the LEGACY `groups` bucket too', () => { + const parsed = formAccept({ type: 'simple', groups: [{ group: 'contact_info' }] }); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + expect(parsed.groups).toBeUndefined(); + }); + + it('keeps the enumerated form working unchanged', () => { + const parsed = formAccept(formWith([{ label: 'Billing', fields: ['amount', { field: 'due_at' }] }])); + const sections = parsed.sections as Array>; + expect((sections[0].fields as unknown[]).length).toBe(2); + expect(FormViewSchema.safeParse(formWith([{ fields: [] }])).success).toBe(true); + }); + + it('accepts `columns` and `pane` beside `group` — the group declares neither', () => { + const parsed = formAccept(formWith([{ group: 'contact_info', columns: 2, pane: 'secondary' }], { + type: 'split', + })); + const section = (parsed.sections as Array>)[0]; + expect(section.columns).toBe(2); + expect(section.pane).toBe('secondary'); + }); + + it('refuses `group` beside `fields`', () => { + const issue = at(formIssues(formWith([{ group: 'contact_info', fields: ['email'] }])), [ + 'sections', 0, 'group', + ]); + expect(issue.message).toContain('`group` and `fields` are mutually exclusive'); + expect(issue.message).toContain('this form section'); + }); + + it('refuses a section that declares NEITHER', () => { + const issue = at(formIssues(formWith([{ label: 'Orphan' }])), ['sections', 0, 'fields']); + expect(issue.message).toContain('must declare its members exactly one way'); + }); + + it.each([ + ['name', 'contact'], + ['label', 'Contact'], + ['description', 'How to reach them'], + ['visibleWhen', "record.type == 'person'"], + ['visibleOn', "record.type == 'person'"], + ['collapsible', true], + ['collapsed', true], + ])('refuses `%s` beside `group` — the group owns it', (key, value) => { + const issue = at(formIssues(formWith([{ group: 'contact_info', [key]: value }])), [ + 'sections', 0, key, + ]); + expect(issue.message).toContain(`\`${key}\` cannot be combined with \`group\``); + expect(issue.message).toContain('`fieldGroups` entry for `contact_info`'); + }); + + it('refuses `visibleOn` beside `group` at ITS OWN path, before the fold renames it', () => { + // The deprecated spelling folds onto `visibleWhen` in a `.transform` that + // runs AFTER this refinement. Naming only the canonical key would leave + // `visibleOn` as the one way to smuggle a section predicate past the rule. + const paths = formIssues(formWith([{ group: 'contact_info', visibleOn: 'true' }])).map(i => i.path); + expect(paths).toContainEqual(['sections', 0, 'visibleOn']); + }); + + it('accepts an authored `collapsible: false` beside `group` — indistinguishable from the default', () => { + // Not leniency: `collapsible`/`collapsed` carry `.default(false)`, so by the + // time an object-level refinement runs, an authored `false` and an absent + // key are the same value — the same asymmetry #13704 records for the wizard + // step keys. `false` also declares exactly what `collapse: 'none'` delivers. + expect(FormViewSchema.safeParse( + formWith([{ group: 'contact_info', collapsible: false, collapsed: false }]), + ).success).toBe(true); + }); + + it('refuses `group` on a WIZARD step', () => { + const issue = at(formIssues(formWith([{ group: 'contact_info' }], { type: 'wizard' })), [ + 'sections', 0, 'group', + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('`group` on a wizard step is refused'); + // The reason, and the two ways out. + expect(issue.message).toContain('deriveFieldGroupLayout'); + expect(issue.message).toContain('Enumerate the step with `fields: [...]`'); + }); + + it('still accepts an enumerated wizard step (the refusal is scoped to `group`)', () => { + expect(FormViewSchema.safeParse( + formWith([{ label: 'Step 1', fields: ['name'] }], { type: 'wizard' }), + ).success).toBe(true); + }); + + it('still refuses an unknown sibling key', () => { + const messages = formIssues(formWith([{ group: 'contact_info', collapsedByDefault: true }])) + .map(i => i.message).join('\n'); + expect(messages).toContain('collapsedByDefault'); + }); + + it('points a near-miss spelling of the new key at `group`', () => { + const messages = formIssues(formWith([{ groupKey: 'contact_info' }])).map(i => i.message).join('\n'); + expect(messages).toContain('groupKey'); + expect(messages).toContain('group'); + }); + + it('refuses a key the group grammar rejects, at the `group` path', () => { + // The reference surface reads the SAME pattern the declaring + // `ObjectFieldGroupSchema.key` enforces (`FIELD_GROUP_KEY_PATTERN`), so a + // key one surface refuses can never be written on the other. + const paths = formIssues(formWith([{ group: 'Contact Info' }])).map(i => i.path); + expect(paths).toContainEqual(['sections', 0, 'group']); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 75fb25f71f..f32a0881d5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -56,6 +56,10 @@ import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../share import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; +// [#13855] The section → field-group reference form, shared with the +// `record:details` section shape (component.zod.ts) so one mixing rule serves +// both layout escape hatches. +import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -2422,6 +2426,16 @@ export const FormSectionSchema = lazySchema(() => strictObject({ // to rename it in place. The reader can only tell which answer is theirs if // the rejection names the shape, so each of the three shapes names itself. surface: 'this form section', + // [#13855] Both are the author reaching for the field-group reference form + // with the word a neighbouring surface uses: the object declares + // `fieldGroups`, and a field points back with `group`. Neither is a typo edit + // distance reaches. Spread the inherited table first so nothing it carries is + // dropped by re-declaring the key. + aliases: { + ...VISIBILITY_ONLY_STRICT_OPTIONS.aliases, + fieldGroup: 'group', + groupKey: 'group', + }, }, { /** * Stable identifier for translation lookup. snake_case convention. @@ -2502,11 +2516,65 @@ export const FormSectionSchema = lazySchema(() => strictObject({ 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'.", ), + /** + * [#13855] Reference a declared field GROUP instead of enumerating members — + * the delta form ruled 2026-08-31 (maintainer: 「直接处理b」). + * + * `{ group: 'contact_info' }` inherits the bound object's `fieldGroups` entry + * with that key: its members (every visible field whose `Field.group` points + * at it, in field-declaration order) and its own presentation (label, + * description, `collapse`, `visibleWhen`, and the drop when the group has no + * visible members) all come from `deriveFieldGroupLayout` (ADR-0085 §5). The + * section restates none of it — see {@link sectionGroupReferenceRefinement} + * for the mixing rule, and for why the keys the group owns are refused beside + * `group` rather than given a precedence. + * + * Existence is NOT a parse question: the key names something on a DIFFERENT + * schema, so it follows the `UserFilterFieldSchema.field` precedent — parse + * takes any well-formed key, and `form-section-group-unknown` + * (`@objectstack/lint`) reports one that resolves to no declared group. + * + * ⛔ Not on a wizard step — refused by the {@link FormViewSchema} refinement, + * for the reason #13704 refused the step keys themselves: a group carries + * `visibleWhen` and `collapse`, and a wizard step has no slot for either. + */ + group: SectionGroupKeySchema.optional().describe( + 'Field group key (snake_case) whose members and presentation this section inherits, from the bound object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics.', + ), + /** + * The section's members, enumerated. + * + * Optional since #13855 — and optional ONLY in the sense that `group` is the + * other way to declare the same fact. A section carrying neither is refused + * (see {@link sectionGroupReferenceRefinement}), so no section reaches a + * renderer without a member source, which is what the previously-required key + * guaranteed. + */ fields: z.array(z.union([ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config - ])), -}).transform(normalizeVisibleWhen)); + ])).optional(), +}).superRefine(sectionGroupReferenceRefinement({ + surface: 'this form section', + // Exactly the keys `deriveFieldGroupLayout` fills from the group. `name` is + // in the list because the derived section's `key` IS the group key, which is + // already this surface's i18n anchor (`metadataForms..sections.`) + // — a second name would give one section two lookup identities. `visibleOn` + // rides along because this refinement runs BEFORE the `.transform` that folds + // it onto `visibleWhen`, so the deprecated spelling must be named here or it + // would be the one way to smuggle a section-level predicate past the rule. + // + // NOT in the list, deliberately: `columns` and `pane`. Those are how THIS + // form lays the section out and the group declares nothing about them, so + // there is no second source to create. + derivedKeys: ['name', 'label', 'description', 'visibleWhen', 'visibleOn'], + // `collapsible` / `collapsed` carry `.default(false)`, so an authored `false` + // is already indistinguishable from the default by the time this runs — the + // same asymmetry #13704 records for the wizard step keys, and it costs + // nothing: `false` declares exactly what a group with `collapse: 'none'` + // delivers. Only `true` declares presentation the group owns. + trueOnlyDerivedKeys: ['collapsible', 'collapsed'], +})).transform(normalizeVisibleWhen)); /** * A single form action button (submit / cancel / reset): visibility + label. @@ -3258,6 +3326,35 @@ export const FormViewSchema = lazySchema(() => strictObject({ }); } } + // [#13855] The field-group REFERENCE form is refused on a wizard step + // for the same reason the two refusals above exist, one layer back: a + // `fieldGroups` entry carries `visibleWhen` and `collapse`, and + // `deriveFieldGroupLayout` passes both through to the derived section. + // Accepting `group` here would hand a wizard step exactly the predicate + // and collapse state #13704 just finished refusing — reached through + // the object's declaration instead of the step's own keys, so the + // refusals above would report clean while the behaviour arrived anyway. + // The section schema cannot see the group (that is cross-schema), so + // the honest answer at parse is to refuse the reference on this form + // type, not to guess what the group declares. + // + // Deliberately the CONSERVATIVE direction: a wizard step whose group + // declares neither key is legal metadata this refuses today, and + // allowing it later (once the renderer half lands and a ruling covers a + // derived predicate on a step) is additive. + if (section.group != null) { + ctx.addIssue({ + code: 'custom', + path: [key, index, 'group'], + message: + '`group` on a wizard step is refused: a field group carries `visibleWhen` and ' + + '`collapse`, and `deriveFieldGroupLayout` passes both through to the section it ' + + 'derives — neither of which a wizard step has a slot for (steps are entered in ' + + 'array order behind the step gate, and the wizard shows exactly the current step). ' + + 'Enumerate the step with `fields: [...]`, or use a `simple`/`tabbed` form to ' + + 'inherit the group.', + }); + } } }); }