import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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"; } } export class FieldRejection extends Error { constructor(public readonly field: string, public readonly code: string) { super(`field rejected: ${field}`); this.name = "FieldRejection"; } } export class InUseError extends Error { constructor(public readonly count: number) { super(`in use: ${count}`); this.name = "InUseError"; } } type UserView = components["schemas"]["UserView"]; type LoginRequest = components["schemas"]["LoginRequest"]; export function useMe() { return useQuery({ queryKey: ["me"], queryFn: async (): Promise => { const { data, response } = await api.GET("/api/admin/me"); if (response.status === 401) return null; if (!data) throw new Error("failed to load session"); return data; }, retry: false, }); } export type ObjectListParams = { limit: number; offset: number; sort?: string; order?: "asc" | "desc"; visibility?: string; q?: string; }; export function useObjectsPage(params: ObjectListParams) { return useQuery({ queryKey: ["objects", params], placeholderData: keepPreviousData, queryFn: async () => { const { data, error } = await api.GET("/api/admin/objects", { params: { query: { limit: params.limit, offset: params.offset, sort: params.sort, order: params.order, visibility: params.visibility, q: params.q, }, }, }); if (error || !data) throw new Error("failed to load objects"); return data; }, }); } export function useObject(id: string) { return useQuery({ queryKey: ["object", id], queryFn: async () => { const { data, response } = await api.GET("/api/admin/objects/{id}", { params: { path: { id } }, }); if (response.status === 404) return null; if (!data) throw new Error("failed to load object"); return data; }, // A 404 resolves to null rather than erroring, so don't retry it. retry: false, }); } export function useFieldDefinitions() { return useQuery({ queryKey: ["field-definitions"], queryFn: async () => { const { data, error } = await api.GET("/api/admin/field-definitions"); if (error || !data) throw new Error("failed to load field definitions"); return data; }, staleTime: 5 * 60 * 1000, }); } export function useLogin() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: LoginRequest) => { const { response } = await api.POST("/api/admin/login", { body }); if (response.status !== 204) { throw new Error(response.status === 401 ? "invalid" : "network"); } }, onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), meta: { suppressErrorToast: true }, }); } export function useLogout() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { await api.POST("/api/admin/logout"); }, onSuccess: () => qc.setQueryData(["me"], null), }); } type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; export function useTerms(vocabularyId: string | null | undefined) { return useQuery({ queryKey: ["terms", vocabularyId], enabled: !!vocabularyId, queryFn: async () => { const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { params: { path: { id: vocabularyId! } }, }); if (error || !data) throw new Error("failed to load terms"); return data; }, staleTime: 5 * 60 * 1000, }); } export function useAuthorities(kind: string | null | undefined) { return useQuery({ queryKey: ["authorities", kind], enabled: !!kind, queryFn: async () => { const { data, error } = await api.GET("/api/admin/authorities", { params: { query: { kind: kind! } }, }); if (error || !data) throw new Error("failed to load authorities"); return data; }, staleTime: 5 * 60 * 1000, }); } export function useCreateObject() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: ObjectCreateRequest) => { const { data, error, response } = await api.POST("/api/admin/objects", { body }); if (error || !data) throw new HttpError(response.status); return data; }, onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } export function useUpdateObject() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => { const { response } = await api.PUT("/api/admin/objects/{id}", { params: { path: { id } }, body, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { void qc.invalidateQueries({ queryKey: ["objects"] }); void qc.invalidateQueries({ queryKey: ["object", id] }); }, meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } export function useSetFields() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, fields }: { id: string; fields: Record }) => { const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", { params: { path: { id } }, body: fields as Record, }); if (response.status === 204) return; if (response.status === 422 && error && typeof error === "object" && "field" in error) { const detail = error as { field: string; code: string }; throw new FieldRejection(detail.field, detail.code); } throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { void qc.invalidateQueries({ queryKey: ["object", id] }); }, meta: { suppressErrorToast: true }, }); } export function useDeleteObject() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { const { response } = await api.DELETE("/api/admin/objects/{id}", { params: { path: { id } }, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } 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, response } = await api.POST("/api/admin/vocabularies", { body }); if (error || !data) throw new HttpError(response.status); return data; }, onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } 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 HttpError(response.status); }, onSuccess: (_result, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } 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 HttpError(response.status); }, onSuccess: (_result, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } const SEARCH_PAGE = 20; export function useSearch(q: string, visibility: string | null) { const term = q.trim(); return useInfiniteQuery({ queryKey: ["search", term, visibility], enabled: term.length > 0, initialPageParam: 0, queryFn: async ({ pageParam }) => { const { data, error, response } = await api.GET("/api/admin/search", { params: { query: { q: term, ...(visibility ? { visibility } : {}), offset: pageParam, limit: SEARCH_PAGE, }, }, }); if (error || !data) throw new HttpError(response.status); return data; }, placeholderData: keepPreviousData, getNextPageParam: (lastPage, allPages) => { const loaded = allPages.reduce((n, page) => n + page.hits.length, 0); return loaded < lastPage.estimated_total ? loaded : undefined; }, }); } type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; export function useCreateFieldDefinition() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: NewFieldDefinitionRequest) => { const { data, response } = await api.POST("/api/admin/field-definitions", { body }); if (response.status !== 201 || !data) throw new HttpError(response.status); return data; }, onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } type Visibility = "draft" | "internal" | "public"; /** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ export class VisibilityError extends Error { constructor(public status: number) { super(`visibility change failed (${status})`); this.name = "VisibilityError"; } } export function useSetVisibility() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => { const { response } = await api.POST("/api/admin/objects/{id}/visibility", { params: { path: { id } }, body: { visibility }, }); if (response.status !== 204) throw new VisibilityError(response.status); }, onSuccess: (_result, { id }) => { void qc.invalidateQueries({ queryKey: ["object", id] }); void qc.invalidateQueries({ queryKey: ["objects"] }); }, meta: { successMessage: "toast.published", suppressErrorToast: true }, }); } export function useUpdateTerm() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ vocabularyId, termId, external_uri, labels, }: { vocabularyId: string; termId: string; external_uri: string | null; labels: LabelInput[]; }) => { const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", { params: { path: { id: vocabularyId, term_id: termId } }, body: { external_uri, labels }, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } export function useDeleteTerm() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => { const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", { params: { path: { id: vocabularyId, term_id: termId } }, }); if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } export function useRenameVocabulary() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, key }: { id: string; key: string }) => { const { response } = await api.PATCH("/api/admin/vocabularies/{id}", { params: { path: { id } }, body: { key }, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), meta: { successMessage: "toast.renamed", suppressErrorToast: true }, }); } export function useDeleteVocabulary() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", { params: { path: { id } }, }); if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } export function useUpdateAuthority() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id, external_uri, labels, }: { id: string; kind: string; external_uri: string | null; labels: LabelInput[]; }) => { const { response } = await api.PATCH("/api/admin/authorities/{id}", { params: { path: { id } }, body: { external_uri, labels }, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } export function useDeleteAuthority() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ id }: { id: string; kind: string }) => { const { error, response } = await api.DELETE("/api/admin/authorities/{id}", { params: { path: { id } }, }); if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } export function useUpdateFieldDefinition() { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ key, required, group, labels, }: { key: string; required: boolean; group: string | null; labels: LabelInput[]; }) => { const { response } = await api.PATCH("/api/admin/field-definitions/{key}", { params: { path: { key } }, body: { required, group, labels }, }); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } export function useDeleteFieldDefinition() { const qc = useQueryClient(); return useMutation({ mutationFn: async (key: string) => { const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", { params: { path: { key } }, }); if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); }