8.0 KiB
Reference-Data Scannability + Parity — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #50 (scoped: scannability + parity; layout/edit-modality unification + API-backed counts are follow-ups).
Context
The three reference-data screens (vocabularies, authorities, fields) render lists in API
creation order with no client sort and no filter — finding "Bronze" in a 200-term vocabulary
is effectively impossible. Read-mode rows show only the label; external_uri (the point of
authority control — distinguishing two "Mercury"s) is visible only in edit mode (term-row.tsx,
authority-row.tsx). There are no counts anywhere. And there are small parity/validation gaps:
authority create has no external_uri field (hardcoded null) though edit does; vocab rename
lacks the empty-guard the create form has; URI inputs have no type="url"/placeholder.
This milestone fixes the daily-pain "can't find / can't disambiguate" problems + the parity gaps, without changing layout/edit modality (a separate redesign) and without backend changes.
Facts: labelText(labels, lang) exists (lib/labels.ts). No Intl.Collator anywhere. List
shapes: term/authority = { id, labels, external_uri?: string|null } (authority also kind);
vocabulary = { id, key } (no labels); field-definition = { key, labels, data_type, group?, required, … }.
No count field on any view (so per-vocab term counts + per-kind authority counts need backend →
deferred; field-group counts are client-side and in scope). Badge/Input exist. Mutations:
useCreateAuthority accepts external_uri but the page passes null; useRenameVocabulary has no
empty-guard; useAddTerm/useUpdateTerm/useUpdateAuthority already accept external_uri.
Decisions (from brainstorming)
- Sort every list by label/key with a locale-aware
Intl.Collator; add a client-side filterInput. - Show
external_uri(linkified, muted) in term/authority read rows; field-group countBadges. - Close the parity/validation gaps (authority-create URI, rename empty-guard,
type="url"+ placeholder).
Components
web/src/lib/sort.ts (new)
- A memoized collator per lang:
Intl.Collator(lang, { sensitivity: "base", numeric: true })(cache in aMap<string, Intl.Collator>). export function byLabel(lang: string)→(a: { labels: LabelView[] }, b) => numbercomparinglabelText(a.labels, lang)vslabelText(b.labels, lang)via the collator.export function byKey(lang: string)→(a: { key: string }, b) => numbercomparinga.keyvsb.key.- (Comparators take the minimal structural type so they work for terms/authorities/fields/vocabs.)
web/src/components/external-uri-link.tsx (new)
A tiny shared read-mode link: function ExternalUriLink({ uri }: { uri: string }) →
<a href={uri} target="_blank" rel="noopener noreferrer" className="block truncate text-xs text-muted-foreground hover:text-foreground">{uri}</a>. Used in term-row + authority-row read mode (render only when external_uri is truthy).
i18n (en + sv parity)
common.filter= "Filter…" / "Filtrera…"common.noMatches= "No matches" / "Inga träffar"labels.uriPlaceholder= "https://…" (same in both)
Per-screen changes
Vocabularies
vocabulary-list.tsx: a filter<Input>(placeholdercommon.filter) above the list; filter vocabularies bykey(case-insensitiveincludes) then sort bybyKey(lang); if filtered-empty but data exists, show mutedcommon.noMatches. Add the rename empty-guard:if (!draftKey.trim()) return;before the rename mutate.vocabulary-terms.tsx: a filter<Input>above the terms list; filter terms bylabelTextthen sort bybyLabel(lang); filtered-empty →common.noMatches. The add-termexternal_uri<Input>getstype="url"+placeholder={t("labels.uriPlaceholder")}.term-row.tsx: read mode renders<ExternalUriLink uri={term.external_uri} />under the label when present; the edit-modeexternal_uri<Input>getstype="url"+ the placeholder.
Authorities
authorities-page.tsx: a filter<Input>above the list; filter bylabelTextthen sort bybyLabel(lang); filtered-empty →common.noMatches. The create form gains anexternal_uri<Input>(type="url", placeholder) mirroring the edit row, stored in auseState, sent viauseCreateAuthority(replace the hardcodedexternal_uri: null).authority-row.tsx: read mode renders<ExternalUriLink uri={authority.external_uri} />under the label when present; editexternal_uri<Input>getstype="url"+ placeholder.
Fields
field-list.tsx: a filter<Input>above the list; filter bylabelText/key; sort groups alphabetically with "Other"/ungrouped last, and sort fields within each group bybyLabel(lang); each group header shows a<Badge variant="secondary">{count}</Badge>(count of fields in that group, after filtering); filtered-empty →common.noMatches.
Data flow
Loaded list → client filter (by label/key substring) → collator sort → render. external_uri read
from the existing view field. Field-group counts from the grouped array length. No new queries.
Error handling / edges
- Filter is case-insensitive substring on the localized label (or key). Empty filter = show all.
- Collator memoized per lang; lang from
i18n.language(sv/en) as elsewhere. ExternalUriLinkonly renders for truthyexternal_uri;rel="noopener noreferrer"+target="_blank".type="url"gives the browser's basic URL hint (not strict validation — full validation is a follow-up); it must not block submitting an empty optional URI (the field stays optional → sendnull/omit when blank, exactly as today).- Sorting must not mutate the query cache array — sort a copy (
[...list].sort(...)). - Field group ordering: a stable rule (named groups A–Z by collator, then the ungrouped "Other" bucket last).
Testing
sort.test.ts(unit):byLabel/byKeyorder case-insensitively + locale-aware (e.g. "ä" sorts sensibly in sv; "bronze" before "Iron").- Vocabularies (
vocabularies.test.tsx): vocabularies render sorted by key; typing in the filter narrows the list; rename with an empty key does not fire the mutation. - Authorities (
authorities.test.tsx): list sorted by label; filter narrows; the create form has anexternal_urifield and a created authority posts the entered URI; a read row shows theexternal_urilink. - Vocabulary terms / term-row: terms sorted by label; a term read row shows its
external_urilink. - Fields (
fields.test.tsx): fields sorted within group; a group header shows a count badge; filter narrows. Keep the existing create/reveal-picker assertions green. - Gate:
typecheck/lint/test/build/check:size/check:colors; en/sv parity (3 new keys); no codename; no new dependency.
Acceptance criteria
- Every reference list is sorted by label (terms/authorities/fields) or key (vocabularies) via a
locale-aware
Intl.Collator, and has a client-side filterInput(with acommon.noMatchesempty state). external_uriis shown (linkified, muted, truncated) in term + authority read rows when present.- Field-group headers show a count
Badge. - Parity/validation gaps closed: authority create has an
external_urifield and sends it; vocab rename has an empty-guard; allexternal_uriinputs usetype="url"+ a placeholder. typecheck/lint/test/build/check:colorsgreen;check:sizereported; en/sv parity; no codename; no new dependency; no layout/edit-modality change; no backend change.
Out of scope → follow-ups
- The layout + edit-modality unification (authorities → two-pane pane-edit; vocab-rename/term/ authority → pane-edit; one create location) — a separate redesign.
- API-backed counts: per-vocabulary term counts and per-kind authority-tab counts (need view/count fields or endpoints).
- Strict URL validation (beyond
type="url"); linkifyingexternal_urielsewhere (e.g. object detail).