refactor(web): central query-key factory + invalidate search on object writes (#65)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:30:57 +02:00
parent c1bddb47c4
commit 704b159d48
5 changed files with 114 additions and 39 deletions
+36 -38
View File
@@ -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<UserView | null> => {
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 },
});
}
+24
View File
@@ -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);
});
+24
View File
@@ -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,
};
+28
View File
@@ -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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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),
);
});
+2 -1
View File
@@ -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<ConfigView> => {
const { data, error } = await api.GET("/api/config");