Files
biggus-dickus/docs/superpowers/specs/2026-06-08-form-field-grouping-design.md
T

137 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AZ (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 (AZ) grouping with this (different purpose — a management list).