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 = () => {