From 0678cefd13424d4a3c420b9a7b0fce0ed0c589b3 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 07:37:40 +0200 Subject: [PATCH 1/6] docs(specs): reference-data scannability + parity (sort/filter/uri/counts) (#50) --- .../2026-06-08-refdata-scannability-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-refdata-scannability-design.md diff --git a/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md b/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md new file mode 100644 index 0000000..4f87185 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-refdata-scannability-design.md @@ -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`). +- `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 })` → +`{uri}`. 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 `` (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 `` above the terms list; filter terms by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The add-term `external_uri` `` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`. +- `term-row.tsx`: read mode renders `` under the label when present; the edit-mode `external_uri` `` gets `type="url"` + the placeholder. + +**Authorities** +- `authorities-page.tsx`: a filter `` above the list; filter by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The **create form gains an `external_uri` ``** (`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 `` under the label when present; edit `external_uri` `` gets `type="url"` + placeholder. + +**Fields** +- `field-list.tsx`: a filter `` 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 `{count}` (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). From a4fb05a1752315de203e891b3a5a121a786369df Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 08:18:58 +0200 Subject: [PATCH 2/6] =?UTF-8?q?docs(plans):=20reference-data=20scannabilit?= =?UTF-8?q?y=20+=20parity=20=E2=80=94=204-task=20plan=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-refdata-scannability.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-refdata-scannability.md diff --git a/docs/superpowers/plans/2026-06-08-refdata-scannability.md b/docs/superpowers/plans/2026-06-08-refdata-scannability.md new file mode 100644 index 0000000..dddf439 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-refdata-scannability.md @@ -0,0 +1,295 @@ +# Reference-Data Scannability + Parity — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the three reference-data lists scannable (locale-aware sort + filter), show `external_uri` in read rows, add field-group count badges, and close the small parity/validation gaps — no layout/edit-modality change, no backend change. + +**Architecture:** A shared `lib/sort.ts` (memoized `Intl.Collator` comparators) + a tiny `ExternalUriLink` are consumed by the vocab/authorities/fields list components. Each list gets a client-side filter `useState` + ``; rows are filtered then sorted before render. + +**Tech Stack:** React 19 + TS + pnpm, react-i18next, Vitest + RTL + MSW. Test runner: `pnpm test` (single pass). + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity (3 new keys); app source double-quote+semicolon; token classes only; **don't mutate query-cache arrays — sort a copy** (`[...list].sort(...)`). + +**Spec:** `docs/superpowers/specs/2026-06-08-refdata-scannability-design.md` + +**Key facts (from the code):** +- `labelText(labels, lang)` in `lib/labels.ts`. `lang = i18n.language.startsWith("sv") ? "sv" : "en"`. +- term/authority view = `{ id, labels, external_uri?: string|null }` (authority also `kind`); vocabulary = `{ id, key }`; field-def = `{ key, labels, data_type, group?, required, … }`. +- `term-row.tsx`/`authority-row.tsx`: read mode `{labelText(...)}` + Edit + DeleteConfirmDialog; edit mode has the `external_uri` `` (no `type`/placeholder). +- `vocabulary-list.tsx`: create form (has empty-guard) + list; rename `
` calls `renameVocabulary.mutate({ id, key: draftKey.trim() })` with NO empty-guard. +- `authorities-page.tsx`: tabs + list + create form; create calls `create.mutate({ kind, external_uri: null, labels })` — hardcoded null, no URI field. +- `field-list.tsx`: groups built into a `Map`; sorted with `t("fields.other")` last (no A–Z); group header `
{group}
`; rows show label+key+type+required. +- `common` i18n: `{ yes, no, close, loading }`. `labels`: `{ label, externalUri, otherLanguages }`. + +--- + +# Task 1: Shared `sort.ts` + `ExternalUriLink` + i18n + unit test + +**Files:** `web/src/lib/sort.ts` (new), `web/src/lib/sort.test.ts` (new), `web/src/components/external-uri-link.tsx` (new), `web/src/i18n/en.json`, `web/src/i18n/sv.json`. + +- [ ] **Step 1: i18n (both locales, parity).** + - `common`: add `"filter"` (en "Filter…" / sv "Filtrera…") and `"noMatches"` (en "No matches" / sv "Inga träffar"). + - `labels`: add `"uriPlaceholder": "https://…"` (same string in both). + +- [ ] **Step 2: `web/src/lib/sort.ts`:** +```ts +import type { components } from "../api/schema"; + +import { labelText } from "./labels"; + +type LabelView = components["schemas"]["LabelView"]; + +const collators = new Map(); + +function collatorFor(lang: string): Intl.Collator { + let c = collators.get(lang); + if (!c) { + c = new Intl.Collator(lang, { sensitivity: "base", numeric: true }); + collators.set(lang, c); + } + return c; +} + +export function compareStrings(lang: string, a: string, b: string): number { + return collatorFor(lang).compare(a, b); +} + +export function byLabel(lang: string) { + return (a: { labels: LabelView[] }, b: { labels: LabelView[] }) => + compareStrings(lang, labelText(a.labels, lang), labelText(b.labels, lang)); +} + +export function byKey(lang: string) { + return (a: { key: string }, b: { key: string }) => compareStrings(lang, a.key, b.key); +} +``` + +- [ ] **Step 3: `web/src/lib/sort.test.ts`** (write failing first, then it passes with Step 2): +```ts +import { expect, test } from "vitest"; +import { byKey, byLabel, compareStrings } from "./sort"; + +const L = (label: string) => ({ labels: [{ lang: "en", label }] }); + +test("byLabel sorts case-insensitively and locale-aware", () => { + const sorted = [L("Iron"), L("bronze"), L("Amber")].sort(byLabel("en")).map((x) => x.labels[0].label); + expect(sorted).toEqual(["Amber", "bronze", "Iron"]); +}); + +test("byKey sorts keys with numeric awareness", () => { + const sorted = [{ key: "item10" }, { key: "item2" }, { key: "item1" }].sort(byKey("en")).map((x) => x.key); + expect(sorted).toEqual(["item1", "item2", "item10"]); +}); + +test("compareStrings is case-insensitive", () => { + expect(compareStrings("en", "bronze", "BRONZE")).toBe(0); +}); +``` +Run: `cd web && pnpm vitest run src/lib/sort.test.ts` → PASS (3 tests). + +- [ ] **Step 4: `web/src/components/external-uri-link.tsx`** (app-source style): +```tsx +export function ExternalUriLink({ uri }: { uri: string }) { + return ( + + {uri} + + ); +} +``` + +- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/lib/sort.test.ts && pnpm typecheck && pnpm lint`. PASS, clean. + +- [ ] **Step 6: Commit** +```bash +git add web/src/lib/sort.ts web/src/lib/sort.test.ts web/src/components/external-uri-link.tsx web/src/i18n/en.json web/src/i18n/sv.json +git commit -m "feat(web): collator sort helpers + ExternalUriLink + filter/uri i18n (#50)" +``` + +--- + +# Task 2: Vocabularies — filter + sort + URI + rename guard + +**Files:** `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/term-row.tsx`, `web/src/vocab/vocabularies.test.tsx`. + +- [ ] **Step 1: `vocabulary-list.tsx`** — add `i18n` to `useTranslation` (`const { t, i18n } = …`) and `const lang = i18n.language.startsWith("sv") ? "sv" : "en";`; import `byKey` from `../lib/sort` and `Input` is already imported. Add `const [filter, setFilter] = useState("");`. + - Add a filter `` between the create `` and the list block: +```tsx +
+ setFilter(e.target.value)} + /> +
+``` + - In the loaded `
    `, replace `{data?.map((v) => (…` with a filtered+sorted list: +```tsx +{(() => { + const q = filter.trim().toLowerCase(); + const rows = [...(data ?? [])] + .filter((v) => !q || v.key.toLowerCase().includes(q)) + .sort(byKey(lang)); + if (data && data.length > 0 && rows.length === 0) + return
  • {t("common.noMatches")}
  • ; + return rows.map((v) => (/* the EXISTING
  • row markup, unchanged */)); +})()} +``` + Keep the `isError`/empty(`data?.length === 0`) branches as-is. (Prefer a clean inline: compute `rows` before the `return`, but the IIFE keeps it local — implementer may hoist `const rows = …` above the JSX instead, whichever is cleaner and lint-clean.) + - **Rename empty-guard:** in the rename ``, add a guard before mutate: +```tsx +onSubmit={(e) => { + e.preventDefault(); + if (!draftKey.trim()) return; + renameVocabulary.mutate({ id: v.id, key: draftKey.trim() }, { onSuccess: () => setEditingId(null) }); +}} +``` + +- [ ] **Step 2: `vocabulary-terms.tsx`** — add `const [filter, setFilter] = useState("");`; import `byLabel` from `../lib/sort`. Add a filter `` above the terms list (the component already has `Input` imported; place it above the `{isLoading ? … :
      }` block). In the `
        `, replace `{terms?.map((term) => …}` with filtered (`labelText(term.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (terms exist) → `common.noMatches` `
      • `. Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the add-term `external_uri` ``. + +- [ ] **Step 3: `term-row.tsx`** — import `ExternalUriLink` from `../components/external-uri-link`. In read mode, wrap the label + URI in a flex-1 column so the URI shows under the label: +```tsx +
        +
        {labelText(term.labels, lang)}
        + {term.external_uri && } +
        +``` +(Replace the existing `{labelText(...)}`; keep the Edit + DeleteConfirmDialog.) Add `type="url"` + `placeholder={t("labels.uriPlaceholder")}` to the edit-mode `external_uri` ``. + +- [ ] **Step 4: Tests** (`vocabularies.test.tsx`) — extend (mirror the existing MSW/`tree()` setup; check the term/vocab fixtures for labels to assert order): + - vocabularies render **sorted by key** (seed/confirm the fixture has out-of-order keys; assert DOM order). + - typing in the vocab **filter** narrows the list (type a substring → only matching vocab shows). + - **rename with an empty key** does NOT call the rename endpoint (override the PATCH/PUT handler with a spy; clear the rename input and submit; assert no request). + - a **term read row shows its `external_uri`** as a link (seed a term fixture with `external_uri`; assert `getByRole("link", { name: // })`). (If the term fixture lacks a URI, add one — check `materialTerms` in `web/src/test/fixtures.ts`.) + Keep existing vocab tests green. Don't weaken. + +- [ ] **Step 5: Verify (vitest ONCE):** `cd web && pnpm vitest run src/vocab && pnpm typecheck && pnpm lint`. PASS. + +- [ ] **Step 6: Commit** +```bash +git add web/src/vocab/vocabulary-list.tsx web/src/vocab/vocabulary-terms.tsx web/src/vocab/term-row.tsx web/src/vocab/vocabularies.test.tsx +git commit -m "feat(web): vocab list/terms sort+filter, external_uri in rows, rename guard, url input (#50)" +``` + +--- + +# Task 3: Authorities — filter + sort + create URI + read URI + +**Files:** `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/authorities/authorities.test.tsx`. + +- [ ] **Step 1: `authorities-page.tsx`** — add `const [filter, setFilter] = useState("");` and `const [uri, setUri] = useState("");`; import `byLabel` from `../lib/sort` and `Input`/`Label` (`Input` may need importing — check; `Button` already imported). + - **Filter ``** above the list block (below the tablist): +```tsx +
        + setFilter(e.target.value)} /> +
        +``` + - In the loaded `
          `, replace `{authorities?.map((a) => …}` with filtered (`labelText(a.labels, lang).toLowerCase().includes(q)`) + `.sort(byLabel(lang))`; filtered-empty (authorities exist) → `common.noMatches` `
        • `. + - **Create form gains an `external_uri` field** (after the ``): +```tsx +
          + + setUri(e.target.value)} /> +
          +``` + - In `onCreate`, send the URI and reset it: +```tsx +create.mutate( + { kind: kind as string, external_uri: uri.trim() || null, labels }, + { onSuccess: () => { setLabels([]); setUri(""); } }, +); +``` + +- [ ] **Step 2: `authority-row.tsx`** — same as term-row: import `ExternalUriLink`; read mode shows label + `ExternalUriLink` (when `authority.external_uri`) in a `flex-1` column; edit `external_uri` `` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`. + +- [ ] **Step 3: Tests** (`authorities.test.tsx`) — extend (mirror existing setup; check `personAuthorities` fixture): + - list **sorted by label** (assert DOM order; ensure fixture has out-of-order labels or seed it). + - **filter** narrows the list. + - the **create form has an `external_uri` field** and a created authority **posts the entered URI** (spy the POST body; type a URI; assert `body.external_uri`). + - a **read row shows the `external_uri`** link (seed a fixture authority with a URI). + Keep existing authorities tests green (tabs, aria-selected, create-without-label alert, 500, redirect). + +- [ ] **Step 4: Verify (vitest ONCE):** `cd web && pnpm vitest run src/authorities && pnpm typecheck && pnpm lint`. PASS. + +- [ ] **Step 5: Commit** +```bash +git add web/src/authorities/authorities-page.tsx web/src/authorities/authority-row.tsx web/src/authorities/authorities.test.tsx +git commit -m "feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50)" +``` + +--- + +# Task 4: Fields — filter + within-group sort + group order + count badges + gate + +**Files:** `web/src/fields/field-list.tsx`, `web/src/fields/fields.test.tsx`. + +- [ ] **Step 1: `field-list.tsx`** — import `byLabel` and `compareStrings` from `../lib/sort` and `Badge` from `@/components/ui/badge`. Add `const [filter, setFilter] = useState("");` (add `useState` to the React import). After the early returns, before grouping: + - Filter `data` by label/key: `const q = filter.trim().toLowerCase(); const filtered = (data ?? []).filter((d) => !q || labelText(d.labels, lang).toLowerCase().includes(q) || d.key.toLowerCase().includes(q));` + - Build the `groups` Map from `filtered` (not `data`). + - **Sort group entries** named A–Z (collator), `t("fields.other")` last: +```tsx +const otherLabel = t("fields.other"); +const entries = [...groups.entries()].sort((a, b) => { + if (a[0] === otherLabel) return 1; + if (b[0] === otherLabel) return -1; + return compareStrings(lang, a[0], b[0]); +}); +``` + - **Sort each group's defs** by `byLabel(lang)`: when rendering `defs.map`, use `[...defs].sort(byLabel(lang)).map(...)`. + - **Count badge** in each group header: +```tsx +
          + {group} + {defs.length} +
          +``` + - Render a **filter ``** above the `
            ` (wrap the return in a fragment/column): a `
            setFilter(e.target.value)} />
            ` then the `
              `. If `filtered.length === 0` (data exists), show `

              {t("common.noMatches")}

              ` instead of the `
                `. (Import `Input` from `@/components/ui/input`.) + Note: the filter Input must remain visible during the empty-filter state so the user can clear it. + +- [ ] **Step 2: Tests** (`fields.test.tsx`) — extend (mirror existing setup; `fieldDefinitions` fixture): + - fields render **sorted within their group by label** (assert relative DOM order of two fields in the same group). + - a **group header shows a count badge** (assert the count number is present near a group label). + - typing in the **filter** narrows the visible fields (and/or shows `common.noMatches` when nothing matches). + Keep the existing fields tests green (grouped list, create text field, reveal pickers). + +- [ ] **Step 3: FULL GATE (run tests EXACTLY ONCE):** +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors +``` +All green. Report test totals, largest chunk (KB gz), check:colors line. + +- [ ] **Step 4: Codename + status:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` + +- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`: each reference list is alphabetized and has a working filter; term/authority rows show their external_uri as a muted link; field groups show counts; the authority create form has a URI field; renaming a vocab to empty does nothing; URI inputs are `type=url` with a placeholder. + +- [ ] **Step 6: Commit** +```bash +git add web/src/fields/field-list.tsx web/src/fields/fields.test.tsx +git commit -m "feat(web): field-list filter, within-group label sort, group order, count badges (#50)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** sort.ts collator + byLabel/byKey + unit test (T1); ExternalUriLink + i18n (T1); vocab list/terms sort+filter + rename guard + read URI + url input (T2); authorities sort+filter + create URI + read URI + url input (T3); fields filter + within-group sort + group A–Z + count badges (T4); gate (T4). Acceptance criteria 1–5 mapped. ✓ + +**Placeholder scan:** the list-rewrite steps say "the EXISTING row markup, unchanged" with the files quoted, and tests say "check the fixture / seed a URI" with the fixture files named — concrete, not vague. The IIFE-vs-hoist choice is explicitly the implementer's (both lint-clean). No TODOs. ✓ + +**Type/consistency:** `byLabel(lang)`/`byKey(lang)`/`compareStrings(lang,a,b)` defined in T1, consumed in T2/T3/T4; `ExternalUriLink({uri})` used in term-row + authority-row; the filter `useState`/predicate pattern is uniform across the four lists; `[...arr].sort(...)` (copy, never mutate cache) everywhere. ✓ + +## Notes +- No new dependency; 3 new i18n keys (`common.filter`, `common.noMatches`, `labels.uriPlaceholder`), en+sv. +- Counts: only field-group counts (client-side). Per-vocab term & per-kind authority counts need backend → follow-up. +- Always sort a COPY of the react-query data (never mutate the cached array). From 6c2fa63cacbcda251c38a1a34b5423737768892b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 08:54:04 +0200 Subject: [PATCH 3/6] feat(web): collator sort helpers + ExternalUriLink + filter/uri i18n (#50) --- web/src/components/external-uri-link.tsx | 12 +++++++++ web/src/i18n/en.json | 4 +-- web/src/i18n/sv.json | 4 +-- web/src/lib/sort.test.ts | 21 ++++++++++++++++ web/src/lib/sort.ts | 31 ++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 web/src/components/external-uri-link.tsx create mode 100644 web/src/lib/sort.test.ts create mode 100644 web/src/lib/sort.ts diff --git a/web/src/components/external-uri-link.tsx b/web/src/components/external-uri-link.tsx new file mode 100644 index 0000000..3d9799a --- /dev/null +++ b/web/src/components/external-uri-link.tsx @@ -0,0 +1,12 @@ +export function ExternalUriLink({ uri }: { uri: string }) { + return ( + + {uri} + + ); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 54e52b6..5a0795e 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1,5 +1,5 @@ { - "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" }, + "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches" }, "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" }, "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" }, @@ -7,7 +7,7 @@ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "createdButFieldRejected": "Object created, but a field was rejected — fix it below.", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, - "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, + "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept.", "uriPlaceholder": "https://…" }, "theme": { "light": "Light", "dark": "Dark", "system": "System" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index aae9a44..b3c8627 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -1,5 +1,5 @@ { - "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" }, + "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar" }, "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" }, "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" }, @@ -7,7 +7,7 @@ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, - "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, + "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls.", "uriPlaceholder": "https://…" }, "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", diff --git a/web/src/lib/sort.test.ts b/web/src/lib/sort.test.ts new file mode 100644 index 0000000..78b0170 --- /dev/null +++ b/web/src/lib/sort.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; + +import { byKey, byLabel, compareStrings } from "./sort"; + +const L = (label: string) => ({ labels: [{ lang: "en", label }] }); + +test("byLabel sorts case-insensitively and locale-aware", () => { + const sorted = [L("Iron"), L("bronze"), L("Amber")].sort(byLabel("en")).map((x) => x.labels[0].label); + + expect(sorted).toEqual(["Amber", "bronze", "Iron"]); +}); + +test("byKey sorts keys with numeric awareness", () => { + const sorted = [{ key: "item10" }, { key: "item2" }, { key: "item1" }].sort(byKey("en")).map((x) => x.key); + + expect(sorted).toEqual(["item1", "item2", "item10"]); +}); + +test("compareStrings is case-insensitive", () => { + expect(compareStrings("en", "bronze", "BRONZE")).toBe(0); +}); diff --git a/web/src/lib/sort.ts b/web/src/lib/sort.ts new file mode 100644 index 0000000..8cc0de6 --- /dev/null +++ b/web/src/lib/sort.ts @@ -0,0 +1,31 @@ +import type { components } from "../api/schema"; + +import { labelText } from "./labels"; + +type LabelView = components["schemas"]["LabelView"]; + +const collators = new Map(); + +function collatorFor(lang: string): Intl.Collator { + let c = collators.get(lang); + + if (!c) { + c = new Intl.Collator(lang, { sensitivity: "base", numeric: true }); + collators.set(lang, c); + } + + return c; +} + +export function compareStrings(lang: string, a: string, b: string): number { + return collatorFor(lang).compare(a, b); +} + +export function byLabel(lang: string) { + return (a: { labels: LabelView[] }, b: { labels: LabelView[] }) => + compareStrings(lang, labelText(a.labels, lang), labelText(b.labels, lang)); +} + +export function byKey(lang: string) { + return (a: { key: string }, b: { key: string }) => compareStrings(lang, a.key, b.key); +} From 76b2cbde1d9c2b3a35baec288af8509715aea669 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 08:57:52 +0200 Subject: [PATCH 4/6] feat(web): vocab list/terms sort+filter, external_uri in rows, rename guard, url input (#50) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/vocab/term-row.tsx | 8 +++-- web/src/vocab/vocabularies.test.tsx | 56 +++++++++++++++++++++++++++++ web/src/vocab/vocabulary-list.tsx | 25 +++++++++++-- web/src/vocab/vocabulary-terms.tsx | 24 ++++++++++++- 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/web/src/vocab/term-row.tsx b/web/src/vocab/term-row.tsx index 64b8af3..d1b5cd6 100644 --- a/web/src/vocab/term-row.tsx +++ b/web/src/vocab/term-row.tsx @@ -5,6 +5,7 @@ import type { components } from "../api/schema"; import { useUpdateTerm, useDeleteTerm } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { ExternalUriLink } from "../components/external-uri-link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -29,7 +30,7 @@ export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; te
                - setUri(e.target.value)} /> + setUri(e.target.value)} />
              @@ -85,6 +105,8 @@ export function VocabularyTerms() { setUri(e.target.value)} /> From 75e7cf904787abb2111d21fb03e002372885c918 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 09:02:13 +0200 Subject: [PATCH 5/6] feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/authorities/authorities-page.tsx | 69 +++++++++++++++++++----- web/src/authorities/authorities.test.tsx | 66 +++++++++++++++++++++++ web/src/authorities/authority-row.tsx | 14 ++++- 3 files changed, 134 insertions(+), 15 deletions(-) diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 19bd9f8..37c2b5f 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -6,9 +6,13 @@ import type { components } from "../api/schema"; import { useAuthorities, useCreateAuthority } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { PageTitle } from "@/components/ui/page-title"; import { ListSkeleton } from "@/components/ui/skeletons"; import { AuthorityRow } from "./authority-row"; +import { byLabel } from "../lib/sort"; +import { labelText } from "../lib/labels"; import { useDocumentTitle } from "../lib/use-document-title"; import { useBreadcrumb } from "../shell/use-breadcrumb"; @@ -29,6 +33,8 @@ export function AuthoritiesPage() { const [labels, setLabels] = useState([]); const [error, setError] = useState(false); + const [filter, setFilter] = useState(""); + const [uri, setUri] = useState(""); useDocumentTitle(t("nav.authorities")); useBreadcrumb([{ label: t("nav.authorities") }]); @@ -45,8 +51,13 @@ export function AuthoritiesPage() { setError(false); create.mutate( - { kind: kind as string, external_uri: null, labels }, - { onSuccess: () => setLabels([]) }, + { kind: kind as string, external_uri: uri.trim() || null, labels }, + { + onSuccess: () => { + setLabels([]); + setUri(""); + }, + }, ); }; @@ -69,20 +80,41 @@ export function AuthoritiesPage() { ))} +
              + setFilter(e.target.value)} + /> +
              + {isLoading ? ( ) : ( -
                - {isError && ( -
              • {t("authorities.loadError")}
              • - )} - {!isError && authorities?.length === 0 && ( -
              • {t("authorities.empty")}
              • - )} - {authorities?.map((a) => ( - - ))} -
              + (() => { + const q = filter.trim().toLowerCase(); + const rows = [...(authorities ?? [])] + .filter((a) => !q || labelText(a.labels, lang).toLowerCase().includes(q)) + .sort(byLabel(lang)); + + return ( +
                + {isError && ( +
              • {t("authorities.loadError")}
              • + )} + {!isError && authorities?.length === 0 && ( +
              • {t("authorities.empty")}
              • + )} + {!isError && authorities && authorities.length > 0 && rows.length === 0 && ( +
              • {t("common.noMatches")}
              • + )} + {rows.map((a) => ( + + ))} +
              + ); + })() )} @@ -92,6 +124,17 @@ export function AuthoritiesPage() { +
              + + setUri(e.target.value)} + /> +
              + {error && (

              {t("form.required")} diff --git a/web/src/authorities/authorities.test.tsx b/web/src/authorities/authorities.test.tsx index fc2ffb8..d718752 100644 --- a/web/src/authorities/authorities.test.tsx +++ b/web/src/authorities/authorities.test.tsx @@ -69,3 +69,69 @@ test("unknown kind redirects to person list", async () => { renderApp(tree(), { route: "/authorities/banana" }); expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); }); + +test("authorities render sorted by label", async () => { + server.use( + http.get("/api/admin/authorities", () => + HttpResponse.json([ + { id: "a-zoe", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Zoe" }] }, + { id: "a-adam", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Adam" }] }, + ]), + ), + ); + renderApp(tree(), { route: "/authorities/person" }); + expect(await screen.findByText("Adam")).toBeInTheDocument(); + const items = screen.getAllByRole("listitem"); + const texts = items.map((item) => item.textContent ?? ""); + const adam = texts.findIndex((text) => text.includes("Adam")); + const zoe = texts.findIndex((text) => text.includes("Zoe")); + expect(adam).toBeLessThan(zoe); +}); + +test("filter narrows the authority list", async () => { + server.use( + http.get("/api/admin/authorities", () => + HttpResponse.json([ + { id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] }, + { id: "a-grace", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Grace Hopper" }] }, + ]), + ), + ); + renderApp(tree(), { route: "/authorities/person" }); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByText("Grace Hopper")).toBeInTheDocument(); + await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "grace"); + expect(screen.getByText("Grace Hopper")).toBeInTheDocument(); + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); +}); + +test("create posts the entered external_uri", async () => { + let body: unknown; + server.use( + http.post("/api/admin/authorities", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "a-c" }, { status: 201 }); + }), + ); + renderApp(tree(), { route: "/authorities/person" }); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné"); + await userEvent.type(screen.getByLabelText(/external uri/i), "https://viaf.org/456"); + await userEvent.click(screen.getByRole("button", { name: /create/i })); + await waitFor(() => + expect((body as { external_uri: string })?.external_uri).toBe("https://viaf.org/456"), + ); +}); + +test("read row shows its external_uri as a link", async () => { + server.use( + http.get("/api/admin/authorities", () => + HttpResponse.json([ + { id: "a-ada", kind: "person", external_uri: "https://viaf.org/123", labels: [{ lang: "en", label: "Ada Lovelace" }] }, + ]), + ), + ); + renderApp(tree(), { route: "/authorities/person" }); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /viaf\.org/ })).toBeInTheDocument(); +}); diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx index fe06c1b..a9a5f60 100644 --- a/web/src/authorities/authority-row.tsx +++ b/web/src/authorities/authority-row.tsx @@ -5,6 +5,7 @@ import type { components } from "../api/schema"; import { useUpdateAuthority, useDeleteAuthority } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { ExternalUriLink } from "../components/external-uri-link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -29,7 +30,13 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi

              - setUri(e.target.value)} /> + setUri(e.target.value)} + />
              - deleteField.mutateAsync(def.key)} - /> - - ))} -
            - - ))} -
          + {labelText(def.labels, lang)} + {def.key} + + {t(`fields.types.${def.data_type}`)} + + {def.required && ( + + * + + )} + + deleteField.mutateAsync(def.key)} + /> +
        • + ))} +
        +
      • + ))} +
      + )} + ); } diff --git a/web/src/fields/fields.test.tsx b/web/src/fields/fields.test.tsx index b939570..1b399e4 100644 --- a/web/src/fields/fields.test.tsx +++ b/web/src/fields/fields.test.tsx @@ -28,6 +28,65 @@ test("lists field definitions grouped, with an Other heading for ungrouped", asy expect(screen.getByText(/^Other$/i)).toBeInTheDocument(); }); +test("sorts fields within a group alphabetically by label", async () => { + server.use( + http.get("/api/admin/field-definitions", () => + HttpResponse.json([ + { + key: "weight", + data_type: "text", + vocabulary_id: null, + authority_kind: null, + required: false, + group: "Description", + labels: [{ lang: "en", label: "Weight" }], + }, + { + key: "color", + data_type: "text", + vocabulary_id: null, + authority_kind: null, + required: false, + group: "Description", + labels: [{ lang: "en", label: "Color" }], + }, + ]), + ), + ); + renderApp(tree(), { route: "/fields" }); + + await screen.findByText("Color"); + const labels = screen.getAllByText(/^(Color|Weight)$/).map((el) => el.textContent); + + expect(labels).toEqual(["Color", "Weight"]); +}); + +test("shows a count badge in each group header", async () => { + renderApp(tree(), { route: "/fields" }); + + const otherHeading = await screen.findByText(/^Other$/i); + const header = otherHeading.closest("div") as HTMLElement; + + // 6 ungrouped fields fall under "Other" in the fixture. + expect(within(header).getByText("6")).toBeInTheDocument(); +}); + +test("filter narrows the visible fields", async () => { + renderApp(tree(), { route: "/fields" }); + + await screen.findByText("Inscription"); + + await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "inscription"); + + expect(screen.getByText("Inscription")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText("Material")).not.toBeInTheDocument()); + + await userEvent.clear(screen.getByRole("textbox", { name: /filter/i })); + await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "zzzznomatch"); + + expect(await screen.findByText(/no matches/i)).toBeInTheDocument(); +}); + test("creates a text field — posts the body and clears the key input", async () => { let body: { key: string; data_type: string } | undefined;