Files
biggus-dickus/docs/superpowers/plans/2026-06-08-refdata-scannability.md
T

17 KiB
Raw Blame History

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) 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 <span className="flex-1">{labelText(...)}</span> + Edit + DeleteConfirmDialog; edit mode has the external_uri <Input> (no type/placeholder).
  • vocabulary-list.tsx: create form (has empty-guard) + list; rename <form onSubmit> 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 AZ); group header <div className="… label-caption">{group}</div>; 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:

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 — 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 <Input> between the create <form> and the list block:
<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 — add const [filter, setFilter] = useState("");; import byLabel from ../lib/sort. Add a filter <Input> above the terms list (the component already has Input imported; 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>. Add type="url" + placeholder={t("labels.uriPlaceholder")} to the add-term external_uri <Input id="term-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:

<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_uri as a link (seed a term fixture with external_uri; assert getByRole("link", { name: /<uri>/ })). (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

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 <Input> above the list block (below the tablist):
<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_uri field (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: import ExternalUriLink; read mode shows label + ExternalUriLink (when authority.external_uri) in a flex-1 column; edit external_uri <Input> 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

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 AZ (collator), t("fields.other") last:
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:
<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>. If filtered.length === 0 (data exists), show <p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p> instead of the <ul>. (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):

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 are type=url with 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 AZ + count badges (T4); gate (T4). Acceptance criteria 15 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).