# Object-Form Flexible-Field Grouping — Design **Date:** 2026-06-08 **Status:** Approved (brainstorming) — ready for implementation planning. **Issue:** #45 (deferred half — the object **form**'s flexible-field grouping; the detail view shipped in `e2ae093`). ## Context #45 fixed the object **detail** view: flexible fields are now grouped by `FieldDefinitionView.group` in definition order with a trailing "Other" bucket (`object-detail.tsx`). The issue's problem #2 explicitly says "detail **and** form", but the **form** still renders flexible-field inputs flat — one `
` with a single "Catalogue fields" legend and `definitions.map(...)` (definition order, no group subheadings). This milestone brings the form to parity by reusing the *same* grouping the detail view uses (extracted into a shared helper so they can't drift). **Facts:** `object-detail.tsx:71-94` builds `{ group, defs }[]` inline: iterate the definitions (filtered to those with a value), bucket by `def.group?.trim()` else `t("fields.other")`, sort so the "Other" group is last, plus orphan-key handling. `object-form.tsx` renders the flexible block as `
{form.flexibleHeading}{definitions.map((def) =>
{error}
)}
`. Field-definition fixtures carry `group` (e.g. `"Description"` / `null`). `field-list.tsx` also groups, but A–Z (a management list, from #50) — out of scope to unify with it. ### Decisions (from brainstorming) 1. Extract the definition-grouping into a shared `lib/group-fields.ts` and use it in **both** detail and form (one source of truth). 2. The form keeps its `
` for a11y but with an `sr-only` legend; visible per-group `label-caption` subheadings (matching the detail view) — no redundant double-heading. 3. Definition order within groups (no re-sort); ungrouped → trailing "Other". ## Components ### `web/src/lib/group-fields.ts` (new) ```ts import type { components } from "../api/schema"; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; export type FieldGroup = { group: string; defs: FieldDefinitionView[] }; /** Group field definitions by `def.group` (trimmed), preserving definition order * within and across groups; ungrouped defs fall into a trailing `otherLabel` bucket. */ export function groupDefinitions( definitions: FieldDefinitionView[], otherLabel: string, ): FieldGroup[] { const groups: FieldGroup[] = []; for (const def of definitions) { const group = def.group?.trim() ? def.group : otherLabel; let bucket = groups.find((g) => g.group === group); if (!bucket) { bucket = { group, defs: [] }; groups.push(bucket); } bucket.defs.push(def); } // Keep definition order among real groups; the "Other" bucket sorts last. groups.sort((a, b) => Number(a.group === otherLabel) - Number(b.group === otherLabel)); return groups; } ``` A unit test asserts: definition order preserved; two defs in the same group bucket together; ungrouped defs land in the `otherLabel` bucket which is **last** even when defined before a grouped def. ### `object-detail.tsx` (refactor to the shared helper) Replace the inline `for (const def of present) { … }` group-building + final `groups.sort(...)` with `const groups = groupDefinitions(present, other);`. Keep the existing orphan handling appended after (`if (orphans.length > 0 && !groups.some(g => g.group === other)) groups.push({ group: other, defs: [] });` — appending the Other bucket still leaves it last). Render is unchanged. Net: identical output, now via the shared helper. (Verify the existing object-detail tests stay green.) ### `object-form.tsx` (group the flexible inputs) Replace the flat flexible block with grouped rendering, reusing the FieldInput + per-field error markup inside each group: ```tsx {definitions && definitions.length > 0 && (
{t("form.flexibleHeading")} {groupDefinitions(definitions, t("fields.other")).map((g) => (
{g.group}
{g.defs.map((def) => (
{errors.fields?.[def.key] && (

{errors.fields[def.key]?.message ?? t("form.required")}

)}
))}
))}
)} ``` - The `` becomes `sr-only` (a11y-correct fieldset name) while the visible structure is the per-group `label-caption` subheadings — mirroring detail, no double-heading. - Field inputs and their error rendering are unchanged (just moved under group wrappers); the submit payload / validation / pruning are untouched (the same `definitions` drive the same inputs). - `import { groupDefinitions } from "../lib/group-fields";` (and `fields.other` is an existing key). ## Data flow `useFieldDefinitions()` → `groupDefinitions(defs, t("fields.other"))` → grouped subheadings + the same `FieldInput`s. No change to form state, RHF registration, pruning, or submission. ## Error handling / edges - Definition order preserved; "Other" last (even if an ungrouped def precedes a grouped one). - All-ungrouped case → a single "Other" group with a visible "Other" subheading (consistent with the detail view's behavior). - The shared helper takes the caller's def list: detail passes `present` (defs with values); the form passes all `definitions`. Orphan handling stays in detail only. - `sr-only` legend keeps the fieldset accessible without a visible redundant heading. ## Testing - **`group-fields.test.ts`** (unit): order preserved; same-group bucketing; ungrouped → trailing Other. - **object-form test:** with the field-definition fixtures (which include `group` values + ungrouped), rendering the form shows the grouped subheadings (e.g. "Description" then "Other") and the fields under them in definition order. (Extend `object-form.test.tsx`; reuse the existing MSW field-def handler / fixtures — add a `group` to one fixture def if needed to exercise a real named group.) - **object-detail tests** stay green (the refactor is output-preserving). - Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; no new i18n keys (reuse `fields.other`, `form.flexibleHeading`); en/sv parity unaffected; no codename; no new dependency. ## Acceptance criteria 1. `groupDefinitions` exists (shared, unit-tested) and is used by **both** object-detail and object-form. 2. The object form renders flexible-field inputs grouped by `def.group` (definition order, "Other" last) with `label-caption` subheadings; the fieldset keeps an `sr-only` legend. 3. The detail view's grouping is unchanged in output (now via the shared helper). 4. Form behavior is otherwise unchanged (inputs, validation, errors, submission, pruning). 5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no codename; no new dependency. ## Out of scope → follow-ups - Export/PDF (#39); backend label resolution. - Reordering fields within a group beyond definition order; a field-definition "position"/sort concept. - Unifying `field-list.tsx`'s (A–Z) grouping with this (different purpose — a management list).