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:
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user