feat(web): useSearch infinite query + useDebouncedValue + MSW search handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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 { api } from "./client";
|
||||||
import type { components } from "./schema";
|
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";
|
type Visibility = "draft" | "internal" | "public";
|
||||||
|
|
||||||
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<input aria-label="in" value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
<span data-testid="out">{debounced}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reflects the value after the delay", async () => {
|
||||||
|
renderApp(<Harness />);
|
||||||
|
await userEvent.type(screen.getByLabelText("in"), "bronze");
|
||||||
|
await screen.findByText("bronze");
|
||||||
|
expect(screen.getByTestId("out")).toHaveTextContent("bronze");
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Returns `value` delayed by `delayMs`; resets the timer on each change. */
|
||||||
|
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
@@ -63,6 +63,27 @@ export const personAuthorities: AuthorityView[] = [
|
|||||||
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
|
{ 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 type VocabularyView = components["schemas"]["VocabularyView"];
|
||||||
|
|
||||||
export const vocabularies: VocabularyView[] = [
|
export const vocabularies: VocabularyView[] = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
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 = [
|
export const handlers = [
|
||||||
http.get("/api/admin/me", () =>
|
http.get("/api/admin/me", () =>
|
||||||
@@ -49,6 +49,20 @@ export const handlers = [
|
|||||||
|
|
||||||
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
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/login", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user