diff --git a/docs/superpowers/plans/2026-06-07-object-detail-readability.md b/docs/superpowers/plans/2026-06-07-object-detail-readability.md new file mode 100644 index 0000000..a14fabf --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-object-detail-readability.md @@ -0,0 +1,185 @@ +# Object Detail Readability — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make `web/src/objects/object-detail.tsx` readable: resolve term/authority ids → labels and `localized_text` → the active-language string, group flexible fields by `def.group` in definition order, and polish (date formatting, empty-core "—", an Edit/Delete actions toolbar). + +**Architecture:** A new per-field `FlexibleFieldValue` component switches on `def.data_type` and resolves via the existing `useTerms`/`useAuthorities` + `labelText` (one hook call per component instance → rules-of-hooks safe; react-query dedups repeated vocabularies). `object-detail.tsx` iterates `definitions` (stable order) for grouping and renders core fields with placeholders. Frontend-only, no backend change. + +**Tech Stack:** React 19 + TS + pnpm, react-i18next, TanStack Query, Vitest+RTL+MSW, Storybook 10. + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; tests query portals via `within(document.body)` (n/a here). `check:size` budget 180 KB gz (this is frontend-only, ~no bundle change). + +**Spec:** `docs/superpowers/specs/2026-06-07-object-detail-readability-design.md` + +**Facts:** flexible values in `object.fields` are: term/authority = UUID **string**, localized_text = `{lang: text}` **object**, others = primitive. `FieldDefinitionView` has `data_type`/`vocabulary_id`/`authority_kind`/`group`/`labels`/`key`. Helpers: `labelText(labels, lang)` (`web/src/lib/labels.ts`); hooks `useTerms(vocabularyId)` / `useAuthorities(kind)` (`web/src/api/queries.ts`). Core labels exist under `fieldsLabels.*`. `buttonVariants` is exported from `@/components/ui/button`. Test fixtures (`web/src/test/fixtures.ts`) have `fieldDefinitions` (covering all types), `materialTerms`, `personAuthorities`. + +--- + +## Task 1: `FlexibleFieldValue` component + story + unit test +**Files:** create `web/src/objects/flexible-field-value.tsx`, `flexible-field-value.stories.tsx`, `flexible-field-value.test.tsx`. Modify `web/src/i18n/{en,sv}.json`. + +- [ ] **Step 1: i18n keys.** Add to **both** locales: a `common` block `{ "yes": "Yes"/"Ja", "no": "No"/"Nej" }` and `objects.unknownRef` ("(unknown)" / "(okänd)"). + +- [ ] **Step 2: Write the component** `web/src/objects/flexible-field-value.tsx`: +```tsx +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useTerms, useAuthorities } from "../api/queries"; +import { labelText } from "../lib/labels"; + +type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; + +/** Renders one flexible field value as human-readable text, resolving term/authority ids + * to labels and localized_text to the active language. */ +export function FlexibleFieldValue({ + def, + value, + lang, +}: { + def: FieldDefinitionView; + value: unknown; + lang: string; +}) { + switch (def.data_type) { + case "term": + return ; + case "authority": + return ; + case "localized_text": + return <>{pickLocalized(value, lang)}; + case "date": + return <>{formatDate(value, lang)}; + case "boolean": + return ; + default: + return <>{value == null ? "—" : String(value)}; + } +} + +function TermValue({ vocabularyId, value, lang }: { vocabularyId: string | null; value: unknown; lang: string }) { + const { t } = useTranslation(); + const { data: terms, isLoading } = useTerms(vocabularyId ?? undefined); + + if (typeof value !== "string") return <>—; + const term = terms?.find((x) => x.id === value); + if (term) return <>{labelText(term.labels, lang)}; + if (isLoading) return ; + return {value} {t("objects.unknownRef")}; +} + +function AuthorityValue({ kind, value, lang }: { kind: string | null; value: unknown; lang: string }) { + const { t } = useTranslation(); + const { data: authorities, isLoading } = useAuthorities(kind ?? undefined); + + if (typeof value !== "string") return <>—; + const authority = authorities?.find((x) => x.id === value); + if (authority) return <>{labelText(authority.labels, lang)}; + if (isLoading) return ; + return {value} {t("objects.unknownRef")}; +} + +function BooleanValue({ value }: { value: unknown }) { + const { t } = useTranslation(); + return <>{value ? t("common.yes") : t("common.no")}; +} + +function pickLocalized(value: unknown, lang: string): string { + if (value && typeof value === "object" && !Array.isArray(value)) { + const map = value as Record; + return map[lang] ?? map.en ?? Object.values(map)[0] ?? "—"; + } + return value == null ? "—" : String(value); +} + +function formatDate(value: unknown, lang: string): string { + if (typeof value !== "string") return value == null ? "—" : String(value); + // Parse as local midnight so a date-only value isn't shifted a day by tz when formatted. + const date = new Date(`${value}T00:00:00`); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(lang, { dateStyle: "medium" }).format(date); +} +``` +Confirm `useTerms`/`useAuthorities` accept `undefined` and short-circuit (they have `enabled: !!arg`) — yes; passing `undefined` disables the query and `data` is undefined → falls through to the `—`/`…` paths. Confirm `TermView`/`AuthorityView` have `id` + `labels` (they do). + +- [ ] **Step 3: Unit test** `flexible-field-value.test.tsx` (RTL + MSW + a QueryClient wrapper; mirror existing component tests). Use fixtures `materialTerms`/`personAuthorities`/`fieldDefinitions`. Cover: a `term` def + a value that is a known term id → renders the label (e.g. "Bronze"); `authority` → label; an unknown term id → renders ` (unknown)`; `localized_text` `{sv:"…",en:"…"}` with lang sv → the sv string; `date` "2024-01-05" → a formatted date (assert it's not the raw ISO); `boolean` true → "Yes". MSW must serve `/api/admin/vocabularies/{id}/terms` and `/api/admin/authorities?kind=` (reuse `web/src/test/handlers.ts` patterns). + +- [ ] **Step 4: Run the unit test.** `cd web && pnpm test -- flexible-field-value`. Iterate to green (genuine assertions — label not id; not vacuous). + +- [ ] **Step 5: Storybook** `flexible-field-value.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx`): stories `Term`, `Authority`, `LocalizedText`, `Date`, `Boolean`, `UnknownRef`. The term/authority stories need the hooks' data — rely on the preview's MSW (`src/test/handlers.ts`) serving terms/authorities, passing a `def` with the matching `vocabulary_id`/`authority_kind` and a `value` that's a known id. Assert the resolved label text shows. + +- [ ] **Step 6:** `pnpm typecheck && pnpm lint`. **Commit** `feat(web): FlexibleFieldValue — resolve term/authority/localized field values (#45)`. + +--- + +## Task 2: Refactor `object-detail.tsx` (grouping, placeholders, toolbar) + tests +**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx` (create if absent). + +- [ ] **Step 1: Failing/updated detail test.** In `object-detail.test.tsx` (RTL + MSW + MemoryRouter at `/objects/:id`, providers from the test harness), seed an object whose `fields` include a term (material → a known term id), a localized_text, and a date; assert the detail shows the **term label** (not the UUID), the **localized string** (not JSON), fields appear under **group subheadings** in definition order, an empty core field shows "—", and an **Edit** link/button points to `/objects/:id/edit`. Run → fails against the current JSON.stringify rendering. + +- [ ] **Step 2: Refactor `object-detail.tsx`.** + - Update the local `Field` to take `value: React.ReactNode` and render "—" when the value is nullish/empty (instead of returning `null`) — so core fields are always shown: +```tsx +function Field({ label, value }: { label: string; value: React.ReactNode }) { + const empty = value === null || value === undefined || value === ""; + return ( +
+
{label}
+
{empty ? "—" : value}
+
+ ); +} +``` + - **Header → actions toolbar:** keep `object_name` (`

`) + `VisibilityBadge` on the left; move Edit + Delete into a right-aligned toolbar. Make Edit a button-styled `Link`: +```tsx +import { buttonVariants } from "@/components/ui/button"; +// ... +
+

{object.object_name}

+ +
+ + {t("actions.edit")} + + +
+
+``` + - **Core fields:** render the known core fields via `Field` (object number, count, brief description, current location, current owner, recorder, recording date). Format `recording_date` with the `formatDate` helper (import it from `flexible-field-value.tsx`, or duplicate the tiny helper — prefer exporting `formatDate` from the value module to keep one copy). They now always show (with "—"). + - **Flexible fields grouped + ordered:** replace the `Object.entries(object.fields)` block with iteration over `definitions`: +```tsx +const OTHER = t("fields.other"); // existing key used by the field list; or add objects.otherGroup +const present = (definitions ?? []).filter((d) => object.fields[d.key] != null); +const groups: { group: string; defs: FieldDefinitionView[] }[] = []; +for (const d of present) { + const g = d.group?.trim() ? d.group : OTHER; + const bucket = groups.find((x) => x.group === g) ?? (groups.push({ group: g, defs: [] }), groups[groups.length - 1]); + bucket.defs.push(d); +} +// render: for each group → a subheading + each def as } /> +``` + Keep the existing `labelFor(key)` helper (active-locale field label). Render a group subheading (reuse the uppercase caption style). Drop the old `JSON.stringify`/`typeof` logic entirely. Keep `PublishControl` below. + - (Confirm `fields.other` exists in i18n — the field-list screen uses it; if not, add `objects.otherGroup` to both locales.) + +- [ ] **Step 3: Run tests.** `cd web && pnpm test -- object-detail flexible-field-value && pnpm typecheck && pnpm lint`. Green; the detail test now passes (labels, grouping, placeholder, Edit link). + +- [ ] **Step 4: Commit** `feat(web): readable, grouped object detail (labels, placeholders, actions toolbar) (#45)`. + +--- + +## Task 3: Final verification +- [ ] `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` — all green; bundle within 180 KB gz (frontend-only, ~no change). +- [ ] `pnpm test -- i18n` (en/sv parity for `common.yes`/`common.no`/`objects.unknownRef` [+ `objects.otherGroup` if added]); `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`; `git status --short` clean. +- [ ] **Manual smoke (recommended):** with the stack up + a seeded object that has a term/authority/localized field, open `/objects/:id` and confirm labels (not UUIDs/JSON), grouped sections, "—" for empty core fields, and the Edit/Delete toolbar. + +--- + +## Self-Review (completed) +**Spec coverage:** value resolution per type + fallbacks → Task 1 (`FlexibleFieldValue` + sub-components); grouping/order + core placeholders + toolbar + date format → Task 2; story → Task 1 Step 5; tests → Task 1/2; i18n keys + parity + verification → Task 1 Step 1 / Task 3. ✓ Out of scope (export #39, form grouping, backend resolution) not included. ✓ +**Placeholder scan:** concrete component + helper code given; the only "confirm X exists" notes (`fields.other`, hook `undefined` handling) are quick verifications against real code, not deferred work. +**Type consistency:** `FlexibleFieldValue({def, value, lang})` defined in Task 1, consumed in Task 2; `formatDate` exported from the value module and reused for `recording_date`; `labelText`/`useTerms`/`useAuthorities`/`buttonVariants` are existing exports. + +## Notes +- No backend, no migration, no new dependency → no lockfile churn; bundle effectively unchanged. +- react-query dedups repeated `["terms", vocab]`/`["authorities", kind]` so multiple same-vocabulary term fields cause one fetch; often already warm from the table/combobox.