docs(plans): reference-data scannability + parity — 4-task plan (#50)
This commit is contained in:
@@ -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` + `<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 A–Z); 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`:**
|
||||||
|
```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):
|
||||||
|
```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 (
|
||||||
|
<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**
|
||||||
|
```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 `<Input>` between the create `<form>` and the list block:
|
||||||
|
```tsx
|
||||||
|
<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:
|
||||||
|
```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 <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:
|
||||||
|
```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 `<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:
|
||||||
|
```tsx
|
||||||
|
<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**
|
||||||
|
```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 `<Input>`** above the list block (below the tablist):
|
||||||
|
```tsx
|
||||||
|
<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>`):
|
||||||
|
```tsx
|
||||||
|
<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:
|
||||||
|
```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` `<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**
|
||||||
|
```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
|
||||||
|
<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):**
|
||||||
|
```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).
|
||||||
Reference in New Issue
Block a user