Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState } from "react";
import "./App.css";
import HomePage from "./pages/homePage/homePage";
import CategoryPage from "./pages/categoryPage/categoryPage";
Expand All @@ -15,20 +14,15 @@ function CategoryRoute() {
}

function App() {
const [search, setSearch] = useState("");

return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 bg-background">
<NavBar onSearch={setSearch} />
<NavBar />
</header>
<main className="flex-1">
<Routes>
<Route path="/" element={<HomePage />} />
<Route
path="/explore"
element={<CategoryPage key={search} search={search} />}
/>
<Route path="/explore" element={<CategoryPage />} />
<Route
path="/explore/category/:category"
element={<CategoryRoute />}
Expand Down
13 changes: 6 additions & 7 deletions frontend/src/components/pageSections/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -24,8 +20,11 @@ export default function NavBar({ onSearch }: NavBarProps) {

function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSearch(search.trim());
navigate("/explore");
const query = search.trim();
navigate({
pathname: "/explore",
search: query ? `?${new URLSearchParams({ search: query })}` : "",
});
}

return (
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/hooks/useCatalogParams.ts
Original file line number Diff line number Diff line change
@@ -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<PartsOptions>) => {
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 };
}
16 changes: 5 additions & 11 deletions frontend/src/pages/categoryPage/categoryPage.tsx
Original file line number Diff line number Diff line change
@@ -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<PartsOptions>({
category: initialCategory?.value,
});
const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams(
initialCategory?.value,
);
const selectedCategory = categories.find(
(item) => item.value === filtOptions.category,
);
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/pages/homePage/homePage.tsx
Original file line number Diff line number Diff line change
@@ -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<PartsOptions>({});
const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams();

return (
<div className="min-h-screen">
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/test/useCatalogParams.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<output data-testid="state">
{JSON.stringify({ search, ...filtOptions })}
</output>
<output data-testid="url">{location.search}</output>
<button onClick={() => setSearch("RTX 4070")}>search</button>
<button
onClick={() =>
setFiltOptions((previous: PartsOptions) => ({
...previous,
category: "GPU",
sortBy: "PRICE",
sortDirection: "DESC",
}))
}
>
filter
</button>
<button onClick={() => setFiltOptions({ category: defaultCategory })}>
reset
</button>
<button onClick={() => navigate(-1)}>back</button>
</>
);
}

function renderAt(url: string, defaultCategory?: "CPU") {
render(
<MemoryRouter initialEntries={[url]}>
<TestPage defaultCategory={defaultCategory} />
</MemoryRouter>,
);
}

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();
});