// Declarative form layout (kernel PR #230): a model's create/edit form can // declare `form_layout` to group its fields into named sections, rendered either // as stacked (optionally collapsible) SECTIONS or as a multi-step WIZARD. // // Wire shape served on the table/modal metadata: // form_layout: { // mode: "sections" | "steps", // default "sections" // sections: [{ key, title?, description?, collapsed? }] // } // and, per field/column: `section: ""` referencing a section key. // // This module owns the PURE grouping logic (no React), so the two renderers // (`dynamic-form.tsx`, `dialogs/dynamic-record.tsx`) share one tested // implementation and the modal-render machinery never has to be booted under // happy-dom just to assert the grouping (same approach as PR #673). import type { VisibleWhen } from './types' import { evaluateVisibleWhen, getVisibleWhen } from './dynamic-form-schema' /** One declared section of a model form. `title`/`description` arrive already * localized from the kernel and are rendered verbatim. */ export interface FormSection { key: string title?: string description?: string /** Sections mode only: render this section collapsed on first paint. */ collapsed?: boolean /** Section-level visibility gate (kernel v0.84.0). When present and its * predicate evaluates false against the live form values, the whole section * is omitted (and in steps mode its wizard step drops from the sequence). * Tolerates the camelCase alias, same as fields. */ visible_when?: VisibleWhen visibleWhen?: VisibleWhen /** AI-assisted step (kernel v0.141.0): the SDK renders an interview panel * driven by the host's assist provider inside this section. */ assist?: FormAssist } /** Declaration of an AI-assisted step (`form_layout.sections[].assist`). */ export interface FormAssist { /** Host-registered provider key, e.g. `brand.website_dna`. */ provider: string label?: string description?: string /** Form fields sent to the provider when the session starts. */ input?: string[] /** Form fields the provider may fill when it finishes. */ output?: string[] /** `button` (default) or `auto` (start as soon as every input has a value). */ trigger?: 'button' | 'auto' } /** Model-level layout directive. `mode` defaults to `"sections"`. */ export interface FormLayout { mode?: 'sections' | 'steps' sections?: FormSection[] } /** A resolved group of fields ready to render: a section plus the (already * visibility-filtered) fields that belong to it. */ export interface FieldGroup { /** Section key, or `__default__` for the orphan group. */ key: string title?: string description?: string collapsed?: boolean /** True for the synthetic group holding section-less / unknown-section fields. */ isDefault: boolean fields: F[] /** AI-assisted step declaration, when the section has one. */ assist?: FormAssist } /** The synthetic key of the orphan group (fields with no / unknown `section`). */ export const DEFAULT_SECTION_KEY = '__default__' /** Reads a field's `section` reference, tolerating a camelCase alias. */ export function getFieldSection( field: { section?: string; Section?: string } | null | undefined, ): string | undefined { if (!field) return undefined const s = field.section ?? (field as { Section?: string }).Section return typeof s === 'string' && s !== '' ? s : undefined } /** * Groups already-filtered (visible) fields by their `section`, honoring the * order of `layout.sections`. * * Contract: * - No `layout` (or no `sections`) → a single default group carrying every * field in its original order. Callers render this exactly like the legacy * flat list, so the layout-less path is byte-for-byte the current behaviour. * - Fields whose `section` is empty or references an UNKNOWN section key are * collected into ONE default group placed FIRST (before any declared * section) — the consistent, documented choice: general/uncategorized fields * lead, declared sections follow in their authored order. * - A declared section with zero visible fields is OMITTED entirely, so a * section whose only members are hidden by `visible_when` never renders an * empty shell (and never yields an empty wizard step). Because the caller * passes the already visibility-filtered list, this falls out for free. * - A section declaring its OWN `visible_when` is dropped whole (before its * fields are even collected) whenever the predicate evaluates false against * `values`, reusing the SAME evaluator the fields use. In steps mode the * caller derives the wizard sequence from these groups, so a hidden section * simply never becomes a step. Section-less / unknown-section fields are * never gated by any section predicate. Without a section `visible_when` * (or without `values`), behaviour is byte-for-byte the legacy path. */ export function groupFieldsBySection( fields: F[], layout: FormLayout | undefined, values?: Record | null, ): FieldGroup[] { if (!layout?.sections?.length) { return [{ key: DEFAULT_SECTION_KEY, isDefault: true, fields }] } // Sections whose own `visible_when` predicate evaluates false are hidden // WHOLESALE: the section never emits a group/step AND its member fields are // dropped (they must not leak into the default group). Reuses the same // field-level evaluator — no duplicated logic. A `declared` set keeps a // field targeting a hidden section from being treated as "unknown section" // and orphaned. const declared = new Set(layout.sections.map((s) => s.key)) const visibleSections = layout.sections.filter((s) => evaluateVisibleWhen(getVisibleWhen(s), values), ) const known = new Set(visibleSections.map((s) => s.key)) const bySection = new Map() const orphans: F[] = [] for (const f of fields) { const sec = getFieldSection(f) if (sec && known.has(sec)) { const arr = bySection.get(sec) if (arr) arr.push(f) else bySection.set(sec, [f]) } else if (sec && declared.has(sec)) { // Belongs to a declared-but-hidden section → drop the field. continue } else { orphans.push(f) } } const groups: FieldGroup[] = [] // Orphan/default group leads (fields with no / unknown section). if (orphans.length) { groups.push({ key: DEFAULT_SECTION_KEY, isDefault: true, fields: orphans }) } for (const s of visibleSections) { const secFields = bySection.get(s.key) if (!secFields || secFields.length === 0) continue // hide empty section groups.push({ key: s.key, title: s.title, description: s.description, collapsed: s.collapsed, isDefault: false, fields: secFields, assist: s.assist, }) } return groups }