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

7.4 KiB
Raw Blame History

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)

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> 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 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 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).