feat(web): useSearch infinite query + useDebouncedValue + MSW search handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:29:11 +02:00
parent 90a1539090
commit 18ed9bd947
6 changed files with 133 additions and 2 deletions
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { useSearch } from "./queries";
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
test("useSearch fetches a page and reports more pages available", async () => {
const { result } = renderHook(() => useSearch("bronze", null), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
const first = result.current.data!.pages[0];
expect(first.hits[0].object_name).toBe("Bronze figurine");
expect(first.estimated_total).toBe(25);
expect(result.current.hasNextPage).toBe(true);
});
test("useSearch is disabled for an empty query", () => {
const { result } = renderHook(() => useSearch(" ", null), { wrapper });
expect(result.current.fetchStatus).toBe("idle");
});
+34 -1
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client";
import type { components } from "./schema";
@@ -281,6 +281,39 @@ export function useCreateAuthority() {
});
}
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 } = await api.GET("/api/admin/search", {
params: {
query: {
q: term,
...(visibility ? { visibility } : {}),
offset: pageParam,
limit: SEARCH_PAGE,
},
},
});
if (error || !data) throw new Error("search failed");
return data;
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, page) => n + page.hits.length, 0);
return loaded < lastPage.estimated_total ? loaded : undefined;
},
});
}
type Visibility = "draft" | "internal" | "public";
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */