fix(web): search 503 vs error (#34); terms/authorities list error states (#31); authority-tab a11y + dead keys (#32); authority-kind test (#37)

This commit is contained in:
2026-06-04 17:28:01 +02:00
parent 1a91b8a242
commit ff513e1712
10 changed files with 92 additions and 15 deletions
+9 -2
View File
@@ -3,6 +3,13 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClie
import { api } from "./client";
import type { components } from "./schema";
export class HttpError extends Error {
constructor(public readonly status: number) {
super(`HTTP ${status}`);
this.name = "HttpError";
}
}
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
@@ -291,7 +298,7 @@ export function useSearch(q: string, visibility: string | null) {
enabled: term.length > 0,
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const { data, error } = await api.GET("/api/admin/search", {
const { data, error, response } = await api.GET("/api/admin/search", {
params: {
query: {
q: term,
@@ -302,7 +309,7 @@ export function useSearch(q: string, visibility: string | null) {
},
});
if (error || !data) throw new Error("search failed");
if (error || !data) throw new HttpError(response.status);
return data;
},
+11 -4
View File
@@ -19,7 +19,7 @@ export function AuthoritiesPage() {
const isValidKind = (KINDS as readonly string[]).includes(kind ?? "");
const { data: authorities } = useAuthorities(isValidKind ? (kind as string) : "person");
const { data: authorities, isLoading, isError } = useAuthorities(isValidKind ? (kind as string) : "person");
const create = useCreateAuthority();
const [labels, setLabels] = useState<LabelInput[]>([]);
@@ -44,22 +44,29 @@ export function AuthoritiesPage() {
return (
<div className="overflow-auto p-4">
<div className="mb-3 flex gap-2">
<div role="tablist" className="mb-3 flex gap-2">
{KINDS.map((k) => (
<NavLink
key={k}
to={`/authorities/${k}`}
role="tab"
className={({ isActive }) =>
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`
}
>
{t(`authorities.${k}`)}
{({ isActive }) => <span aria-selected={isActive}>{t(`authorities.${k}`)}</span>}
</NavLink>
))}
</div>
<ul className="mb-4">
{authorities?.length === 0 && (
{isLoading && (
<li className="text-sm text-neutral-400"></li>
)}
{isError && (
<li className="text-sm text-red-600">{t("authorities.loadError")}</li>
)}
{!isLoading && !isError && authorities?.length === 0 && (
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li>
)}
{authorities?.map((a) => (
+9 -1
View File
@@ -33,7 +33,7 @@ test("lists authorities for the kind and creates one", async () => {
test("kind tabs link to the other kinds", async () => {
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
expect(await screen.findByRole("tab", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
});
test("create without EN label shows required alert and does not POST", async () => {
@@ -51,6 +51,14 @@ test("create without EN label shows required alert and does not POST", async ()
expect(posted).toBe(false);
});
test("authorities endpoint error shows loadError", async () => {
server.use(
http.get("/api/admin/authorities", () => new HttpResponse(null, { status: 500 })),
);
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
});
test("unknown kind redirects to person list", async () => {
renderApp(tree(), { route: "/authorities/banana" });
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
+21
View File
@@ -43,6 +43,27 @@ test("creates a text field — posts the body and clears the key input", async (
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue(""));
});
test("selecting Authority reveals the kind picker and posts the chosen kind", async () => {
let body: { authority_kind: string | null } | undefined;
server.use(
http.post("/api/admin/field-definitions", async ({ request }) => {
body = (await request.json()) as { authority_kind: string | null };
return HttpResponse.json({ key: "maker" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/fields" });
await userEvent.type(screen.getByLabelText(/^key$/i), "maker");
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Maker");
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority");
const kind = await screen.findByLabelText(/authority kind/i);
await userEvent.selectOptions(kind, "person");
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
await waitFor(() => expect(body?.authority_kind).toBe("person"));
});
test("selecting Term reveals the vocabulary picker and blocks submit until chosen", async () => {
let posted = false;
+3 -2
View File
@@ -9,13 +9,13 @@
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" },
"vocab": {
"title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key",
"newVocabulary": "New vocabulary", "key": "Key",
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
"terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet",
"noTerms": "No terms yet", "loadError": "Could not load"
},
"authorities": {
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
"person": "Person", "organisation": "Organisation", "place": "Place",
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
},
"search": {
@@ -24,6 +24,7 @@
"prompt": "Type to search",
"empty": "No results",
"loadError": "Search is unavailable",
"unavailable": "Search is not available on this server",
"loadMore": "Load more",
"resultCount_one": "{{count}} result",
"resultCount_other": "{{count}} results",
+3 -2
View File
@@ -9,13 +9,13 @@
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" },
"vocab": {
"title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel",
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
"terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu",
"noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda"
},
"authorities": {
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
"person": "Person", "organisation": "Organisation", "place": "Plats",
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
},
"search": {
@@ -24,6 +24,7 @@
"prompt": "Skriv för att söka",
"empty": "Inga träffar",
"loadError": "Sök är inte tillgängligt",
"unavailable": "Sök är inte tillgängligt på den här servern",
"loadMore": "Visa fler",
"resultCount_one": "{{count}} träff",
"resultCount_other": "{{count}} träffar",
+6 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSearch } from "../api/queries";
import { useSearch, HttpError } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { SearchResultRow } from "./search-result-row";
import { Button } from "@/components/ui/button";
@@ -92,7 +92,11 @@ export function SearchPanel() {
)}
{hasQuery && search.isError && (
<p className="p-4 text-sm text-red-600">{t("search.loadError")}</p>
<p className="p-4 text-sm text-red-600">
{search.error instanceof HttpError && search.error.status === 503
? t("search.unavailable")
: t("search.loadError")}
</p>
)}
{hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
+14
View File
@@ -76,6 +76,20 @@ test("clicking a result shows the object in the detail pane", async () => {
expect(await screen.findByText(amphora.object_name)).toBeInTheDocument();
});
test("a 503 shows the search-unavailable message", async () => {
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })));
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText(/not available on this server/i)).toBeInTheDocument();
});
test("a 500 shows the generic search error", async () => {
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 500 })));
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText(/^search is unavailable$/i)).toBeInTheDocument();
});
test("hydrates query and visibility from the initial URL", async () => {
renderApp(tree(), { route: "/search?q=bronze" });
+8
View File
@@ -52,6 +52,14 @@ test("selecting a vocabulary shows its terms and adds one", async () => {
);
});
test("terms endpoint error shows vocab loadError", async () => {
server.use(
http.get("/api/admin/vocabularies/:id/terms", () => new HttpResponse(null, { status: 500 })),
);
renderApp(tree(), { route: "/vocabularies/v-material" });
expect(await screen.findByText(/could not load/i)).toBeInTheDocument();
});
test("add term without EN label shows required alert and does not POST", async () => {
let posted = false;
server.use(
+8 -2
View File
@@ -19,7 +19,7 @@ export function VocabularyTerms() {
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const { data: terms } = useTerms(id);
const { data: terms, isLoading, isError } = useTerms(id);
const addTerm = useAddTerm();
@@ -53,7 +53,13 @@ export function VocabularyTerms() {
{t("vocab.terms")}
</h3>
<ul className="mb-4">
{terms?.length === 0 && (
{isLoading && (
<li className="text-sm text-neutral-400"></li>
)}
{isError && (
<li className="text-sm text-red-600">{t("vocab.loadError")}</li>
)}
{!isLoading && !isError && terms?.length === 0 && (
<li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>
)}
{terms?.map((term) => (