feat(web): authorities sort+filter, create external_uri, external_uri in rows, url input (#50)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:02:13 +02:00
parent 76b2cbde1d
commit 75e7cf9047
3 changed files with 134 additions and 15 deletions
+46 -3
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,9 +80,25 @@ 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} />
) : (
(() => {
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>
@@ -79,10 +106,15 @@ export function AuthoritiesPage() {
{!isError && authorities?.length === 0 && (
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
)}
{authorities?.map((a) => (
{!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"