17 KiB
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 + <Input>; 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)inlib/labels.ts.lang = i18n.language.startsWith("sv") ? "sv" : "en".- term/authority view =
{ id, labels, external_uri?: string|null }(authority alsokind); vocabulary ={ id, key }; field-def ={ key, labels, data_type, group?, required, … }. term-row.tsx/authority-row.tsx: read mode<span className="flex-1">{labelText(...)}</span>+ Edit + DeleteConfirmDialog; edit mode has theexternal_uri<Input>(notype/placeholder).vocabulary-list.tsx: create form (has empty-guard) + list; rename<form onSubmit>callsrenameVocabulary.mutate({ id, key: draftKey.trim() })with NO empty-guard.authorities-page.tsx: tabs + list + create form; create callscreate.mutate({ kind, external_uri: null, labels })— hardcoded null, no URI field.field-list.tsx: groups built into aMap; sorted witht("fields.other")last (no A–Z); group header<div className="… label-caption">{group}</div>; rows show label+key+type+required.commoni18n:{ 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:
import type { components } from "../api/schema";
import { labelText } from "./labels";
type LabelView = components["schemas"]["LabelView"];
const collators = new Map<string, Intl.Collator>();
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):
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):
export function ExternalUriLink({ uri }: { uri: string }) {
return (
<a
href={uri}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-xs text-muted-foreground hover:text-foreground"
>
{uri}
</a>
);
}
-
Step 5: Verify (vitest ONCE):
cd web && pnpm vitest run src/lib/sort.test.ts && pnpm typecheck && pnpm lint. PASS, clean. -
Step 6: Commit
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— addi18ntouseTranslation(const { t, i18n } = …) andconst lang = i18n.language.startsWith("sv") ? "sv" : "en";; importbyKeyfrom../lib/sortandInputis already imported. Addconst [filter, setFilter] = useState("");.- Add a filter
<Input>between the create<form>and the list block:
- Add a filter
<div className="border-b p-2">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
- In the loaded
<ul>, replace{data?.map((v) => (…with a filtered+sorted list:
{(() => {
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 <li className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</li>;
return rows.map((v) => (/* the EXISTING <li> 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
<form onSubmit>, add a guard before mutate:
onSubmit={(e) => {
e.preventDefault();
if (!draftKey.trim()) return;
renameVocabulary.mutate({ id: v.id, key: draftKey.trim() }, { onSuccess: () => setEditingId(null) });
}}
-
Step 2:
vocabulary-terms.tsx— addconst [filter, setFilter] = useState("");; importbyLabelfrom../lib/sort. Add a filter<Input>above the terms list (the component already hasInputimported; place it above the{isLoading ? … : <ul>}block). In the<ul>, replace{terms?.map((term) => …}with filtered (labelText(term.labels, lang).toLowerCase().includes(q)) +.sort(byLabel(lang)); filtered-empty (terms exist) →common.noMatches<li>. Addtype="url"+placeholder={t("labels.uriPlaceholder")}to the add-termexternal_uri<Input id="term-uri">. -
Step 3:
term-row.tsx— importExternalUriLinkfrom../components/external-uri-link. In read mode, wrap the label + URI in a flex-1 column so the URI shows under the label:
<div className="flex-1">
<div>{labelText(term.labels, lang)}</div>
{term.external_uri && <ExternalUriLink uri={term.external_uri} />}
</div>
(Replace the existing <span className="flex-1">{labelText(...)}</span>; keep the Edit + DeleteConfirmDialog.) Add type="url" + placeholder={t("labels.uriPlaceholder")} to the edit-mode external_uri <Input>.
-
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_urias a link (seed a term fixture withexternal_uri; assertgetByRole("link", { name: /<uri>/ })). (If the term fixture lacks a URI, add one — checkmaterialTermsinweb/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
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— addconst [filter, setFilter] = useState("");andconst [uri, setUri] = useState("");; importbyLabelfrom../lib/sortandInput/Label(Inputmay need importing — check;Buttonalready imported).- Filter
<Input>above the list block (below the tablist):
- Filter
<div className="mb-3">
<Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} />
</div>
- In the loaded
<ul>, replace{authorities?.map((a) => …}with filtered (labelText(a.labels, lang).toLowerCase().includes(q)) +.sort(byLabel(lang)); filtered-empty (authorities exist) →common.noMatches<li>. - Create form gains an
external_urifield (after the<LabelEditor>):
<div className="space-y-1">
<Label htmlFor="auth-create-uri">{t("labels.externalUri")}</Label>
<Input id="auth-create-uri" type="url" placeholder={t("labels.uriPlaceholder")} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
- In
onCreate, send the URI and reset it:
create.mutate(
{ kind: kind as string, external_uri: uri.trim() || null, labels },
{ onSuccess: () => { setLabels([]); setUri(""); } },
);
-
Step 2:
authority-row.tsx— same as term-row: importExternalUriLink; read mode shows label +ExternalUriLink(whenauthority.external_uri) in aflex-1column; editexternal_uri<Input>getstype="url"+placeholder={t("labels.uriPlaceholder")}. -
Step 3: Tests (
authorities.test.tsx) — extend (mirror existing setup; checkpersonAuthoritiesfixture):- 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_urifield and a created authority posts the entered URI (spy the POST body; type a URI; assertbody.external_uri). - a read row shows the
external_urilink (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
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— importbyLabelandcompareStringsfrom../lib/sortandBadgefrom@/components/ui/badge. Addconst [filter, setFilter] = useState("");(adduseStateto the React import). After the early returns, before grouping:- Filter
databy 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
groupsMap fromfiltered(notdata). - Sort group entries named A–Z (collator),
t("fields.other")last:
- Filter
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 renderingdefs.map, use[...defs].sort(byLabel(lang)).map(...). - Count badge in each group header:
<div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
<span>{group}</span>
<Badge variant="secondary">{defs.length}</Badge>
</div>
-
Render a filter
<Input>above the<ul>(wrap the return in a fragment/column): a<div className="border-b p-2"><Input aria-label={t("common.filter")} placeholder={t("common.filter")} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>then the<ul>. Iffiltered.length === 0(data exists), show<p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>instead of the<ul>. (ImportInputfrom@/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;fieldDefinitionsfixture):- 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.noMatcheswhen nothing matches). Keep the existing fields tests green (grouped list, create text field, reveal pickers).
-
Step 3: FULL GATE (run tests EXACTLY ONCE):
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:
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 aretype=urlwith a placeholder. -
Step 6: Commit
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).