From a4fb05a1752315de203e891b3a5a121a786369df Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 08:18:58 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20reference-data=20scannability=20?= =?UTF-8?q?+=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).