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 { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||||
import { LabelEditor } from "../components/label-editor";
|
import { LabelEditor } from "../components/label-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { PageTitle } from "@/components/ui/page-title";
|
||||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||||
import { AuthorityRow } from "./authority-row";
|
import { AuthorityRow } from "./authority-row";
|
||||||
|
import { byLabel } from "../lib/sort";
|
||||||
|
import { labelText } from "../lib/labels";
|
||||||
import { useDocumentTitle } from "../lib/use-document-title";
|
import { useDocumentTitle } from "../lib/use-document-title";
|
||||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ export function AuthoritiesPage() {
|
|||||||
|
|
||||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
|
||||||
useDocumentTitle(t("nav.authorities"));
|
useDocumentTitle(t("nav.authorities"));
|
||||||
useBreadcrumb([{ label: t("nav.authorities") }]);
|
useBreadcrumb([{ label: t("nav.authorities") }]);
|
||||||
@@ -45,8 +51,13 @@ export function AuthoritiesPage() {
|
|||||||
|
|
||||||
setError(false);
|
setError(false);
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ kind: kind as string, external_uri: null, labels },
|
{ kind: kind as string, external_uri: uri.trim() || null, labels },
|
||||||
{ onSuccess: () => setLabels([]) },
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setLabels([]);
|
||||||
|
setUri("");
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,20 +80,41 @@ export function AuthoritiesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{isLoading ? (
|
||||||
<ListSkeleton className="mb-4" rows={5} />
|
<ListSkeleton className="mb-4" rows={5} />
|
||||||
) : (
|
) : (
|
||||||
<ul className="mb-4">
|
(() => {
|
||||||
{isError && (
|
const q = filter.trim().toLowerCase();
|
||||||
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
|
const rows = [...(authorities ?? [])]
|
||||||
)}
|
.filter((a) => !q || labelText(a.labels, lang).toLowerCase().includes(q))
|
||||||
{!isError && authorities?.length === 0 && (
|
.sort(byLabel(lang));
|
||||||
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
|
|
||||||
)}
|
return (
|
||||||
{authorities?.map((a) => (
|
<ul className="mb-4">
|
||||||
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
{isError && (
|
||||||
))}
|
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
|
||||||
</ul>
|
)}
|
||||||
|
{!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">
|
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||||
@@ -92,6 +124,17 @@ export function AuthoritiesPage() {
|
|||||||
|
|
||||||
<LabelEditor value={labels} onChange={setLabels} />
|
<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 && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-destructive">
|
<p role="alert" className="text-xs text-destructive">
|
||||||
{t("form.required")}
|
{t("form.required")}
|
||||||
|
|||||||
@@ -69,3 +69,69 @@ test("unknown kind redirects to person list", async () => {
|
|||||||
renderApp(tree(), { route: "/authorities/banana" });
|
renderApp(tree(), { route: "/authorities/banana" });
|
||||||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
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 { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
|
||||||
import { LabelEditor } from "../components/label-editor";
|
import { LabelEditor } from "../components/label-editor";
|
||||||
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||||
|
import { ExternalUriLink } from "../components/external-uri-link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -29,7 +30,13 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
|
|||||||
<LabelEditor value={labels} onChange={setLabels} />
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
|
<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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -55,7 +62,10 @@ export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityVi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center gap-2 border-b py-1 text-sm">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
Reference in New Issue
Block a user