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
14 changes: 14 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/pages/BountyList.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Bounty, "issueRef" | "package">) => 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(
<MemoryRouter initialEntries={["/"]}>
<BountyList />
</MemoryRouter>,
);
}

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();
});
});
57 changes: 55 additions & 2 deletions frontend/src/pages/BountyList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<>
<section className="hero">
Expand All @@ -44,6 +76,25 @@ export function BountyList() {
<section className="board">
<div className="board-head">
<h2>Bounty board</h2>
<form className="board-search" role="search" onSubmit={(e) => e.preventDefault()}>
<input
type="search"
aria-label="Search bounties by finding text"
placeholder="Search by package or finding…"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
{input && (
<button
type="button"
className="chip"
aria-label="Clear search"
onClick={() => setInput("")}
>
Clear
</button>
)}
</form>
<div className="chips" role="tablist" aria-label="Filter by status">
{FILTERS.map((f) => {
const count = f.status ? bounties.filter((b) => b.status === f.status).length : bounties.length;
Expand Down Expand Up @@ -73,7 +124,7 @@ export function BountyList() {
)}
{state.status === "error" && <ErrorState error={state.error} onRetry={state.reload} />}
{state.status === "success" &&
(shown.length === 0 ? (
(searched.length === 0 ? (
bounties.length === 0 ? (
<Empty>
<div className="empty-cta">
Expand All @@ -95,12 +146,14 @@ export function BountyList() {
</ul>
</div>
</Empty>
) : query ? (
<Empty>{`No bounties match “${urlQuery.trim()}”${filter.status ? ` in ${filter.label.toLowerCase()}` : ""}.`}</Empty>
) : (
<Empty>{`No ${filter.label.toLowerCase()} bounties right now.`}</Empty>
)
) : (
<div className="grid">
{shown.map((b) => (
{searched.map((b) => (
<BountyCard key={b.id} bounty={b} />
))}
</div>
Expand Down