From 6ec31b6c51f8caa8fb9d2dfef07caa6bc965b7b2 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 10:54:41 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs):=20object=20detail=20readability=20?= =?UTF-8?q?=E2=80=94=20resolve=20labels,=20group=20fields=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-07-object-detail-readability-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-object-detail-readability-design.md diff --git a/docs/superpowers/specs/2026-06-07-object-detail-readability-design.md b/docs/superpowers/specs/2026-06-07-object-detail-readability-design.md new file mode 100644 index 0000000..74a3d81 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-object-detail-readability-design.md @@ -0,0 +1,119 @@ +# 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`; 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` (`

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