# 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)
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 `<fieldset>` 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 && (
  <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>` 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).
