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 `