diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index 00dd35f..76152d8 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -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"; @@ -14,8 +15,6 @@ import { buildIssuesHref, isFilterActive, parseBountyQuery, - SORT_LABELS, - SORT_VALUES, STATUS_VALUES, DIFFICULTY_VALUES, ASSET_VALUES, @@ -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. */} -
- {query.status !== "all" && } - - - - - - - - - - - - - {filterActive && ( - - Clear all - - )} -
+ 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. */} + {visible.length === 0 ? ( filterActive ? ( diff --git a/src/components/bounty/IssuesFilterForm.test.tsx b/src/components/bounty/IssuesFilterForm.test.tsx new file mode 100644 index 0000000..be4e1bd --- /dev/null +++ b/src/components/bounty/IssuesFilterForm.test.tsx @@ -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
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); +} + +describe("IssuesFilterForm — default/empty fields omitted on submit (#701)", () => { + it("excludes every default select and empty number input from the submission", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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); + }); +}); diff --git a/src/components/bounty/IssuesFilterForm.tsx b/src/components/bounty/IssuesFilterForm.tsx new file mode 100644 index 0000000..386c8e2 --- /dev/null +++ b/src/components/bounty/IssuesFilterForm.tsx @@ -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 = { + 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) { + 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 ( + + {query.status !== "all" && } + + + + + + + + + + + + + {filterActive && ( + + Clear all + + )} + + ); +}