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