From 6afc3583342b072b324ea96d5bc5efe4a42e2c18 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:08:15 +0200 Subject: [PATCH] feat(web): vocabulary/term/authority list+create hooks + MSW handlers Co-Authored-By: Claude Sonnet 4.6 --- web/src/api/queries.ts | 81 ++++++++++++++++++++++++++++++ web/src/api/queries.vocab.test.tsx | 50 ++++++++++++++++++ web/src/test/fixtures.ts | 7 +++ web/src/test/handlers.ts | 16 +++++- 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 web/src/api/queries.vocab.test.tsx diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index b777b8a..a23651c 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -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. */ diff --git a/web/src/api/queries.vocab.test.tsx b/web/src/api/queries.vocab.test.tsx new file mode 100644 index 0000000..3d7299b --- /dev/null +++ b/web/src/api/queries.vocab.test.tsx @@ -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 {children}; +} + +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"); + }); +}); diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index d6a650f..a53fdad 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -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" }, +]; diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index dce24de..db0599f 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -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 }), ),