Skip to content
Closed
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
104 changes: 11 additions & 93 deletions src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GitPullRequest, FilterX } from "lucide-react";
import { fetchBounties } from "@/lib/api";
import { mockBounties } from "@/lib/mock-data";
import { BountyCard } from "@/components/bounty/BountyCard";
import { IssuesFilterForm } from "@/components/bounty/IssuesFilterForm";
import { EmptyState } from "@/components/ui/EmptyState";
import { Button } from "@/components/ui/Button";
import { DataSourceNotice } from "@/components/ui/DataSourceNotice";
Expand All @@ -14,8 +15,6 @@ import {
buildIssuesHref,
isFilterActive,
parseBountyQuery,
SORT_LABELS,
SORT_VALUES,
STATUS_VALUES,
DIFFICULTY_VALUES,
ASSET_VALUES,
Expand Down Expand Up @@ -128,97 +127,16 @@ export default async function IssuesPage({
rather than client-side state — every combination stays a shareable,
bookmarkable URL and the page works with JavaScript disabled. The
hidden `status` field carries the pill selection above through the
same submit, since HTML forms only send their own named fields. */}
<form
method="get"
action="/issues"
className="mb-6 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
>
{query.status !== "all" && <input type="hidden" name="status" value={query.status} />}

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Difficulty
<select
name="difficulty"
defaultValue={query.difficulty}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{DIFFICULTY_VALUES.map((value) => (
<option key={value} value={value}>
{DIFFICULTY_LABELS[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Asset
<select
name="asset"
defaultValue={query.asset}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{ASSET_VALUES.map((value) => (
<option key={value} value={value}>
{ASSET_LABELS[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Min reward
<input
type="number"
inputMode="decimal"
min={0}
name="minReward"
defaultValue={query.minReward ?? ""}
placeholder="0"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Max reward
<input
type="number"
inputMode="decimal"
min={0}
name="maxReward"
defaultValue={query.maxReward ?? ""}
placeholder="Any"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Sort by
<select
name="sort"
defaultValue={query.sort}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{SORT_VALUES.map((value) => (
<option key={value} value={value}>
{SORT_LABELS[value]}
</option>
))}
</select>
</label>

<Button type="submit" size="sm">
Apply filters
</Button>
{filterActive && (
<Link
href="/issues"
className="text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400"
>
Clear all
</Link>
)}
</form>
same submit, since HTML forms only send their own named fields.
Extracted into a client component so it can disable default/empty
fields before submit when JS is available (#701) — see
IssuesFilterForm's own doc comment for why. */}
<IssuesFilterForm
query={query}
filterActive={filterActive}
difficultyLabels={DIFFICULTY_LABELS}
assetLabels={ASSET_LABELS}
/>

{visible.length === 0 ? (
filterActive ? (
Expand Down
90 changes: 90 additions & 0 deletions src/components/bounty/IssuesFilterForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { IssuesFilterForm } from "./IssuesFilterForm";
import { parseBountyQuery } from "@/lib/bounty-query";

const DIFFICULTY_LABELS = {
all: "Any difficulty",
beginner: "Beginner",
intermediate: "Intermediate",
advanced: "Advanced",
expert: "Expert",
} as const;

const ASSET_LABELS = { all: "Any asset", USDC: "USDC", XLM: "XLM" } as const;

/** Reads the <form> element's GET submission as a plain param record. */
function submittedParams(form: HTMLFormElement): URLSearchParams {
const data = new FormData(form);
return new URLSearchParams(data as unknown as Record<string, string>);
}

describe("IssuesFilterForm — default/empty fields omitted on submit (#701)", () => {
it("excludes every default select and empty number input from the submission", () => {
render(
<IssuesFilterForm
query={parseBountyQuery({})}
filterActive={false}
difficultyLabels={DIFFICULTY_LABELS}
assetLabels={ASSET_LABELS}
/>,
);

const form = screen.getByRole("button", { name: "Apply filters" }).closest("form")!;
fireEvent.submit(form);

const params = submittedParams(form);
expect(params.has("difficulty")).toBe(false);
expect(params.has("asset")).toBe(false);
expect(params.has("sort")).toBe(false);
expect(params.has("minReward")).toBe(false);
expect(params.has("maxReward")).toBe(false);
});

it("still includes a non-default value chosen for each field", () => {
render(
<IssuesFilterForm
query={parseBountyQuery({
difficulty: "expert",
asset: "USDC",
sort: "reward-desc",
minReward: "50",
maxReward: "200",
})}
filterActive
difficultyLabels={DIFFICULTY_LABELS}
assetLabels={ASSET_LABELS}
/>,
);

const form = screen.getByRole("button", { name: "Apply filters" }).closest("form")!;
fireEvent.submit(form);

const params = submittedParams(form);
expect(params.get("difficulty")).toBe("expert");
expect(params.get("asset")).toBe("USDC");
expect(params.get("sort")).toBe("reward-desc");
expect(params.get("minReward")).toBe("50");
expect(params.get("maxReward")).toBe("200");
});

it("only includes the fields a visitor actually changed, not every default", () => {
render(
<IssuesFilterForm
query={parseBountyQuery({ asset: "XLM" })}
filterActive
difficultyLabels={DIFFICULTY_LABELS}
assetLabels={ASSET_LABELS}
/>,
);

const form = screen.getByRole("button", { name: "Apply filters" }).closest("form")!;
fireEvent.submit(form);

const params = submittedParams(form);
expect(params.get("asset")).toBe("XLM");
expect(params.has("difficulty")).toBe(false);
expect(params.has("sort")).toBe(false);
expect(params.has("minReward")).toBe(false);
expect(params.has("maxReward")).toBe(false);
});
});
154 changes: 154 additions & 0 deletions src/components/bounty/IssuesFilterForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import type { FormEvent } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import {
SORT_LABELS,
SORT_VALUES,
DIFFICULTY_VALUES,
ASSET_VALUES,
type BountyQuery,
} from "@/lib/bounty-query";

/** The sentinel "no filter" value for each select, by field name. */
const DEFAULT_SELECT_VALUES: Record<string, string> = {
difficulty: "all",
asset: "all",
sort: "default",
};

interface IssuesFilterFormProps {
query: BountyQuery;
filterActive: boolean;
difficultyLabels: Record<(typeof DIFFICULTY_VALUES)[number], string>;
assetLabels: Record<(typeof ASSET_VALUES)[number], string>;
}

/**
* A plain GET form so every filter combination stays a shareable,
* bookmarkable URL and the page works with JavaScript disabled (#28).
*
* When JS *is* available, `handleSubmit` disables any field still at its
* default/empty value right before the browser serializes the form —
* disabled fields aren't included in a submission, so the resulting URL
* matches the canonical one `buildIssuesHref` produces instead of spelling
* out every default explicitly as `?difficulty=all&asset=all&...` (#701).
* Without JS this handler never runs and the form still works exactly as
* before, just with a noisier URL.
*/
export function IssuesFilterForm({
query,
filterActive,
difficultyLabels,
assetLabels,
}: IssuesFilterFormProps) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
const form = event.currentTarget;
for (const element of Array.from(form.elements)) {
if (element instanceof HTMLSelectElement) {
if (element.value === DEFAULT_SELECT_VALUES[element.name]) {
element.disabled = true;
}
} else if (element instanceof HTMLInputElement && element.type === "number") {
if (element.value === "") {
element.disabled = true;
}
}
}
}

return (
<form
method="get"
action="/issues"
onSubmit={handleSubmit}
className="mb-6 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
>
{query.status !== "all" && <input type="hidden" name="status" value={query.status} />}

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Difficulty
<select
name="difficulty"
defaultValue={query.difficulty}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{DIFFICULTY_VALUES.map((value) => (
<option key={value} value={value}>
{difficultyLabels[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Asset
<select
name="asset"
defaultValue={query.asset}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{ASSET_VALUES.map((value) => (
<option key={value} value={value}>
{assetLabels[value]}
</option>
))}
</select>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Min reward
<input
type="number"
inputMode="decimal"
min={0}
name="minReward"
defaultValue={query.minReward ?? ""}
placeholder="0"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Max reward
<input
type="number"
inputMode="decimal"
min={0}
name="maxReward"
defaultValue={query.maxReward ?? ""}
placeholder="Any"
className="w-24 rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
/>
</label>

<label className="flex flex-col gap-1 text-xs font-medium text-slate-500 dark:text-slate-400">
Sort by
<select
name="sort"
defaultValue={query.sort}
className="rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{SORT_VALUES.map((value) => (
<option key={value} value={value}>
{SORT_LABELS[value]}
</option>
))}
</select>
</label>

<Button type="submit" size="sm">
Apply filters
</Button>
{filterActive && (
<Link
href="/issues"
className="text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400"
>
Clear all
</Link>
)}
</form>
);
}