merge: reference-data scannability + parity — sort/filter/external_uri/counts (#50)
CI / web (push) Has been cancelled

This commit is contained in:
2026-06-08 09:10:33 +02:00
16 changed files with 842 additions and 72 deletions
@@ -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 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`:**
```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 AZ (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 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).
@@ -0,0 +1,100 @@
# Reference-Data Scannability + Parity — Design
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation planning.
**Issue:** #50 (scoped: scannability + parity; layout/edit-modality unification + API-backed counts are follow-ups).
## Context
The three reference-data screens (vocabularies, authorities, fields) render lists in API
**creation order** with **no client sort and no filter** — finding "Bronze" in a 200-term vocabulary
is effectively impossible. Read-mode rows show **only the label**; `external_uri` (the point of
authority control — distinguishing two "Mercury"s) is visible only in edit mode (`term-row.tsx`,
`authority-row.tsx`). There are **no counts** anywhere. And there are small parity/validation gaps:
authority **create** has no `external_uri` field (hardcoded `null`) though edit does; vocab **rename**
lacks the empty-guard the create form has; URI inputs have no `type="url"`/placeholder.
This milestone fixes the daily-pain "can't find / can't disambiguate" problems + the parity gaps,
**without** changing layout/edit modality (a separate redesign) and **without** backend changes.
**Facts:** `labelText(labels, lang)` exists (`lib/labels.ts`). No `Intl.Collator` anywhere. List
shapes: term/authority = `{ id, labels, external_uri?: string|null }` (authority also `kind`);
vocabulary = `{ id, key }` (no labels); field-definition = `{ key, labels, data_type, group?, required, … }`.
No count field on any view (so per-vocab term counts + per-kind authority counts need backend →
deferred; **field-group counts are client-side** and in scope). `Badge`/`Input` exist. Mutations:
`useCreateAuthority` accepts `external_uri` but the page passes `null`; `useRenameVocabulary` has no
empty-guard; `useAddTerm`/`useUpdateTerm`/`useUpdateAuthority` already accept `external_uri`.
### Decisions (from brainstorming)
1. Sort every list by label/key with a locale-aware `Intl.Collator`; add a client-side filter `Input`.
2. Show `external_uri` (linkified, muted) in term/authority read rows; field-group count `Badge`s.
3. Close the parity/validation gaps (authority-create URI, rename empty-guard, `type="url"` + placeholder).
## Components
### `web/src/lib/sort.ts` (new)
- A memoized collator per lang: `Intl.Collator(lang, { sensitivity: "base", numeric: true })` (cache in a `Map<string, Intl.Collator>`).
- `export function byLabel(lang: string)``(a: { labels: LabelView[] }, b) => number` comparing `labelText(a.labels, lang)` vs `labelText(b.labels, lang)` via the collator.
- `export function byKey(lang: string)``(a: { key: string }, b) => number` comparing `a.key` vs `b.key`.
- (Comparators take the minimal structural type so they work for terms/authorities/fields/vocabs.)
### `web/src/components/external-uri-link.tsx` (new)
A tiny shared read-mode link: `function ExternalUriLink({ uri }: { uri: string })`
`<a href={uri} target="_blank" rel="noopener noreferrer" className="block truncate text-xs text-muted-foreground hover:text-foreground">{uri}</a>`. Used in term-row + authority-row read mode (render only when `external_uri` is truthy).
### i18n (en + sv parity)
- `common.filter` = "Filter…" / "Filtrera…"
- `common.noMatches` = "No matches" / "Inga träffar"
- `labels.uriPlaceholder` = "https://…" (same in both)
### Per-screen changes
**Vocabularies**
- `vocabulary-list.tsx`: a filter `<Input>` (placeholder `common.filter`) above the list; filter vocabularies by `key` (case-insensitive `includes`) then **sort by `byKey(lang)`**; if filtered-empty but data exists, show muted `common.noMatches`. Add the **rename empty-guard**: `if (!draftKey.trim()) return;` before the rename mutate.
- `vocabulary-terms.tsx`: a filter `<Input>` above the terms list; filter terms by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The add-term `external_uri` `<Input>` gets `type="url"` + `placeholder={t("labels.uriPlaceholder")}`.
- `term-row.tsx`: read mode renders `<ExternalUriLink uri={term.external_uri} />` under the label when present; the edit-mode `external_uri` `<Input>` gets `type="url"` + the placeholder.
**Authorities**
- `authorities-page.tsx`: a filter `<Input>` above the list; filter by `labelText` then **sort by `byLabel(lang)`**; filtered-empty → `common.noMatches`. The **create form gains an `external_uri` `<Input>`** (`type="url"`, placeholder) mirroring the edit row, stored in a `useState`, sent via `useCreateAuthority` (replace the hardcoded `external_uri: null`).
- `authority-row.tsx`: read mode renders `<ExternalUriLink uri={authority.external_uri} />` under the label when present; edit `external_uri` `<Input>` gets `type="url"` + placeholder.
**Fields**
- `field-list.tsx`: a filter `<Input>` above the list; filter by `labelText`/`key`; **sort groups alphabetically with "Other"/ungrouped last, and sort fields within each group by `byLabel(lang)`**; each group header shows a `<Badge variant="secondary">{count}</Badge>` (count of fields in that group, after filtering); filtered-empty → `common.noMatches`.
## Data flow
Loaded list → client filter (by label/key substring) → collator sort → render. `external_uri` read
from the existing view field. Field-group counts from the grouped array length. No new queries.
## Error handling / edges
- Filter is case-insensitive substring on the localized label (or key). Empty filter = show all.
- Collator memoized per lang; lang from `i18n.language` (sv/en) as elsewhere.
- `ExternalUriLink` only renders for truthy `external_uri`; `rel="noopener noreferrer"` + `target="_blank"`.
- `type="url"` gives the browser's basic URL hint (not strict validation — full validation is a follow-up); it must not block submitting an empty optional URI (the field stays optional → send `null`/omit when blank, exactly as today).
- Sorting must not mutate the query cache array — sort a copy (`[...list].sort(...)`).
- Field group ordering: a stable rule (named groups AZ by collator, then the ungrouped "Other" bucket last).
## Testing
- **`sort.test.ts`** (unit): `byLabel`/`byKey` order case-insensitively + locale-aware (e.g. "ä" sorts sensibly in sv; "bronze" before "Iron").
- **Vocabularies** (`vocabularies.test.tsx`): vocabularies render sorted by key; typing in the filter narrows the list; rename with an empty key does not fire the mutation.
- **Authorities** (`authorities.test.tsx`): list sorted by label; filter narrows; the create form has an `external_uri` field and a created authority posts the entered URI; a read row shows the `external_uri` link.
- **Vocabulary terms / term-row:** terms sorted by label; a term read row shows its `external_uri` link.
- **Fields** (`fields.test.tsx`): fields sorted within group; a group header shows a count badge; filter narrows. Keep the existing create/reveal-picker assertions green.
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity (3 new keys); no codename; no new dependency.
## Acceptance criteria
1. Every reference list is sorted by label (terms/authorities/fields) or key (vocabularies) via a
locale-aware `Intl.Collator`, and has a client-side filter `Input` (with a `common.noMatches`
empty state).
2. `external_uri` is shown (linkified, muted, truncated) in term + authority read rows when present.
3. Field-group headers show a count `Badge`.
4. Parity/validation gaps closed: authority **create** has an `external_uri` field and sends it; vocab
**rename** has an empty-guard; all `external_uri` inputs use `type="url"` + a placeholder.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity; no
codename; no new dependency; no layout/edit-modality change; no backend change.
## Out of scope → follow-ups
- The layout + edit-modality **unification** (authorities → two-pane pane-edit; vocab-rename/term/
authority → pane-edit; one create location) — a separate redesign.
- **API-backed counts**: per-vocabulary term counts and per-kind authority-tab counts (need view/count
fields or endpoints).
- Strict URL validation (beyond `type="url"`); linkifying `external_uri` elsewhere (e.g. object detail).
+56 -13
View File
@@ -6,9 +6,13 @@ import type { components } from "../api/schema";
import { useAuthorities, useCreateAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageTitle } from "@/components/ui/page-title";
import { ListSkeleton } from "@/components/ui/skeletons";
import { AuthorityRow } from "./authority-row";
import { byLabel } from "../lib/sort";
import { labelText } from "../lib/labels";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
@@ -29,6 +33,8 @@ export function AuthoritiesPage() {
const [labels, setLabels] = useState<LabelInput[]>([]);
const [error, setError] = useState(false);
const [filter, setFilter] = useState("");
const [uri, setUri] = useState("");
useDocumentTitle(t("nav.authorities"));
useBreadcrumb([{ label: t("nav.authorities") }]);
@@ -45,8 +51,13 @@ export function AuthoritiesPage() {
setError(false);
create.mutate(
{ kind: kind as string, external_uri: null, labels },
{ onSuccess: () => setLabels([]) },
{ kind: kind as string, external_uri: uri.trim() || null, labels },
{
onSuccess: () => {
setLabels([]);
setUri("");
},
},
);
};
@@ -69,20 +80,41 @@ export function AuthoritiesPage() {
))}
</div>
<div className="mb-3">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && (
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
)}
{!isError && authorities?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)}
{authorities?.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))}
</ul>
(() => {
const q = filter.trim().toLowerCase();
const rows = [...(authorities ?? [])]
.filter((a) => !q || labelText(a.labels, lang).toLowerCase().includes(q))
.sort(byLabel(lang));
return (
<ul className="mb-4">
{isError && (
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
)}
{!isError && authorities?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)}
{!isError && authorities && authorities.length > 0 && rows.length === 0 && (
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
)}
{rows.map((a) => (
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))}
</ul>
);
})()
)}
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
@@ -92,6 +124,17 @@ export function AuthoritiesPage() {
<LabelEditor value={labels} onChange={setLabels} />
<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>
{error && (
<p role="alert" className="text-xs text-destructive">
{t("form.required")}
+66
View File
@@ -69,3 +69,69 @@ test("unknown kind redirects to person list", async () => {
renderApp(tree(), { route: "/authorities/banana" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
});
test("authorities render sorted by label", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-zoe", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Zoe" }] },
{ id: "a-adam", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Adam" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Adam")).toBeInTheDocument();
const items = screen.getAllByRole("listitem");
const texts = items.map((item) => item.textContent ?? "");
const adam = texts.findIndex((text) => text.includes("Adam"));
const zoe = texts.findIndex((text) => text.includes("Zoe"));
expect(adam).toBeLessThan(zoe);
});
test("filter narrows the authority list", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
{ id: "a-grace", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Grace Hopper" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "grace");
expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument();
});
test("create posts the entered external_uri", async () => {
let body: unknown;
server.use(
http.post("/api/admin/authorities", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "a-c" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné");
await userEvent.type(screen.getByLabelText(/external uri/i), "https://viaf.org/456");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() =>
expect((body as { external_uri: string })?.external_uri).toBe("https://viaf.org/456"),
);
});
test("read row shows its external_uri as a link", async () => {
server.use(
http.get("/api/admin/authorities", () =>
HttpResponse.json([
{ id: "a-ada", kind: "person", external_uri: "https://viaf.org/123", labels: [{ lang: "en", label: "Ada Lovelace" }] },
]),
),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /viaf\.org/ })).toBeInTheDocument();
});
+12 -2
View File
@@ -5,6 +5,7 @@ import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { ExternalUriLink } from "../components/external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -29,7 +30,13 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
<Input id={`auth-uri-${authority.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
<Input
id={`auth-uri-${authority.id}`}
type="url"
placeholder={t("labels.uriPlaceholder")}
value={uri}
onChange={(e) => setUri(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
@@ -55,7 +62,10 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(authority.labels, lang)}</span>
<div className="flex-1">
<div>{labelText(authority.labels, lang)}</div>
{authority.external_uri && <ExternalUriLink uri={authority.external_uri} />}
</div>
<Button
type="button"
variant="ghost"
+12
View File
@@ -0,0 +1,12 @@
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>
);
}
+78 -48
View File
@@ -1,9 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels";
import { byLabel, compareStrings } from "../lib/sort";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ListSkeleton } from "@/components/ui/skeletons";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
@@ -19,6 +23,7 @@ export function FieldList({
const { data, isLoading, isError } = useFieldDefinitions();
const deleteField = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const [filter, setFilter] = useState("");
if (isLoading) return <ListSkeleton rows={6} />;
@@ -26,9 +31,17 @@ export function FieldList({
if (!data || data.length === 0)
return <p className="p-4 text-sm text-muted-foreground">{t("fields.empty")}</p>;
const q = filter.trim().toLowerCase();
const filtered = (data ?? []).filter(
(d) =>
!q ||
labelText(d.labels, lang).toLowerCase().includes(q) ||
d.key.toLowerCase().includes(q),
);
const groups = new Map<string, FieldDefinitionView[]>();
for (const def of data) {
for (const def of filtered) {
const key = def.group?.trim() ? def.group : t("fields.other");
const bucket = groups.get(key) ?? [];
@@ -37,55 +50,72 @@ export function FieldList({
}
const otherLabel = t("fields.other");
const entries = [...groups.entries()].sort((a, b) =>
a[0] === otherLabel ? 1 : b[0] === otherLabel ? -1 : 0,
);
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]);
});
return (
<ul className="overflow-auto">
{entries.map(([group, defs]) => (
<li key={group}>
<div className="border-b bg-muted px-3 py-1 label-caption">
{group}
</div>
<ul>
{defs.map((def) => (
<li
key={def.key}
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
def.key === selectedKey ? "bg-primary/10" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
aria-pressed={def.key === selectedKey}
onClick={() => onSelect(def)}
>
<span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-muted-foreground">{def.key}</span>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{t(`fields.types.${def.data_type}`)}
</span>
{def.required && (
<span
className="text-xs text-destructive"
title={t("fields.required")}
aria-label={t("fields.required")}
<div className="flex h-full flex-col">
<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>
{filtered.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>
) : (
<ul className="overflow-auto">
{entries.map(([group, defs]) => (
<li key={group}>
<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>
<ul>
{[...defs].sort(byLabel(lang)).map((def) => (
<li
key={def.key}
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
def.key === selectedKey ? "bg-primary/10" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
aria-pressed={def.key === selectedKey}
onClick={() => onSelect(def)}
>
*
</span>
)}
</button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteField")}
onConfirm={() => deleteField.mutateAsync(def.key)}
/>
</li>
))}
</ul>
</li>
))}
</ul>
<span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-muted-foreground">{def.key}</span>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{t(`fields.types.${def.data_type}`)}
</span>
{def.required && (
<span
className="text-xs text-destructive"
title={t("fields.required")}
aria-label={t("fields.required")}
>
*
</span>
)}
</button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteField")}
onConfirm={() => deleteField.mutateAsync(def.key)}
/>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
);
}
+59
View File
@@ -28,6 +28,65 @@ test("lists field definitions grouped, with an Other heading for ungrouped", asy
expect(screen.getByText(/^Other$/i)).toBeInTheDocument();
});
test("sorts fields within a group alphabetically by label", async () => {
server.use(
http.get("/api/admin/field-definitions", () =>
HttpResponse.json([
{
key: "weight",
data_type: "text",
vocabulary_id: null,
authority_kind: null,
required: false,
group: "Description",
labels: [{ lang: "en", label: "Weight" }],
},
{
key: "color",
data_type: "text",
vocabulary_id: null,
authority_kind: null,
required: false,
group: "Description",
labels: [{ lang: "en", label: "Color" }],
},
]),
),
);
renderApp(tree(), { route: "/fields" });
await screen.findByText("Color");
const labels = screen.getAllByText(/^(Color|Weight)$/).map((el) => el.textContent);
expect(labels).toEqual(["Color", "Weight"]);
});
test("shows a count badge in each group header", async () => {
renderApp(tree(), { route: "/fields" });
const otherHeading = await screen.findByText(/^Other$/i);
const header = otherHeading.closest("div") as HTMLElement;
// 6 ungrouped fields fall under "Other" in the fixture.
expect(within(header).getByText("6")).toBeInTheDocument();
});
test("filter narrows the visible fields", async () => {
renderApp(tree(), { route: "/fields" });
await screen.findByText("Inscription");
await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "inscription");
expect(screen.getByText("Inscription")).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText("Material")).not.toBeInTheDocument());
await userEvent.clear(screen.getByRole("textbox", { name: /filter/i }));
await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "zzzznomatch");
expect(await screen.findByText(/no matches/i)).toBeInTheDocument();
});
test("creates a text field — posts the body and clears the key input", async () => {
let body: { key: string; data_type: string } | undefined;
+2 -2
View File
@@ -1,5 +1,5 @@
{
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" },
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches" },
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
@@ -7,7 +7,7 @@
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "createdButFieldRejected": "Object created, but a field was rejected — fix it below.", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } },
"actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
"labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." },
"labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept.", "uriPlaceholder": "https://…" },
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
"vocab": {
"newVocabulary": "New vocabulary", "key": "Key",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" },
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar" },
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
@@ -7,7 +7,7 @@
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } },
"actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." },
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls.", "uriPlaceholder": "https://…" },
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
"vocab": {
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
+21
View File
@@ -0,0 +1,21 @@
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);
});
+31
View File
@@ -0,0 +1,31 @@
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);
}
+6 -2
View File
@@ -5,6 +5,7 @@ import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { ExternalUriLink } from "../components/external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -29,7 +30,7 @@ export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; te
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`term-uri-${term.id}`}>{t("labels.externalUri")}</Label>
<Input id={`term-uri-${term.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
<Input id={`term-uri-${term.id}`} type="url" placeholder={t("labels.uriPlaceholder")} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button
@@ -55,7 +56,10 @@ export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; te
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(term.labels, lang)}</span>
<div className="flex-1">
<div>{labelText(term.labels, lang)}</div>
{term.external_uri && <ExternalUriLink uri={term.external_uri} />}
</div>
<Button
type="button"
variant="ghost"
+56
View File
@@ -74,3 +74,59 @@ test("add term without EN label shows required alert and does not POST", async (
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(posted).toBe(false);
});
test("vocabularies render sorted by key", async () => {
server.use(
http.get("/api/admin/vocabularies", () =>
HttpResponse.json([
{ id: "v-zeta", key: "zeta" },
{ id: "v-alpha", key: "alpha" },
]),
),
);
renderApp(tree(), { route: "/vocabularies" });
expect(await screen.findByText("alpha")).toBeInTheDocument();
const links = screen.getAllByRole("link");
const keys = links.map((link) => link.textContent);
expect(keys.indexOf("alpha")).toBeLessThan(keys.indexOf("zeta"));
});
test("filter narrows the vocabulary list", async () => {
renderApp(tree(), { route: "/vocabularies" });
expect(await screen.findByText("material")).toBeInTheDocument();
expect(screen.getByText("technique")).toBeInTheDocument();
await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "mat");
expect(screen.getByText("material")).toBeInTheDocument();
expect(screen.queryByText("technique")).not.toBeInTheDocument();
});
test("renaming a vocabulary to an empty key does not call the rename endpoint", async () => {
let renamed = false;
server.use(
http.patch("/api/admin/vocabularies/:id", () => {
renamed = true;
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(tree(), { route: "/vocabularies" });
expect(await screen.findByText("material")).toBeInTheDocument();
await userEvent.click(screen.getAllByRole("button", { name: /rename/i })[0]);
const keyInputs = screen.getAllByRole("textbox", { name: /key/i });
const input = keyInputs[keyInputs.length - 1];
await userEvent.clear(input);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(renamed).toBe(false);
});
test("term read row shows its external_uri as a link", async () => {
server.use(
http.get("/api/admin/vocabularies/:id/terms", () =>
HttpResponse.json([
{ id: "t-bronze", external_uri: "https://example.org/bronze", labels: [{ lang: "en", label: "Bronze" }] },
]),
),
);
renderApp(tree(), { route: "/vocabularies/v-material" });
expect(await screen.findByText("Bronze")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /example\.org/ })).toBeInTheDocument();
});
+23 -2
View File
@@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries";
import { byKey } from "../lib/sort";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,7 +11,9 @@ import { Label } from "@/components/ui/label";
import { ListSkeleton } from "@/components/ui/skeletons";
export function VocabularyList() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const { data, isLoading, isError } = useVocabularies();
@@ -19,6 +22,7 @@ export function VocabularyList() {
const deleteVocabulary = useDeleteVocabulary();
const [key, setKey] = useState("");
const [filter, setFilter] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [draftKey, setDraftKey] = useState("");
@@ -30,6 +34,11 @@ export function VocabularyList() {
create.mutate({ key: key.trim() }, { onSuccess: () => setKey("") });
};
const q = filter.trim().toLowerCase();
const rows = [...(data ?? [])]
.filter((v) => !q || v.key.toLowerCase().includes(q))
.sort(byKey(lang));
return (
<div className="flex h-full flex-col">
<form onSubmit={onCreate} className="space-y-1 border-b p-3">
@@ -51,6 +60,14 @@ export function VocabularyList() {
</p>
)}
</form>
<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>
{isLoading ? (
<ListSkeleton className="flex-1 overflow-auto" />
) : (
@@ -61,13 +78,17 @@ export function VocabularyList() {
{data?.length === 0 && (
<li className="p-3 text-sm text-muted-foreground">{t("vocab.empty")}</li>
)}
{data?.map((v) => (
{data && data.length > 0 && rows.length === 0 && (
<li className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</li>
)}
{rows.map((v) => (
<li key={v.id} className="flex items-center gap-1 border-b pr-2">
{editingId === v.id ? (
<form
className="flex flex-1 flex-wrap gap-1 p-1"
onSubmit={(e) => {
e.preventDefault();
if (!draftKey.trim()) return;
renameVocabulary.mutate(
{ id: v.id, key: draftKey.trim() },
{ onSuccess: () => setEditingId(null) },
+23 -1
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useTerms, useAddTerm, useVocabularies } from "../api/queries";
import { byLabel } from "../lib/sort";
import { labelText } from "../lib/labels";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { LabelEditor } from "../components/label-editor";
import { TermRow } from "./term-row";
@@ -29,6 +31,8 @@ export function VocabularyTerms() {
const [uri, setUri] = useState("");
const [filter, setFilter] = useState("");
const [error, setError] = useState(false);
const { data: vocabularies } = useVocabularies();
@@ -58,11 +62,24 @@ export function VocabularyTerms() {
);
};
const q = filter.trim().toLowerCase();
const rows = [...(terms ?? [])]
.filter((term) => !q || labelText(term.labels, lang).toLowerCase().includes(q))
.sort(byLabel(lang));
return (
<div className="overflow-auto p-4">
<div className="mb-2 label-caption">
{t("vocab.terms")}
</div>
<div className="mb-3">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
@@ -73,7 +90,10 @@ export function VocabularyTerms() {
{!isError && terms?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
)}
{terms?.map((term) => (
{!isError && terms && terms.length > 0 && rows.length === 0 && (
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
)}
{rows.map((term) => (
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
))}
</ul>
@@ -85,6 +105,8 @@ export function VocabularyTerms() {
<Label htmlFor="term-uri">{t("labels.externalUri")}</Label>
<Input
id="term-uri"
type="url"
placeholder={t("labels.uriPlaceholder")}
value={uri}
onChange={(e) => setUri(e.target.value)}
/>