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:
@@ -7,6 +7,8 @@ import { AppShell } from "./shell/app-shell";
|
|||||||
import { ObjectsPage } from "./objects/objects-page";
|
import { ObjectsPage } from "./objects/objects-page";
|
||||||
import { ObjectDetail } from "./objects/object-detail";
|
import { ObjectDetail } from "./objects/object-detail";
|
||||||
import { SelectPrompt } from "./objects/select-prompt";
|
import { SelectPrompt } from "./objects/select-prompt";
|
||||||
|
import { SearchPage } from "./search/search-page";
|
||||||
|
import { SelectSearchPrompt } from "./search/select-search-prompt";
|
||||||
import { VocabulariesPage } from "./vocab/vocabularies-page";
|
import { VocabulariesPage } from "./vocab/vocabularies-page";
|
||||||
import { VocabularyTerms } from "./vocab/vocabulary-terms";
|
import { VocabularyTerms } from "./vocab/vocabulary-terms";
|
||||||
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
|
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
|
||||||
@@ -55,6 +57,10 @@ export function App() {
|
|||||||
<Route index element={<SelectVocabularyPrompt />} />
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
<Route path=":id" element={<VocabularyTerms />} />
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/search" element={<SearchPage />}>
|
||||||
|
<Route index element={<SelectSearchPrompt />} />
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
|
</Route>
|
||||||
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
||||||
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
<Route path="/" element={<Navigate to="/objects" replace />} />
|
<Route path="/" element={<Navigate to="/objects" replace />} />
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
||||||
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
|
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search the collection…",
|
||||||
|
"all": "All",
|
||||||
|
"prompt": "Type to search",
|
||||||
|
"empty": "No results",
|
||||||
|
"loadError": "Search is unavailable",
|
||||||
|
"loadMore": "Load more",
|
||||||
|
"resultCount": "{{count}} results",
|
||||||
|
"selectPrompt": "Select a result to see the full record"
|
||||||
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"heading": "Visibility",
|
"heading": "Visibility",
|
||||||
"advanceInternal": "Advance to internal",
|
"advanceInternal": "Advance to internal",
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
||||||
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
|
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Sök i samlingen…",
|
||||||
|
"all": "Alla",
|
||||||
|
"prompt": "Skriv för att söka",
|
||||||
|
"empty": "Inga träffar",
|
||||||
|
"loadError": "Sök är inte tillgängligt",
|
||||||
|
"loadMore": "Visa fler",
|
||||||
|
"resultCount": "{{count}} träffar",
|
||||||
|
"selectPrompt": "Välj en träff för att se hela posten"
|
||||||
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"heading": "Synlighet",
|
"heading": "Synlighet",
|
||||||
"advanceInternal": "Gör intern",
|
"advanceInternal": "Gör intern",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => {
|
|||||||
renderApp(tree(), { route: "/objects" });
|
renderApp(tree(), { route: "/objects" });
|
||||||
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
||||||
// later milestones are present but disabled
|
// fields is still disabled; search is now a link
|
||||||
expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
|
expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("language switch toggles to Swedish", async () => {
|
test("language switch toggles to Swedish", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useLogout } from "../api/queries";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LangSwitch } from "./lang-switch";
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
|
||||||
const DISABLED_NAV = ["fields", "search"] as const;
|
const DISABLED_NAV = ["fields"] as const;
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -46,6 +46,14 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
{t("nav.authorities")}
|
{t("nav.authorities")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/search"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.search")}
|
||||||
|
</NavLink>
|
||||||
{DISABLED_NAV.map((key) => (
|
{DISABLED_NAV.map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
Reference in New Issue
Block a user