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:
+36
-38
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user