docs(specs): reference-data scannability + parity (sort/filter/uri/counts) (#50)
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# 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)
|
||||
1. Sort every list by label/key with a locale-aware `Intl.Collator`; add a client-side filter `Input`.
|
||||
2. Show `external_uri` (linkified, muted) in term/authority read rows; field-group count `Badge`s.
|
||||
3. 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 a `Map<string, Intl.Collator>`).
|
||||
- `export function byLabel(lang: string)` → `(a: { labels: LabelView[] }, b) => number` comparing `labelText(a.labels, lang)` vs `labelText(b.labels, lang)` via the collator.
|
||||
- `export function byKey(lang: string)` → `(a: { key: string }, b) => number` comparing `a.key` vs `b.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>` (placeholder `common.filter`) above the list; filter vocabularies by `key` (case-insensitive `includes`) then **sort by `byKey(lang)`**; if filtered-empty but data exists, show muted `common.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 by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The add-term `external_uri` `<Input>` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`.
|
||||
- `term-row.tsx`: read mode renders `<ExternalUriLink uri={term.external_uri} />` under the label when present; the edit-mode `external_uri` `<Input>` gets `type="url"` + the placeholder.
|
||||
|
||||
**Authorities**
|
||||
- `authorities-page.tsx`: a filter `<Input>` above the list; filter by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The **create form gains an `external_uri` `<Input>`** (`type="url"`, placeholder) mirroring the edit row, stored in a `useState`, sent via `useCreateAuthority` (replace the hardcoded `external_uri: null`).
|
||||
- `authority-row.tsx`: read mode renders `<ExternalUriLink uri={authority.external_uri} />` under the label when present; edit `external_uri` `<Input>` gets `type="url"` + placeholder.
|
||||
|
||||
**Fields**
|
||||
- `field-list.tsx`: a filter `<Input>` above the list; filter by `labelText`/`key`; **sort groups alphabetically with "Other"/ungrouped last, and sort fields within each group by `byLabel(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.
|
||||
- `ExternalUriLink` only renders for truthy `external_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 → send `null`/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`/`byKey` order 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 an `external_uri` field and a created authority posts the entered URI; a read row shows the `external_uri` link.
|
||||
- **Vocabulary terms / term-row:** terms sorted by label; a term read row shows its `external_uri` link.
|
||||
- **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
|
||||
1. Every reference list is sorted by label (terms/authorities/fields) or key (vocabularies) via a
|
||||
locale-aware `Intl.Collator`, and has a client-side filter `Input` (with a `common.noMatches`
|
||||
empty state).
|
||||
2. `external_uri` is shown (linkified, muted, truncated) in term + authority read rows when present.
|
||||
3. Field-group headers show a count `Badge`.
|
||||
4. Parity/validation gaps closed: authority **create** has an `external_uri` field and sends it; vocab
|
||||
**rename** has an empty-guard; all `external_uri` inputs use `type="url"` + a placeholder.
|
||||
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; 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"`); linkifying `external_uri` elsewhere (e.g. object detail).
|
||||
Reference in New Issue
Block a user