Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
commonblock{ "yes": "Yes"/"Ja", "no": "No"/"Nej" }andobjects.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 fixturesmaterialTerms/personAuthorities/fieldDefinitions. Cover: atermdef + 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);booleantrue → "Yes". MSW must serve/api/admin/vocabularies/{id}/termsand/api/admin/authorities?kind=(reuseweb/src/test/handlers.tspatterns). -
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(mirrorweb/src/objects/visibility-badge.stories.tsx): storiesTerm,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 adefwith the matchingvocabulary_id/authority_kindand avaluethat's a known id. Assert the resolved label text shows. -
Step 6:
pnpm typecheck && pnpm lint. Commitfeat(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 whosefieldsinclude 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
Fieldto takevalue: React.ReactNodeand render "—" when the value is nullish/empty (instead of returningnull) — so core fields are always shown:
- Update the local
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>) +VisibilityBadgeon the left; move Edit + Delete into a right-aligned toolbar. Make Edit a button-styledLink:
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). Formatrecording_datewith theformatDatehelper (import it fromflexible-field-value.tsx, or duplicate the tiny helper — prefer exportingformatDatefrom 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 overdefinitions:
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.otherexists in i18n — the field-list screen uses it; if not, addobjects.otherGroupto 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 forcommon.yes/common.no/objects.unknownRef[+objects.otherGroupif added]);git grep -in 'biggus\|dickus' -- web/src || echo CLEAN;git status --shortclean.- Manual smoke (recommended): with the stack up + a seeded object that has a term/authority/localized field, open
/objects/:idand 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.