diff --git a/web/src/api/queries.search.test.tsx b/web/src/api/queries.search.test.tsx
new file mode 100644
index 0000000..f005861
--- /dev/null
+++ b/web/src/api/queries.search.test.tsx
@@ -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 {children};
+}
+
+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");
+});
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index a23651c..69cf773 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -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. */
diff --git a/web/src/lib/use-debounced-value.test.tsx b/web/src/lib/use-debounced-value.test.tsx
new file mode 100644
index 0000000..2b715df
--- /dev/null
+++ b/web/src/lib/use-debounced-value.test.tsx
@@ -0,0 +1,24 @@
+import { useState } from "react";
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderApp } from "../test/render";
+import { useDebouncedValue } from "./use-debounced-value";
+
+function Harness() {
+ const [text, setText] = useState("");
+ const debounced = useDebouncedValue(text, 150);
+ return (
+
+ setText(e.target.value)} />
+ {debounced}
+
+ );
+}
+
+test("reflects the value after the delay", async () => {
+ renderApp();
+ await userEvent.type(screen.getByLabelText("in"), "bronze");
+ await screen.findByText("bronze");
+ expect(screen.getByTestId("out")).toHaveTextContent("bronze");
+});
diff --git a/web/src/lib/use-debounced-value.ts b/web/src/lib/use-debounced-value.ts
new file mode 100644
index 0000000..ffc1345
--- /dev/null
+++ b/web/src/lib/use-debounced-value.ts
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+/** Returns `value` delayed by `delayMs`; resets the timer on each change. */
+export function useDebouncedValue(value: T, delayMs: number): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delayMs);
+
+ return () => clearTimeout(id);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
index a53fdad..2c10b42 100644
--- a/web/src/test/fixtures.ts
+++ b/web/src/test/fixtures.ts
@@ -63,6 +63,27 @@ export const personAuthorities: AuthorityView[] = [
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
];
+export type SearchHitView = components["schemas"]["SearchHitView"];
+
+export const searchHits: SearchHitView[] = [
+ {
+ id: amphora.id,
+ object_number: "2019.4.12",
+ object_name: "Bronze figurine",
+ brief_description: "A small cast figure.",
+ visibility: "public",
+ snippet: "cast bronze with green patina",
+ },
+ ...Array.from({ length: 24 }, (_, i) => ({
+ id: `s-${i + 2}`,
+ object_number: `N-${i + 2}`,
+ object_name: `Object ${i + 2}`,
+ brief_description: null,
+ visibility: "internal",
+ snippet: null,
+ })),
+];
+
export type VocabularyView = components["schemas"]["VocabularyView"];
export const vocabularies: VocabularyView[] = [
diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts
index db0599f..81d0d89 100644
--- a/web/src/test/handlers.ts
+++ b/web/src/test/handlers.ts
@@ -1,6 +1,6 @@
import { http, HttpResponse } from "msw";
-import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, vocabularies } from "./fixtures";
+import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
export const handlers = [
http.get("/api/admin/me", () =>
@@ -49,6 +49,20 @@ export const handlers = [
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
+ http.get("/api/admin/search", ({ request }) => {
+ const url = new URL(request.url);
+ const q = (url.searchParams.get("q") ?? "").trim();
+ const offset = Number(url.searchParams.get("offset") ?? 0);
+ const limit = Number(url.searchParams.get("limit") ?? 20);
+
+ if (!q) return HttpResponse.json({ hits: [], estimated_total: 0 });
+
+ return HttpResponse.json({
+ hits: searchHits.slice(offset, offset + limit),
+ estimated_total: searchHits.length,
+ });
+ }),
+
http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),