From 2e9b61443031706483410f237e5234b42ce7fd9a Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Fri, 13 Nov 2026 16:07:05 +0100 Subject: [PATCH] feat: implement catalog search and sorting persistence in URL --- frontend/src/App.tsx | 10 +- .../src/components/pageSections/Header.tsx | 13 +- frontend/src/hooks/useCatalogParams.ts | 91 ++++++++++++++ .../src/pages/categoryPage/categoryPage.tsx | 16 +-- frontend/src/pages/homePage/homePage.tsx | 6 +- frontend/src/test/useCatalogParams.test.tsx | 116 ++++++++++++++++++ 6 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 frontend/src/hooks/useCatalogParams.ts create mode 100644 frontend/src/test/useCatalogParams.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4be207..15ce456 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import "./App.css"; import HomePage from "./pages/homePage/homePage"; import CategoryPage from "./pages/categoryPage/categoryPage"; @@ -15,20 +14,15 @@ function CategoryRoute() { } function App() { - const [search, setSearch] = useState(""); - return (
- +
} /> - } - /> + } /> } diff --git a/frontend/src/components/pageSections/Header.tsx b/frontend/src/components/pageSections/Header.tsx index 61f3153..7e93843 100644 --- a/frontend/src/components/pageSections/Header.tsx +++ b/frontend/src/components/pageSections/Header.tsx @@ -11,11 +11,7 @@ const navigation = [ { to: "/community", label: "Community Page" }, ]; -type NavBarProps = { - onSearch: (value: string) => void; -}; - -export default function NavBar({ onSearch }: NavBarProps) { +export default function NavBar() { const [search, setSearch] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const navigate = useNavigate(); @@ -24,8 +20,11 @@ export default function NavBar({ onSearch }: NavBarProps) { function handleSearch(event: FormEvent) { event.preventDefault(); - onSearch(search.trim()); - navigate("/explore"); + const query = search.trim(); + navigate({ + pathname: "/explore", + search: query ? `?${new URLSearchParams({ search: query })}` : "", + }); } return ( diff --git a/frontend/src/hooks/useCatalogParams.ts b/frontend/src/hooks/useCatalogParams.ts new file mode 100644 index 0000000..ab7491d --- /dev/null +++ b/frontend/src/hooks/useCatalogParams.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo, type SetStateAction } from "react"; +import { useSearchParams } from "react-router"; +import type { PartsOptions } from "@/api/parts"; +import { categories } from "@/types/categories"; + +const SORT_FIELDS = ["NAME", "PRICE", "MANUFACTURER", "CATEGORY"] as const; +const SORT_DIRECTIONS = ["ASC", "DESC"] as const; + +type FilterOptions = Pick< + PartsOptions, + "category" | "sortBy" | "sortDirection" +>; + +function readFilters( + params: URLSearchParams, + defaultCategory: PartsOptions["category"], +): FilterOptions { + const category = params.get("category"); + const sortBy = SORT_FIELDS.find((field) => field === params.get("sortBy")); + const sortDirection = SORT_DIRECTIONS.find( + (direction) => direction === params.get("sortDirection"), + ); + + return { + // An empty value means "all categories" on a page that has a default category. + category: + category === null + ? defaultCategory + : categories.find((item) => item.value === category)?.value, + sortBy, + sortDirection, + }; +} + +/** + * Keeps the catalog search, category and sorting in the URL query string, so they + * survive a refresh, can be shared, and follow browser back/forward navigation. + * `defaultCategory` is the category implied by the page (for example from the route). + */ +export function useCatalogParams(defaultCategory?: PartsOptions["category"]) { + const [searchParams, setSearchParams] = useSearchParams(); + + const search = searchParams.get("search") ?? ""; + const filtOptions = useMemo( + () => readFilters(searchParams, defaultCategory), + [searchParams, defaultCategory], + ); + + const setSearch = useCallback( + (value: string) => { + // Replace instead of push so every keystroke does not add a history entry. + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + if (value) next.set("search", value); + else next.delete("search"); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setFiltOptions = useCallback( + (action: SetStateAction) => { + setSearchParams((previous) => { + const options = + typeof action === "function" + ? action(readFilters(previous, defaultCategory)) + : action; + const next = new URLSearchParams(previous); + + if (options.category === defaultCategory) next.delete("category"); + else next.set("category", options.category ?? ""); + + if (options.sortBy) next.set("sortBy", options.sortBy); + else next.delete("sortBy"); + + if (options.sortDirection) + next.set("sortDirection", options.sortDirection); + else next.delete("sortDirection"); + + return next; + }); + }, + [setSearchParams, defaultCategory], + ); + + return { search, setSearch, filtOptions, setFiltOptions }; +} diff --git a/frontend/src/pages/categoryPage/categoryPage.tsx b/frontend/src/pages/categoryPage/categoryPage.tsx index 4ddd20c..cbb2c1b 100644 --- a/frontend/src/pages/categoryPage/categoryPage.tsx +++ b/frontend/src/pages/categoryPage/categoryPage.tsx @@ -1,25 +1,19 @@ import AllPartsPreview from "../../components/AllPartsPreview"; import { categories } from "@/types/categories"; import FilterComponent from "@/components/FilterComponent"; -import { useState } from "react"; -import type { PartsOptions } from "@/api/parts"; +import { useCatalogParams } from "@/hooks/useCatalogParams"; import CategorySelection from "@/components/CategorySelection"; type CategoryPageProps = { category?: string; - search?: string; }; -export default function CategoryPage({ - category, - search: initialSearch = "", -}: CategoryPageProps) { +export default function CategoryPage({ category }: CategoryPageProps) { const initialCategory = categories.find((item) => item.value === category); const invalidCategory = category !== undefined && !initialCategory; - const [search, setSearch] = useState(initialSearch); - const [filtOptions, setFiltOptions] = useState({ - category: initialCategory?.value, - }); + const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams( + initialCategory?.value, + ); const selectedCategory = categories.find( (item) => item.value === filtOptions.category, ); diff --git a/frontend/src/pages/homePage/homePage.tsx b/frontend/src/pages/homePage/homePage.tsx index d4eafbd..5d9b6f8 100644 --- a/frontend/src/pages/homePage/homePage.tsx +++ b/frontend/src/pages/homePage/homePage.tsx @@ -1,14 +1,12 @@ import { Link } from "react-router"; -import { useState } from "react"; import AllPartsPreview from "../../components/AllPartsPreview"; -import type { PartsOptions } from "@/api/parts"; +import { useCatalogParams } from "@/hooks/useCatalogParams"; import FilterComponent from "@/components/FilterComponent"; import BuildPCGraphic from "./components/BuildPCGraphic"; import CategorySelection from "../../components/CategorySelection"; export default function HomePage() { - const [search, setSearch] = useState(""); - const [filtOptions, setFiltOptions] = useState({}); + const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams(); return (
diff --git a/frontend/src/test/useCatalogParams.test.tsx b/frontend/src/test/useCatalogParams.test.tsx new file mode 100644 index 0000000..b4d63f6 --- /dev/null +++ b/frontend/src/test/useCatalogParams.test.tsx @@ -0,0 +1,116 @@ +import { it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation, useNavigate } from "react-router"; + +import { useCatalogParams } from "@/hooks/useCatalogParams"; +import type { PartsOptions } from "@/api/parts"; + +function TestPage({ defaultCategory }: { defaultCategory?: "CPU" }) { + const { search, setSearch, filtOptions, setFiltOptions } = + useCatalogParams(defaultCategory); + const location = useLocation(); + const navigate = useNavigate(); + + return ( + <> + + {JSON.stringify({ search, ...filtOptions })} + + {location.search} + + + + + + ); +} + +function renderAt(url: string, defaultCategory?: "CPU") { + render( + + + , + ); +} + +const state = () => JSON.parse(screen.getByTestId("state").textContent!); +const url = () => screen.getByTestId("url").textContent; + +it("restores search, category and sorting from the URL", () => { + renderAt("/?search=ryzen&category=GPU&sortBy=PRICE&sortDirection=DESC"); + + expect(state()).toEqual({ + search: "ryzen", + category: "GPU", + sortBy: "PRICE", + sortDirection: "DESC", + }); +}); + +it("ignores invalid values in the URL", () => { + renderAt("/?category=Toaster&sortBy=COLOR&sortDirection=UP"); + + expect(state()).toEqual({ search: "" }); +}); + +it("writes search and filters to the URL", async () => { + const user = userEvent.setup(); + renderAt("/"); + + await user.click(screen.getByText("search")); + await user.click(screen.getByText("filter")); + + const params = new URLSearchParams(url()!); + expect(params.get("search")).toBe("RTX 4070"); + expect(params.get("category")).toBe("GPU"); + expect(params.get("sortBy")).toBe("PRICE"); + expect(params.get("sortDirection")).toBe("DESC"); +}); + +it("clears the filters from the URL on reset but keeps the search", async () => { + const user = userEvent.setup(); + renderAt("/?search=ryzen&category=GPU&sortBy=PRICE&sortDirection=DESC"); + + await user.click(screen.getByText("reset")); + + expect(url()).toBe("?search=ryzen"); + expect(state()).toEqual({ search: "ryzen" }); +}); + +it("uses the default category and lets the URL override it", async () => { + const user = userEvent.setup(); + renderAt("/", "CPU"); + expect(state().category).toBe("CPU"); + + await user.click(screen.getByText("filter")); + expect(state().category).toBe("GPU"); + + await user.click(screen.getByText("reset")); + expect(state().category).toBe("CPU"); + expect(url()).toBe(""); +}); + +it("restores the previous filters on browser back", async () => { + const user = userEvent.setup(); + renderAt("/"); + + await user.click(screen.getByText("filter")); + expect(state().category).toBe("GPU"); + + await user.click(screen.getByText("back")); + expect(state().category).toBeUndefined(); +});