From d8d80358505285a43dcc9b715793faea214b4d16 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 21:35:02 +0200 Subject: [PATCH] refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65) --- web/src/api/queries.ts | 556 ----------------------------- web/src/api/queries/auth.ts | 51 +++ web/src/api/queries/authorities.ts | 93 +++++ web/src/api/queries/field-defs.ts | 83 +++++ web/src/api/queries/index.ts | 8 + web/src/api/queries/objects.ts | 157 ++++++++ web/src/api/queries/search.ts | 39 ++ web/src/api/queries/vocab.ts | 160 +++++++++ 8 files changed, 591 insertions(+), 556 deletions(-) delete mode 100644 web/src/api/queries.ts create mode 100644 web/src/api/queries/auth.ts create mode 100644 web/src/api/queries/authorities.ts create mode 100644 web/src/api/queries/field-defs.ts create mode 100644 web/src/api/queries/index.ts create mode 100644 web/src/api/queries/objects.ts create mode 100644 web/src/api/queries/search.ts create mode 100644 web/src/api/queries/vocab.ts diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts deleted file mode 100644 index d26cbca..0000000 --- a/web/src/api/queries.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { api } from "./client"; -import { keys, type ObjectListParams } from "./query-keys"; -import type { components } from "./schema"; -import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; - -export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; -export type { ObjectListParams } from "./query-keys"; - -type UserView = components["schemas"]["UserView"]; -type LoginRequest = components["schemas"]["LoginRequest"]; - -export function useMe() { - return useQuery({ - queryKey: keys.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 function useObjectsPage(params: ObjectListParams) { - return useQuery({ - queryKey: keys.objectsPage(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: keys.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: keys.fieldDefinitions(), - 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: keys.me() }), - meta: { suppressErrorToast: true }, - }); -} - -export function useLogout() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - await api.POST("/api/admin/logout"); - }, - onSuccess: () => qc.setQueryData(keys.me(), null), - }); -} - -type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; -type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; - -export function useTerms(vocabularyId: string | null | undefined) { - return useQuery({ - queryKey: keys.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: keys.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: keys.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: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.object(id) }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - 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: keys.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: () => { - void qc.invalidateQueries({ queryKey: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} - -type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; -type LabelInput = components["schemas"]["LabelInput"]; - -export function useVocabularies() { - return useQuery({ - queryKey: keys.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: keys.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: keys.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: keys.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: keys.searchResults(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: keys.fieldDefinitions() }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -type Visibility = "draft" | "internal" | "public"; - -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: keys.object(id) }); - void qc.invalidateQueries({ queryKey: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - 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: keys.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: keys.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: keys.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: keys.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: keys.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: keys.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: keys.fieldDefinitions() }), - 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: keys.fieldDefinitions() }), - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} diff --git a/web/src/api/queries/auth.ts b/web/src/api/queries/auth.ts new file mode 100644 index 0000000..45c612a --- /dev/null +++ b/web/src/api/queries/auth.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { keys } from "../query-keys"; + +type UserView = components["schemas"]["UserView"]; +type LoginRequest = components["schemas"]["LoginRequest"]; + +export function useMe() { + return useQuery({ + queryKey: keys.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 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: keys.me() }), + meta: { suppressErrorToast: true }, + }); +} + +export function useLogout() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + await api.POST("/api/admin/logout"); + }, + onSuccess: () => qc.setQueryData(keys.me(), null), + }); +} diff --git a/web/src/api/queries/authorities.ts b/web/src/api/queries/authorities.ts new file mode 100644 index 0000000..cce2bff --- /dev/null +++ b/web/src/api/queries/authorities.ts @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type LabelInput = components["schemas"]["LabelInput"]; + +export function useAuthorities(kind: string | null | undefined) { + return useQuery({ + queryKey: keys.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 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: keys.authorities(kind) }), + meta: { successMessage: "toast.created", 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: keys.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: keys.authorities(kind) }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/field-defs.ts b/web/src/api/queries/field-defs.ts new file mode 100644 index 0000000..8bd7b5f --- /dev/null +++ b/web/src/api/queries/field-defs.ts @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function useFieldDefinitions() { + return useQuery({ + queryKey: keys.fieldDefinitions(), + 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 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: keys.fieldDefinitions() }), + meta: { successMessage: "toast.created", 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: keys.fieldDefinitions() }), + 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: keys.fieldDefinitions() }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/index.ts b/web/src/api/queries/index.ts new file mode 100644 index 0000000..4f71f3d --- /dev/null +++ b/web/src/api/queries/index.ts @@ -0,0 +1,8 @@ +export * from "./auth"; +export * from "./objects"; +export * from "./field-defs"; +export * from "./vocab"; +export * from "./authorities"; +export * from "./search"; +export * from "../errors"; +export type { ObjectListParams } from "../query-keys"; diff --git a/web/src/api/queries/objects.ts b/web/src/api/queries/objects.ts new file mode 100644 index 0000000..024e11d --- /dev/null +++ b/web/src/api/queries/objects.ts @@ -0,0 +1,157 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, FieldRejection, VisibilityError } from "../errors"; +import { keys, type ObjectListParams } from "../query-keys"; + +type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; +type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; +type Visibility = "draft" | "internal" | "public"; + +export function useObjectsPage(params: ObjectListParams) { + return useQuery({ + queryKey: keys.objectsPage(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: keys.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 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: keys.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: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + 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: keys.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: () => { + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} + +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: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + meta: { successMessage: "toast.published", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/search.ts b/web/src/api/queries/search.ts new file mode 100644 index 0000000..76e9ecc --- /dev/null +++ b/web/src/api/queries/search.ts @@ -0,0 +1,39 @@ +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; + +import { api } from "../client"; +import { HttpError } from "../errors"; +import { keys } from "../query-keys"; + +const SEARCH_PAGE = 20; + +export function useSearch(q: string, visibility: string | null) { + const term = q.trim(); + + return useInfiniteQuery({ + queryKey: keys.searchResults(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; + }, + }); +} diff --git a/web/src/api/queries/vocab.ts b/web/src/api/queries/vocab.ts new file mode 100644 index 0000000..72db7f7 --- /dev/null +++ b/web/src/api/queries/vocab.ts @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function useVocabularies() { + return useQuery({ + queryKey: keys.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: keys.vocabularies() }), + meta: { successMessage: "toast.created", 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: keys.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: keys.vocabularies() }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} + +export function useTerms(vocabularyId: string | null | undefined) { + return useQuery({ + queryKey: keys.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 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: keys.terms(vocabularyId) }), + meta: { successMessage: "toast.created", 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: keys.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: keys.terms(vocabularyId) }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +}