Skip to content
Open
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
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const App = () => {
path="/player/:player_id/:char_short?/:count?/:offset?"
element={<Player />}
/>
<Route path="/search/:search_string/:exact?" element={<Search />} />
<Route path="/search" element={<Search />} />
<Route path="/stats" element={<Stats />} />
<Route path="/popularity" element={<Popularity />} />
<Route path="/matchup" element={<Matchup />} />
Expand Down
19 changes: 10 additions & 9 deletions frontend/src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -47,15 +48,15 @@ function resetCharacters() {
}

function NavBar() {
const navigate = useNavigate();
const navigateToSearch = useSearchNavigation();

const [anchorElNav, setAnchorElNav] = useState<HTMLElement | null>(null);

const [characterElNav, setCharacterElNav] = useState<HTMLElement | null>(
null,
);

const [searchString, setSearchString] = useState("");
const [searchQuery, setSearchQuery] = useState("");

const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT;

Expand All @@ -66,21 +67,21 @@ function NavBar() {
const handleSearchChange = (event: {
target: { value: SetStateAction<string> };
}) => {
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(() => {
Expand Down Expand Up @@ -251,7 +252,7 @@ function NavBar() {
variant="outlined"
label="Search..."
style={{ marginTop: 10 }}
value={searchString}
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
Expand Down Expand Up @@ -363,7 +364,7 @@ function NavBar() {
variant="outlined"
label="Search..."
style={{ marginTop: 10 }}
value={searchString}
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/hooks/useSearchNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -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<EncodingCase>([
{
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");
});
});
16 changes: 16 additions & 0 deletions frontend/src/hooks/useSearchNavigation.ts
Original file line number Diff line number Diff line change
@@ -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));
}
30 changes: 17 additions & 13 deletions frontend/src/pages/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,23 @@ 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";

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())
Expand All @@ -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 (
<Suspense fallback={<CircularProgress size={60} />}>
Expand Down Expand Up @@ -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 (
<>
Expand All @@ -130,8 +134,8 @@ const Search = () => {
</AppBar>

<SearchResultsLoader
key={`${search_string}-${exact}`}
search_string={search_string}
key={`${searchQuery}-${exact}`}
searchQuery={searchQuery}
exact={exact}
/>
</>
Expand Down
Loading