feat(web): /search two-pane screen (debounced query, visibility filter, load more) + nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:40:46 +02:00
parent ee65b27595
commit 358d793e44
9 changed files with 270 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import { SearchPanel } from "./search-panel";
export function SearchPage() {
return (
<div className="grid h-full grid-cols-[24rem_1fr]">
<div className="overflow-hidden border-r">
<SearchPanel />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSearch } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { SearchResultRow } from "./search-result-row";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
const VIS = ["all", "draft", "internal", "public"] as const;
export function SearchPanel() {
const { t } = useTranslation();
const [params, setParams] = useSearchParams();
const [text, setText] = useState(() => params.get("q") ?? "");
const visibility = params.get("visibility"); // null == "all"
const debounced = useDebouncedValue(text, 300);
useEffect(() => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
const term = debounced.trim();
if (term) next.set("q", term);
else next.delete("q");
return next;
},
{ replace: true },
);
}, [debounced, setParams]);
const search = useSearch(debounced, visibility);
const setVisibility = (value: string) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === "all") next.delete("visibility");
else next.set("visibility", value);
return next;
},
{ replace: true },
);
const hits = search.data?.pages.flatMap((page) => page.hits) ?? [];
const total = search.data?.pages[0]?.estimated_total ?? 0;
const hasQuery = debounced.trim().length > 0;
return (
<div className="flex h-full flex-col">
<div className="space-y-2 border-b p-3">
<Input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
/>
<div className="flex gap-1 text-xs">
{VIS.map((value) => {
const active = (visibility ?? "all") === value;
return (
<button
key={value}
type="button"
onClick={() => setVisibility(value)}
className={`rounded px-2 py-0.5 ${active ? "bg-indigo-600 text-white" : "border"}`}
>
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button>
);
})}
</div>
</div>
<div className="flex-1 overflow-auto">
{!hasQuery && <p className="p-4 text-sm text-neutral-400">{t("search.prompt")}</p>}
{hasQuery && search.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{hasQuery && search.isError && (
<p className="p-4 text-sm text-red-600">{t("search.loadError")}</p>
)}
{hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
<p className="p-4 text-sm text-neutral-500">{t("search.empty")}</p>
)}
{hits.length > 0 && (
<>
<p className="px-3 pt-2 text-xs text-neutral-500">
{t("search.resultCount", { count: total })}
</p>
<ul>
{hits.map((hit) => (
<SearchResultRow key={hit.id} hit={hit} />
))}
</ul>
{search.hasNextPage && (
<div className="p-3 text-center">
<Button
variant="ghost"
size="sm"
disabled={search.isFetchingNextPage}
onClick={() => search.fetchNextPage()}
>
{t("search.loadMore")}
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Route, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { amphora } from "../test/fixtures";
import { SearchPage } from "./search-page";
import { SelectSearchPrompt } from "./select-search-prompt";
import { ObjectDetail } from "../objects/object-detail";
function tree() {
return (
<Routes>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
test("typing searches and renders highlighted rich rows", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
const mark = await screen.findByText("bronze");
expect(mark.tagName).toBe("MARK");
expect(screen.getByText(/25 results/i)).toBeInTheDocument();
});
test("Load more appends the next page", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await screen.findByText("Bronze figurine");
expect(screen.queryByText("Object 21")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: /load more/i }));
expect(await screen.findByText("Object 21")).toBeInTheDocument();
});
test("the visibility filter adds the param to the request", async () => {
let lastVisibility: string | null = "unset";
server.use(
http.get("/api/admin/search", ({ request }) => {
lastVisibility = new URL(request.url).searchParams.get("visibility");
return HttpResponse.json({ hits: [], estimated_total: 0 });
}),
);
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
await waitFor(() => expect(lastVisibility).toBe("draft"));
});
test("empty query shows the prompt; zero results shows empty", async () => {
renderApp(tree(), { route: "/search" });
expect(screen.getByText(/type to search/i)).toBeInTheDocument();
server.use(
http.get("/api/admin/search", () => HttpResponse.json({ hits: [], estimated_total: 0 })),
);
await userEvent.type(screen.getByLabelText(/search the collection/i), "zzz");
expect(await screen.findByText(/no results/i)).toBeInTheDocument();
});
test("clicking a result shows the object in the detail pane", async () => {
renderApp(tree(), { route: "/search" });
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
await userEvent.click(await screen.findByText("Bronze figurine"));
expect(await screen.findByText(amphora.object_name)).toBeInTheDocument();
});
+11
View File
@@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export function SelectSearchPrompt() {
const { t } = useTranslation();
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("search.selectPrompt")}
</div>
);
}