docs(specs): object detail readability — resolve labels, group fields (#45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>`; 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` (`<h2>`) + `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
|
||||||
|
`<FlexibleFieldValue>` 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).
|
||||||
Reference in New Issue
Block a user