diff --git a/PROGRESS.md b/PROGRESS.md index c955003..519cd9d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,20 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). ## Entries +### Bounty board search by finding text — #122 + +- **What:** Adds a search box to the bounty board: case-insensitive substring + match over each bounty's issue reference and structured package name + (`bountyPackage`), debounced 150 ms into the `q` URL parameter (shareable + views, composable with the status filter), a clear button, and a dedicated + no-matches message. +- **Tests:** New `src/pages/BountyList.test.tsx` covers empty-query pass-through, + debouncing, case-insensitive matching by reference and by package name, the + no-matches message and the clear button. Full frontend suite: 56 passing. +- **By:** Hermes agent +- **Closes:** #122 +- **Status:** Ready for review. + ### Dependency licenses and sources — `cargo deny` — #163 - **What:** Adds [`deny.toml`](deny.toml) allowing MIT, Apache-2.0, BSD, diff --git a/frontend/src/index.css b/frontend/src/index.css index 5f3c0ca..adf64fc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -366,6 +366,27 @@ button:hover { flex-wrap: wrap; margin-bottom: 1.25rem; } +.board-search { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.board-search input[type="search"] { + background: var(--slate-soft); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-h); + font: inherit; + padding: 0.35rem 0.9rem; + min-width: 14rem; +} + +.board-search input[type="search"]:focus { + outline: none; + border-color: rgba(45, 212, 191, 0.5); +} + .chips { display: flex; gap: 0.4rem; diff --git a/frontend/src/pages/BountyList.test.tsx b/frontend/src/pages/BountyList.test.tsx new file mode 100644 index 0000000..636360e --- /dev/null +++ b/frontend/src/pages/BountyList.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BountyList } from "./BountyList"; +import type { Bounty } from "../lib/types"; +import * as contract from "../lib/contract"; + +vi.mock("../lib/contract", () => ({ + listBounties: vi.fn(), +})); +vi.mock("../lib/typosquat", () => ({ + bountyPackage: (b: Pick) => b.package ?? null, +})); +// The Radar canvas isn't the subject here. +vi.mock("../components/Radar", () => ({ Radar: () => null })); + +const bounty = (id: number, issueRef: string, pkg?: string): Bounty => ({ + id, + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + issueRef, + package: pkg, + amount: 5_000_000n, + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + status: "Open", + createdAt: 1_791_546_147, + deadline: 1_794_138_138, + claimCount: 0, + pendingClaims: 0, +}); + +const bounties = [ + bounty(1, "expresss typosquat of express", "expresss"), + bounty(2, "requetss typosquat of requests", "requetss"), + bounty(3, "lodash prototype pollution", "lodash"), +]; + +function renderList() { + return render( + + + , + ); +} + +const searchBox = () => screen.getByRole("searchbox", { name: /search bounties/i }); + +/** Types into the search box and lets the 150ms debounce elapse inside act(). */ +function search(text: string) { + act(() => { + fireEvent.change(searchBox(), { target: { value: text } }); + vi.advanceTimersByTime(150); + }); +} + +beforeEach(() => { + // Fake timers with advancing real time: the debounce is testable via + // advanceTimersByTime, while async contract resolves still settle. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.mocked(contract.listBounties).mockResolvedValue(bounties); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("BountyList search", () => { + it("shows every bounty when the query is empty", async () => { + renderList(); + expect(await screen.findByText("expresss typosquat of express")).toBeInTheDocument(); + expect(screen.getByText("requetss typosquat of requests")).toBeInTheDocument(); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + }); + + it("debounces input before filtering", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + act(() => { + fireEvent.change(searchBox(), { target: { value: "lodash" } }); + }); + // Not yet applied before the debounce elapses. + expect(screen.getByText("expresss typosquat of express")).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + }); + + it("filters case-insensitively by issue reference text", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("LODASH"); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + }); + + it("filters by package name", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("requets"); + expect(screen.getByText("requetss typosquat of requests")).toBeInTheDocument(); + expect(screen.queryByText("lodash prototype pollution")).not.toBeInTheDocument(); + }); + + it("shows a no-matches message", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("zzzznope"); + expect(screen.getByText(/no bounties match/i)).toBeInTheDocument(); + }); + + it("clears the search with the clear button", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("lodash"); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /clear search/i })); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.getByText("expresss typosquat of express")).toBeInTheDocument(); + expect(screen.queryByText(/no bounties match/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/BountyList.tsx b/frontend/src/pages/BountyList.tsx index c6e7398..8ccccff 100644 --- a/frontend/src/pages/BountyList.tsx +++ b/frontend/src/pages/BountyList.tsx @@ -1,5 +1,7 @@ import { useSearchParams } from "react-router-dom"; +import { useEffect, useState } from "react"; import { BountyCard, BountyCardSkeleton } from "../components/BountyCard"; +import { bountyPackage } from "../lib/typosquat"; import { Radar } from "../components/Radar"; import { Empty, ErrorState, Skeleton } from "../components/ui"; import { listBounties } from "../lib/contract"; @@ -22,6 +24,36 @@ export function BountyList() { const bounties = state.status === "success" ? state.data : []; const shown = filter.status ? bounties.filter((b) => b.status === filter.status) : bounties; + // Search by finding text (#122): case-insensitive substring over the package + // name and issue reference. The input is debounced (150ms) into the `q` + // URL param so the view stays shareable without re-rendering every keystroke. + const urlQuery = params.get("q") ?? ""; + const [input, setInput] = useState(urlQuery); + useEffect(() => { + const t = setTimeout(() => { + const next = new URLSearchParams(params); + if (input) next.set("q", input); + else next.delete("q"); + if (next.toString() !== params.toString()) setParams(next, { replace: true }); + }, 150); + return () => clearTimeout(t); + }, [input, params, setParams]); + useEffect(() => { + // Keep the input in sync when the URL changes externally (back/forward + // navigation, shared links with ?q=). Deliberate sync-in-effect. + /* eslint-disable-next-line react(set-state-in-effect) -- intentional URL→input sync; oxlint reads plain eslint-disable too */ + // oxlint-disable-next-line + setInput(urlQuery); + }, [urlQuery]); + const query = urlQuery.trim().toLowerCase(); + const searched = query + ? shown.filter( + (b) => + b.issueRef.toLowerCase().includes(query) || + (bountyPackage(b) ?? "").toLowerCase().includes(query), + ) + : shown; + return ( <>
@@ -44,6 +76,25 @@ export function BountyList() {

Bounty board

+
e.preventDefault()}> + setInput(e.target.value)} + /> + {input && ( + + )} +
{FILTERS.map((f) => { const count = f.status ? bounties.filter((b) => b.status === f.status).length : bounties.length; @@ -73,7 +124,7 @@ export function BountyList() { )} {state.status === "error" && } {state.status === "success" && - (shown.length === 0 ? ( + (searched.length === 0 ? ( bounties.length === 0 ? (
@@ -95,12 +146,14 @@ export function BountyList() {
+ ) : query ? ( + {`No bounties match “${urlQuery.trim()}”${filter.status ? ` in ${filter.label.toLowerCase()}` : ""}.`} ) : ( {`No ${filter.label.toLowerCase()} bounties right now.`} ) ) : (
- {shown.map((b) => ( + {searched.map((b) => ( ))}