diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e931d3e..cb9fc85 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -42,7 +42,7 @@ const App = () => {
path="/player/:player_id/:char_short?/:count?/:offset?"
element={}
/>
- } />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx
index 6a160bf..927c4a4 100644
--- a/frontend/src/components/NavBar.tsx
+++ b/frontend/src/components/NavBar.tsx
@@ -20,7 +20,8 @@ import {
useEffect,
useState,
} from "react";
-import { Link, useNavigate } from "react-router-dom";
+import { Link } from "react-router-dom";
+import { useSearchNavigation } from "../hooks/useSearchNavigation";
import { JSONParse } from "../utils/JSONParse";
import { StorageUtils } from "../utils/Storage";
@@ -47,7 +48,7 @@ function resetCharacters() {
}
function NavBar() {
- const navigate = useNavigate();
+ const navigateToSearch = useSearchNavigation();
const [anchorElNav, setAnchorElNav] = useState(null);
@@ -55,7 +56,7 @@ function NavBar() {
null,
);
- const [searchString, setSearchString] = useState("");
+ const [searchQuery, setSearchQuery] = useState("");
const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT;
@@ -66,21 +67,21 @@ function NavBar() {
const handleSearchChange = (event: {
target: { value: SetStateAction };
}) => {
- setSearchString(event.target.value);
+ setSearchQuery(event.target.value);
};
const handleSearchKeyDown = (event: { key: string }) => {
if (event.key === "Enter") {
- navigate(`/search/${searchString}`);
+ navigateToSearch(searchQuery, false);
}
};
const handleSearchClick = () => {
- navigate(`/search/${searchString}`);
+ navigateToSearch(searchQuery, false);
};
const handleExactSearchClick = () => {
- navigate(`/search/${searchString}/exact`);
+ navigateToSearch(searchQuery, true);
};
useEffect(() => {
@@ -251,7 +252,7 @@ function NavBar() {
variant="outlined"
label="Search..."
style={{ marginTop: 10 }}
- value={searchString}
+ value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
@@ -363,7 +364,7 @@ function NavBar() {
variant="outlined"
label="Search..."
style={{ marginTop: 10 }}
- value={searchString}
+ value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
diff --git a/frontend/src/hooks/useSearchNavigation.test.ts b/frontend/src/hooks/useSearchNavigation.test.ts
new file mode 100644
index 0000000..01df14c
--- /dev/null
+++ b/frontend/src/hooks/useSearchNavigation.test.ts
@@ -0,0 +1,64 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { buildSearchPath, useSearchNavigation } from "./useSearchNavigation";
+
+const mockNavigate = vi.fn();
+vi.mock("react-router-dom", () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+interface EncodingCase {
+ label: string;
+ input: string;
+ expected: string;
+}
+
+describe("buildSearchPath", () => {
+ test("builds a query-param path for a normal search string", () => {
+ expect(buildSearchPath("sol", false)).toBe("/search?q=sol");
+ });
+
+ test("includes exact=true when exact is requested", () => {
+ expect(buildSearchPath("sol", true)).toBe("/search?q=sol&exact=true");
+ });
+
+ test("does not collide with an empty search string and exact requested", () => {
+ expect(buildSearchPath("", true)).toBe("/search?q=&exact=true");
+ });
+
+ test.each([
+ {
+ label: "spaces and slashes",
+ input: "a b/c",
+ expected: "/search?q=a+b%2Fc",
+ },
+ {
+ label: "multibyte characters",
+ input: "ソル",
+ expected: "/search?q=%E3%82%BD%E3%83%AB",
+ },
+ ])("encodes special characters in the search string: $label", ({
+ input,
+ expected,
+ }) => {
+ expect(buildSearchPath(input, false)).toBe(expected);
+ });
+
+ test("regression: a literal 'exact' search string is not mistaken for the exact flag", () => {
+ expect(buildSearchPath("exact", false)).toBe("/search?q=exact");
+ });
+});
+
+describe("useSearchNavigation", () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ });
+
+ test("navigates to the path built from the given search string and exact flag", () => {
+ const { result } = renderHook(() => useSearchNavigation());
+
+ result.current("sol", true);
+
+ expect(mockNavigate).toHaveBeenCalledWith("/search?q=sol&exact=true");
+ });
+});
diff --git a/frontend/src/hooks/useSearchNavigation.ts b/frontend/src/hooks/useSearchNavigation.ts
new file mode 100644
index 0000000..baf94f4
--- /dev/null
+++ b/frontend/src/hooks/useSearchNavigation.ts
@@ -0,0 +1,16 @@
+import { useNavigate } from "react-router-dom";
+
+export function buildSearchPath(searchQuery: string, exact: boolean): string {
+ const params = new URLSearchParams({ q: searchQuery });
+ if (exact) {
+ params.set("exact", "true");
+ }
+ return `/search?${params.toString()}`;
+}
+
+export function useSearchNavigation() {
+ const navigate = useNavigate();
+
+ return (searchQuery: string, exact: boolean) =>
+ navigate(buildSearchPath(searchQuery, exact));
+}
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx
index eb7a442..9908715 100644
--- a/frontend/src/pages/Search.tsx
+++ b/frontend/src/pages/Search.tsx
@@ -10,7 +10,7 @@ import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import { Suspense, use, useEffect, useState } from "react";
-import { Link, useParams } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
import type { PlayerSearchResponse } from "../interfaces/API";
import { JSONParse } from "../utils/JSONParse";
import { Utils } from "../utils/Utils";
@@ -18,13 +18,15 @@ import { Utils } from "../utils/Utils";
const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT;
function fetchSearchResults(
- q: string | undefined,
- exact: string | undefined,
+ q: string,
+ exact: boolean,
): Promise<{ results: PlayerSearchResponse[] }> {
if (!q) return Promise.resolve({ results: [] });
- const isExact = exact === "exact" ? "true" : "false";
- const params = new URLSearchParams({ search_string: q, exact: isExact });
+ const params = new URLSearchParams({
+ search_string: q,
+ exact: String(exact),
+ });
return fetch(`${API_ENDPOINT}/player/search?${params.toString()}`)
.then((res) => res.text())
@@ -40,14 +42,14 @@ interface SearchResultsProps {
}
const SearchResultsLoader = ({
- search_string,
+ searchQuery,
exact,
}: {
- search_string: string | undefined;
- exact: string | undefined;
+ searchQuery: string;
+ exact: boolean;
}) => {
const [resultsPromise] = useState(() =>
- fetchSearchResults(search_string, exact),
+ fetchSearchResults(searchQuery, exact),
);
return (
}>
@@ -107,12 +109,14 @@ const SearchResults = ({ resultsPromise }: SearchResultsProps) => {
};
const Search = () => {
- const { search_string, exact } = useParams();
+ const [searchParams] = useSearchParams();
+ const searchQuery = searchParams.get("q") ?? "";
+ const exact = searchParams.get("exact") === "true";
// biome-ignore lint/correctness/useExhaustiveDependencies: trigger only
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
- }, [search_string, exact]);
+ }, [searchQuery, exact]);
return (
<>
@@ -130,8 +134,8 @@ const Search = () => {
>