Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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)
- 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. - 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); findterms?.find(t => t.id === value); showlabelText(term.labels, lang).authority:const { data: authorities } = useAuthorities(def.authority_kind); same.localized_text:valueisRecord<string, string>; showvalue[lang] ?? value.en ?? Object.values(value)[0] ?? "—".date:Intl.DateTimeFormat(i18n.language, { dateStyle: "medium" }).format(new Date(value))— a bare date, notimeZone(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 → "—".
defmissing for a stored key (field definition deleted) → fall back toString(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 realButtonlinking to/objects/:id/edit— useLink+buttonVariantslike the table's New button, since the Base UIButtonhas noasChild) and Delete (the existingDeleteObjectDialog).PublishControlstays 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
Fieldcomponent to show "—" instead of returningnull. (recording_dateformatted as a locale date.) - Flexible fields grouped + ordered: iterate
definitions(stable API order); for each definition whose key exists inobject.fieldswith a non-null value, render<FlexibleFieldValue>under a subheading for itsdef.group(null group → an "Other" / ungrouped section rendered last). No moreObject.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_textwith 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
termfield renders the term's active-locale label (not the UUID);authoritylikewise;localized_textrenders the active-language string (not JSON);dateis 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
FlexibleFieldValuecovering eachdata_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
- Term and authority flexible fields display their active-locale label (not a UUID);
localized_textshows the active-language string (not JSON); date/boolean/integer/text render readably. - A deleted/unknown term/authority ref degrades to a muted id + "(unknown)", never raw JSON or a crash.
- Flexible fields are grouped by
def.groupin stable definition order; empty core fields show "—". - The detail header is an actions toolbar (Edit as a Button + Delete);
recording_dateis locale-formatted. - A
FlexibleFieldValueStorybook story exists; en/sv parity for new keys (common.yes,common.no,objects.unknownRef);pnpm typecheck/lint/test/buildgreen;check:sizewithin budget; no codename; noany/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).