diff --git a/web/src/app.tsx b/web/src/app.tsx index e882e83..952925d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -7,6 +7,8 @@ import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; 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 { VocabularyTerms } from "./vocab/vocabulary-terms"; import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; @@ -55,6 +57,10 @@ export function App() { } /> } /> + }> + } /> + } /> + } /> } /> } /> diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index eb1d289..9706f75 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -18,6 +18,16 @@ "title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place", "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": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 56e4f7e..574c995 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -18,6 +18,16 @@ "title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats", "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": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx new file mode 100644 index 0000000..41ac0ed --- /dev/null +++ b/web/src/search/search-page.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; + +import { SearchPanel } from "./search-panel"; + +export function SearchPage() { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/web/src/search/search-panel.tsx b/web/src/search/search-panel.tsx new file mode 100644 index 0000000..e540b57 --- /dev/null +++ b/web/src/search/search-panel.tsx @@ -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 ( +
+
+ setText(event.target.value)} + placeholder={t("search.placeholder")} + aria-label={t("search.placeholder")} + /> +
+ {VIS.map((value) => { + const active = (visibility ?? "all") === value; + + return ( + + ); + })} +
+
+ +
+ {!hasQuery &&

{t("search.prompt")}

} + + {hasQuery && search.isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {hasQuery && search.isError && ( +

{t("search.loadError")}

+ )} + + {hasQuery && !search.isLoading && !search.isError && hits.length === 0 && ( +

{t("search.empty")}

+ )} + + {hits.length > 0 && ( + <> +

+ {t("search.resultCount", { count: total })} +

+
    + {hits.map((hit) => ( + + ))} +
+ {search.hasNextPage && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/web/src/search/search.test.tsx b/web/src/search/search.test.tsx new file mode 100644 index 0000000..22a2f19 --- /dev/null +++ b/web/src/search/search.test.tsx @@ -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 ( + + }> + } /> + } /> + + + ); +} + +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(); +}); diff --git a/web/src/search/select-search-prompt.tsx b/web/src/search/select-search-prompt.tsx new file mode 100644 index 0000000..d5aa715 --- /dev/null +++ b/web/src/search/select-search-prompt.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export function SelectSearchPrompt() { + const { t } = useTranslation(); + + return ( +
+ {t("search.selectPrompt")} +
+ ); +} diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx index 1e58ec9..9cc1ca2 100644 --- a/web/src/shell/app-shell.test.tsx +++ b/web/src/shell/app-shell.test.tsx @@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => { renderApp(tree(), { route: "/objects" }); expect(await screen.findByText("objects outlet")).toBeInTheDocument(); expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument(); - // later milestones are present but disabled - expect(screen.getByRole("button", { name: /search/i })).toBeDisabled(); + // fields is still disabled; search is now a link + expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled(); }); test("language switch toggles to Swedish", async () => { diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 1ab6a0c..4a1aa13 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,7 +5,7 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const DISABLED_NAV = ["fields", "search"] as const; +const DISABLED_NAV = ["fields"] as const; export function AppShell() { const { t } = useTranslation(); @@ -46,6 +46,14 @@ export function AppShell() { > {t("nav.authorities")} + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.search")} + {DISABLED_NAV.map((key) => (