diff --git a/docs/superpowers/specs/2026-06-08-form-field-grouping-design.md b/docs/superpowers/specs/2026-06-08-form-field-grouping-design.md new file mode 100644 index 0000000..d31be7e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-form-field-grouping-design.md @@ -0,0 +1,136 @@ +# 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).