diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index d0a37ba..d26cbca 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,17 +1,19 @@ 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: ["me"], + queryKey: keys.me(), queryFn: async (): Promise => { const { data, response } = await api.GET("/api/admin/me"); @@ -25,18 +27,9 @@ export function useMe() { }); } -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], + queryKey: keys.objectsPage(params), placeholderData: keepPreviousData, queryFn: async () => { const { data, error } = await api.GET("/api/admin/objects", { @@ -61,7 +54,7 @@ export function useObjectsPage(params: ObjectListParams) { export function useObject(id: string) { return useQuery({ - queryKey: ["object", id], + queryKey: keys.object(id), queryFn: async () => { const { data, response } = await api.GET("/api/admin/objects/{id}", { params: { path: { id } }, @@ -80,7 +73,7 @@ export function useObject(id: string) { export function useFieldDefinitions() { return useQuery({ - queryKey: ["field-definitions"], + queryKey: keys.fieldDefinitions(), queryFn: async () => { const { data, error } = await api.GET("/api/admin/field-definitions"); @@ -103,7 +96,7 @@ export function useLogin() { throw new Error(response.status === 401 ? "invalid" : "network"); } }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }), meta: { suppressErrorToast: true }, }); } @@ -115,7 +108,7 @@ export function useLogout() { mutationFn: async () => { await api.POST("/api/admin/logout"); }, - onSuccess: () => qc.setQueryData(["me"], null), + onSuccess: () => qc.setQueryData(keys.me(), null), }); } @@ -124,7 +117,7 @@ type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; export function useTerms(vocabularyId: string | null | undefined) { return useQuery({ - queryKey: ["terms", vocabularyId], + queryKey: keys.terms(vocabularyId), enabled: !!vocabularyId, queryFn: async () => { const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { @@ -141,7 +134,7 @@ export function useTerms(vocabularyId: string | null | undefined) { export function useAuthorities(kind: string | null | undefined) { return useQuery({ - queryKey: ["authorities", kind], + queryKey: keys.authorities(kind), enabled: !!kind, queryFn: async () => { const { data, error } = await api.GET("/api/admin/authorities", { @@ -167,7 +160,7 @@ export function useCreateObject() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -185,8 +178,9 @@ export function useUpdateObject() { if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: ["objects"] }); - void qc.invalidateQueries({ queryKey: ["object", 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 }, }); @@ -212,7 +206,7 @@ export function useSetFields() { throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: ["object", id] }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); }, meta: { suppressErrorToast: true }, }); @@ -229,7 +223,10 @@ export function useDeleteObject() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -239,7 +236,7 @@ type LabelInput = components["schemas"]["LabelInput"]; export function useVocabularies() { return useQuery({ - queryKey: ["vocabularies"], + queryKey: keys.vocabularies(), queryFn: async () => { const { data, error } = await api.GET("/api/admin/vocabularies"); @@ -262,7 +259,7 @@ export function useCreateVocabulary() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -288,7 +285,7 @@ export function useAddTerm() { if (response.status !== 201) throw new HttpError(response.status); }, onSuccess: (_result, { vocabularyId }) => - qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -313,7 +310,7 @@ export function useCreateAuthority() { if (response.status !== 201) throw new HttpError(response.status); }, onSuccess: (_result, { kind }) => - qc.invalidateQueries({ queryKey: ["authorities", kind] }), + qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -324,7 +321,7 @@ export function useSearch(q: string, visibility: string | null) { const term = q.trim(); return useInfiniteQuery({ - queryKey: ["search", term, visibility], + queryKey: keys.searchResults(term, visibility), enabled: term.length > 0, initialPageParam: 0, queryFn: async ({ pageParam }) => { @@ -365,7 +362,7 @@ export function useCreateFieldDefinition() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -385,8 +382,9 @@ export function useSetVisibility() { if (response.status !== 204) throw new VisibilityError(response.status); }, onSuccess: (_result, { id }) => { - void qc.invalidateQueries({ queryKey: ["object", id] }); - void qc.invalidateQueries({ queryKey: ["objects"] }); + 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 }, }); @@ -414,7 +412,7 @@ export function useUpdateTerm() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -431,7 +429,7 @@ export function useDeleteTerm() { 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] }), + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -448,7 +446,7 @@ export function useRenameVocabulary() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.renamed", suppressErrorToast: true }, }); } @@ -465,7 +463,7 @@ export function useDeleteVocabulary() { 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"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -491,7 +489,7 @@ export function useUpdateAuthority() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -508,7 +506,7 @@ export function useDeleteAuthority() { 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] }), + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -535,7 +533,7 @@ export function useUpdateFieldDefinition() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -552,7 +550,7 @@ export function useDeleteFieldDefinition() { 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"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } diff --git a/web/src/api/query-keys.test.ts b/web/src/api/query-keys.test.ts new file mode 100644 index 0000000..ddc6c09 --- /dev/null +++ b/web/src/api/query-keys.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; + +import { keys } from "./query-keys"; + +test("the key factory produces the expected arrays", () => { + expect(keys.me()).toEqual(["me"]); + expect(keys.config()).toEqual(["config"]); + expect(keys.objects()).toEqual(["objects"]); + const p = { limit: 50, offset: 0 }; + expect(keys.objectsPage(p)).toEqual(["objects", p]); + expect(keys.object("x")).toEqual(["object", "x"]); + expect(keys.fieldDefinitions()).toEqual(["field-definitions"]); + expect(keys.vocabularies()).toEqual(["vocabularies"]); + expect(keys.terms("v1")).toEqual(["terms", "v1"]); + expect(keys.authorities("person")).toEqual(["authorities", "person"]); + expect(keys.search()).toEqual(["search"]); + expect(keys.searchResults("q", null)).toEqual(["search", "q", null]); +}); + +test("objects() is a prefix of objectsPage() so invalidation matches", () => { + const prefix = keys.objects(); + const full = keys.objectsPage({ limit: 50, offset: 0 }); + expect(full.slice(0, prefix.length)).toEqual(prefix); +}); diff --git a/web/src/api/query-keys.ts b/web/src/api/query-keys.ts new file mode 100644 index 0000000..b1fec78 --- /dev/null +++ b/web/src/api/query-keys.ts @@ -0,0 +1,24 @@ +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +/** Central query-key factory — the single source of truth for cache keys, so + * query/invalidate/setQueryData sites can't drift. */ +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, + objectsPage: (params: ObjectListParams) => ["objects", params] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string | null | undefined) => ["terms", vocabularyId] as const, + authorities: (kind: string | null | undefined) => ["authorities", kind] as const, + search: () => ["search"] as const, + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; diff --git a/web/src/api/search-invalidation.test.tsx b/web/src/api/search-invalidation.test.tsx new file mode 100644 index 0000000..7c7dbc9 --- /dev/null +++ b/web/src/api/search-invalidation.test.tsx @@ -0,0 +1,28 @@ +import { 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 { useSetVisibility } from "./queries"; +import { keys } from "./query-keys"; + +test("changing an object's visibility invalidates the active search query", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(keys.searchResults("amphora", null), { pages: [], pageParams: [] }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + + await result.current.mutateAsync({ id: "o1", visibility: "public" }); + + await waitFor(() => + expect(qc.getQueryState(keys.searchResults("amphora", null))?.isInvalidated).toBe(true), + ); +}); diff --git a/web/src/config/config-provider.tsx b/web/src/config/config-provider.tsx index 8573294..dc6a668 100644 --- a/web/src/config/config-provider.tsx +++ b/web/src/config/config-provider.tsx @@ -2,12 +2,13 @@ import { useEffect, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { api } from "../api/client"; +import { keys } from "../api/query-keys"; import i18n, { LOCALE_KEY } from "../i18n"; import { ConfigContext, DEFAULTS, type ConfigView } from "./config-context"; export function ConfigProvider({ children }: { children: ReactNode }) { const { data } = useQuery({ - queryKey: ["config"], + queryKey: keys.config(), queryFn: async (): Promise => { const { data, error } = await api.GET("/api/config");