Files
biggus-dickus/docs/superpowers/plans/2026-06-07-object-detail-readability.md
2026-06-07 11:24:58 +02:00

13 KiB

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:

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 <TermValue vocabularyId={def.vocabulary_id} value={value} lang={lang} />;
    case "authority":
      return <AuthorityValue kind={def.authority_kind} value={value} lang={lang} />;
    case "localized_text":
      return <>{pickLocalized(value, lang)}</>;
    case "date":
      return <>{formatDate(value, lang)}</>;
    case "boolean":
      return <BooleanValue value={value} />;
    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 <span className="text-neutral-400"></span>;
  return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}

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 <span className="text-neutral-400"></span>;
  return <span className="text-neutral-400">{value} {t("objects.unknownRef")}</span>;
}

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<string, string>;
    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 <id> (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:
function Field({ label, value }: { label: string; value: React.ReactNode }) {
  const empty = value === null || value === undefined || value === "";
  return (
    <div className="border-b py-2">
      <div className="text-xs uppercase tracking-wide text-neutral-400">{label}</div>
      <div className="text-sm text-neutral-900">{empty ? "—" : value}</div>
    </div>
  );
}
  • Header → actions toolbar: keep object_name (<h2>) + VisibilityBadge on the left; move Edit + Delete into a right-aligned toolbar. Make Edit a button-styled Link:
import { buttonVariants } from "@/components/ui/button";
// ...
<div className="mb-4 flex items-center gap-3">
  <h2 className="text-xl font-semibold">{object.object_name}</h2>
  <VisibilityBadge visibility={object.visibility} />
  <div className="ml-auto flex items-center gap-2">
    <Link to={`/objects/${object.id}/edit`} className={buttonVariants({ size: "sm" })}>
      {t("actions.edit")}
    </Link>
    <DeleteObjectDialog id={object.id} />
  </div>
</div>
  • 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:
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 <Field label={labelFor(d.key)} value={<FlexibleFieldValue def={d} value={object.fields[d.key]} lang={lang} />} />

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.