feat(web): vocabulary/term/authority list+create hooks + MSW handlers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 09:08:15 +02:00
parent 26e10704a9
commit 6afc358334
4 changed files with 153 additions and 1 deletions
+81
View File
@@ -200,6 +200,87 @@ export function useDeleteObject() {
});
}
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
type LabelInput = components["schemas"]["LabelInput"];
export function useVocabularies() {
return useQuery({
queryKey: ["vocabularies"],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies");
if (error || !data) throw new Error("failed to load vocabularies");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => {
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
if (error || !data) throw new Error("create vocabulary failed");
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useAddTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
external_uri,
labels,
}: {
vocabularyId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId } },
body: { external_uri, labels },
});
if (response.status !== 201) throw new Error("add term failed");
},
onSuccess: (_result, { vocabularyId }) =>
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useCreateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
kind,
external_uri,
labels,
}: {
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/authorities", {
body: { kind, external_uri, labels },
});
if (response.status !== 201) throw new Error("create authority failed");
},
onSuccess: (_result, { kind }) =>
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
type Visibility = "draft" | "internal" | "public";
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "../test/server";
import { useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority } from "./queries";
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("vocab/authority hooks", () => {
test("useVocabularies lists vocabularies", async () => {
const { result } = renderHook(() => useVocabularies(), { wrapper });
await waitFor(() => expect(result.current.data?.length).toBe(2));
expect(result.current.data?.[0].key).toBe("material");
});
test("useCreateVocabulary POSTs the key", async () => {
let body: unknown;
server.use(http.post("/api/admin/vocabularies", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "v-x", key: "colour" }, { status: 201 });
}));
const { result } = renderHook(() => useCreateVocabulary(), { wrapper });
await result.current.mutateAsync({ key: "colour" });
expect((body as { key: string }).key).toBe("colour");
});
test("useAddTerm POSTs labels to the vocabulary", async () => {
let body: unknown;
server.use(http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "t-x" }, { status: 201 });
}));
const { result } = renderHook(() => useAddTerm(), { wrapper });
await result.current.mutateAsync({ vocabularyId: "v-material", external_uri: null, labels: [{ lang: "en", label: "Red" }] });
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Red");
});
test("useCreateAuthority POSTs kind + labels", async () => {
let body: unknown;
server.use(http.post("/api/admin/authorities", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "a-x" }, { status: 201 });
}));
const { result } = renderHook(() => useCreateAuthority(), { wrapper });
await result.current.mutateAsync({ kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada" }] });
expect((body as { kind: string }).kind).toBe("person");
});
});
+7
View File
@@ -62,3 +62,10 @@ export const materialTerms: TermView[] = [
export const personAuthorities: AuthorityView[] = [
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
];
export type VocabularyView = components["schemas"]["VocabularyView"];
export const vocabularies: VocabularyView[] = [
{ id: "v-material", key: "material" },
{ id: "v-technique", key: "technique" },
];
+15 -1
View File
@@ -1,6 +1,6 @@
import { http, HttpResponse } from "msw";
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities } from "./fixtures";
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, vocabularies } from "./fixtures";
export const handlers = [
http.get("/api/admin/me", () =>
@@ -17,6 +17,8 @@ export const handlers = [
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
http.get("/api/admin/authorities", ({ request }) => {
@@ -25,6 +27,18 @@ export const handlers = [
return HttpResponse.json(kind === "person" ? personAuthorities : []);
}),
http.post("/api/admin/vocabularies", () =>
HttpResponse.json({ id: "v-new", key: "new" }, { status: 201 }),
),
http.post("/api/admin/vocabularies/:id/terms", () =>
HttpResponse.json({ id: "t-new" }, { status: 201 }),
),
http.post("/api/admin/authorities", () =>
HttpResponse.json({ id: "a-new" }, { status: 201 }),
),
http.post("/api/admin/objects", () =>
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }),
),