From 18ed9bd94707e47b46388fd13ae1b1fb2dcf89b9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:29:11 +0200 Subject: [PATCH] feat(web): useSearch infinite query + useDebouncedValue + MSW search handler Co-Authored-By: Claude Sonnet 4.6 --- web/src/api/queries.search.test.tsx | 25 +++++++++++++++++ web/src/api/queries.ts | 35 +++++++++++++++++++++++- web/src/lib/use-debounced-value.test.tsx | 24 ++++++++++++++++ web/src/lib/use-debounced-value.ts | 14 ++++++++++ web/src/test/fixtures.ts | 21 ++++++++++++++ web/src/test/handlers.ts | 16 ++++++++++- 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 web/src/api/queries.search.test.tsx create mode 100644 web/src/lib/use-debounced-value.test.tsx create mode 100644 web/src/lib/use-debounced-value.ts 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 })),