Files
biggus-dickus/docs/superpowers/specs/2026-06-07-object-detail-readability-design.md
T
2026-06-07 10:54:41 +02:00

7.2 KiB

Object Detail Readability — Design

Date: 2026-06-07 Status: Approved (brainstorming) — ready for implementation planning. Issue: #45.

Context

web/src/objects/object-detail.tsx renders flexible field values with typeof value === "object" ? JSON.stringify(value) : String(value). Since term/authority values are stored as UUID strings and localized_text as a {lang: text} object, the result is: term/authority fields show a raw UUID, and localized_text shows {"sv":"…"} JSON. Flexible fields also render in Object.entries (insertion) order, ungrouped, and empty core fields vanish (layout shifts per object). This is the worst readability defect in the screen curators read most — now more visible since the detail moved into the new pane/drawer (#44).

The resolution machinery already exists: useTerms(vocabulary_id) / useAuthorities(kind) and labelText(labels, lang) (web/src/lib/labels.ts) — the object form/combobox use them. FieldDefinitionView carries data_type, vocabulary_id, authority_kind, group, and labels.

Decisions (from brainstorming)

  1. Client-side resolution, reusing useTerms/useAuthorities + labelText (no backend change; react-query dedups repeated vocabularies/kinds). Backend-resolved labels were rejected for now (more work; the client machinery exists). PDF export (#39) may revisit server-side resolution later.
  2. Scope = detail readability bundle: value resolution + grouping/order + small polish (date formatting, empty-core placeholders, an actions toolbar). Excluded: export/PDF (#39), the object form's grouping (left to the form work / #46), backend resolution.

Components

FlexibleFieldValue (new, in object-detail.tsx or a sibling file)

Rendered once per flexible field — so the term/authority hooks satisfy the rules of hooks (one hook call per component instance, not a loop). Props: { def: FieldDefinitionView, value: unknown, lang: string }. Switches on def.data_type:

  • term: const { data: terms } = useTerms(def.vocabulary_id); find terms?.find(t => t.id === value); show labelText(term.labels, lang).
  • authority: const { data: authorities } = useAuthorities(def.authority_kind); same.
  • localized_text: value is Record<string, string>; show value[lang] ?? value.en ?? Object.values(value)[0] ?? "—".
  • date: Intl.DateTimeFormat(i18n.language, { dateStyle: "medium" }).format(new Date(value)) — a bare date, no timeZone (would shift a date-only value).
  • boolean: value ? t("common.yes") : t("common.no").
  • integer / text / default: String(value).

Fallbacks:

  • Term/authority value present but not found in the loaded set (deleted ref) → render the raw id in muted text with a t("objects.unknownRef") ("(unknown)") suffix.
  • Terms/authorities still loading → a muted placeholder (e.g. the id faintly, or "…").
  • Empty/absent value → "—".
  • def missing for a stored key (field definition deleted) → fall back to String(value) (or the raw value) muted.

react-query keys: ["terms", vocabularyId] / ["authorities", kind] are shared, so N term fields in one vocabulary cause one fetch (often already cached from the table/combobox).

Detail layout changes (object-detail.tsx)

  • Header → actions toolbar: left = object_name (<h2>) + VisibilityBadge; right = a toolbar with Edit (a real Button linking to /objects/:id/edit — use Link + buttonVariants like the table's New button, since the Base UI Button has no asChild) and Delete (the existing DeleteObjectDialog). PublishControl stays below the fields.
  • Core fields render always with a muted "—" when empty (object number, count, brief description, current location, current owner, recorder, recording date) — stable layout. Update the local Field component to show "—" instead of returning null. (recording_date formatted as a locale date.)
  • Flexible fields grouped + ordered: iterate definitions (stable API order); for each definition whose key exists in object.fields with a non-null value, render <FlexibleFieldValue> under a subheading for its def.group (null group → an "Other" / ungrouped section rendered last). No more Object.entries/JSON.stringify.

Data flow

useObject(id) + useFieldDefinitions() (already loaded) → group definitions by group (stable order) → for each present flexible field, FlexibleFieldValue resolves the display via the type-appropriate hook (useTerms/useAuthorities, react-query-cached) or inline (localized/date/boolean/text). Core fields render directly with placeholders.

Error handling / edges

  • Deleted term/authority (id not resolvable) → muted id + "(unknown)"; never a crash or raw JSON.
  • localized_text with no active-language entry → English → first → "—".
  • Invalid date string → fall back to the raw string (guard Number.isNaN(date.getTime())).
  • Object with no flexible values → no flexible section (only core fields).
  • A stored key with no matching definition → render the raw value muted (don't drop silently into JSON.stringify).

Testing

Vitest + RTL + MSW (extend web/src/test/fixtures.ts — it already has fieldDefinitions with term/authority/localized_text/date/boolean defs, materialTerms, personAuthorities):

  • A term field renders the term's active-locale label (not the UUID); authority likewise; localized_text renders the active-language string (not JSON); date is locale-formatted; boolean → yes/no.
  • A term value whose id isn't in the vocabulary → muted id + "(unknown)" fallback.
  • Flexible fields render grouped with group subheadings, in definition order.
  • An empty core field shows "—".
  • The Edit action links to /objects/:id/edit; Delete dialog present.
  • Component test for FlexibleFieldValue covering each data_type + the unknown-ref fallback.

Storybook (per the standing preference): FlexibleFieldValue.stories.tsx — Term / Authority / LocalizedText / Date / Boolean / UnknownRef variants (MSW from the preview, or pass pre-seeded query data). Mirror the established story format.

Acceptance criteria

  1. Term and authority flexible fields display their active-locale label (not a UUID); localized_text shows the active-language string (not JSON); date/boolean/integer/text render readably.
  2. A deleted/unknown term/authority ref degrades to a muted id + "(unknown)", never raw JSON or a crash.
  3. Flexible fields are grouped by def.group in stable definition order; empty core fields show "—".
  4. The detail header is an actions toolbar (Edit as a Button + Delete); recording_date is locale-formatted.
  5. A FlexibleFieldValue Storybook story exists; en/sv parity for new keys (common.yes, common.no, objects.unknownRef); pnpm typecheck/lint/test/build green; check:size within budget; no codename; no any/eslint-disable.

Out of scope → follow-ups

  • Export / PDF of a record (#39).
  • The object form's flexible-field grouping/order (the form still renders flat) — left to the form work (#46) or a small follow-up.
  • Backend-resolved labels (would help PDF export #39 and avoid client N+1 at large scale).