7.4 KiB
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
<fieldset> 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
<fieldset className="space-y-3 border-t pt-3"><legend className="label-caption">{form.flexibleHeading}</legend>{definitions.map((def) => <div key={def.key}><FieldInput .../>{error}</div>)}</fieldset>.
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)
- Extract the definition-grouping into a shared
lib/group-fields.tsand use it in both detail and form (one source of truth). - The form keeps its
<fieldset>for a11y but with ansr-onlylegend; visible per-grouplabel-captionsubheadings (matching the detail view) — no redundant double-heading. - Definition order within groups (no re-sort); ungrouped → trailing "Other".
Components
web/src/lib/group-fields.ts (new)
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:
{definitions && definitions.length > 0 && (
<fieldset className="space-y-3 border-t pt-3">
<legend className="sr-only">{t("form.flexibleHeading")}</legend>
{groupDefinitions(definitions, t("fields.other")).map((g) => (
<div key={g.group} className="space-y-3">
<div className="label-caption">{g.group}</div>
{g.defs.map((def) => (
<div key={def.key}>
<FieldInput definition={def} form={form} />
{errors.fields?.[def.key] && (
<p role="alert" className="text-xs text-destructive">
{errors.fields[def.key]?.message ?? t("form.required")}
</p>
)}
</div>
))}
</div>
))}
</fieldset>
)}
- The
<legend>becomessr-only(a11y-correct fieldset name) while the visible structure is the per-grouplabel-captionsubheadings — 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
definitionsdrive the same inputs). import { groupDefinitions } from "../lib/group-fields";(andfields.otheris an existing key).
Data flow
useFieldDefinitions() → groupDefinitions(defs, t("fields.other")) → grouped subheadings + the same
FieldInputs. 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 alldefinitions. Orphan handling stays in detail only. sr-onlylegend 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
groupvalues + ungrouped), rendering the form shows the grouped subheadings (e.g. "Description" then "Other") and the fields under them in definition order. (Extendobject-form.test.tsx; reuse the existing MSW field-def handler / fixtures — add agroupto 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 (reusefields.other,form.flexibleHeading); en/sv parity unaffected; no codename; no new dependency.
Acceptance criteria
groupDefinitionsexists (shared, unit-tested) and is used by both object-detail and object-form.- The object form renders flexible-field inputs grouped by
def.group(definition order, "Other" last) withlabel-captionsubheadings; the fieldset keeps ansr-onlylegend. - The detail view's grouping is unchanged in output (now via the shared helper).
- Form behavior is otherwise unchanged (inputs, validation, errors, submission, pruning).
typecheck/lint/test/build/check:colorsgreen;check:sizereported; 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).