diff --git a/src/app/issues/IssuesFilterForm.test.tsx b/src/app/issues/IssuesFilterForm.test.tsx new file mode 100644 index 0000000..b2d1418 --- /dev/null +++ b/src/app/issues/IssuesFilterForm.test.tsx @@ -0,0 +1,133 @@ +import { render, fireEvent } from "@testing-library/react"; +import { IssuesFilterForm } from "./IssuesFilterForm"; + +describe("IssuesFilterForm (#701)", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("disables default selects and empty inputs when submitted", () => { + const { container } = render( + + + + + + + + , + ); + + const form = container.querySelector("form")!; + const difficultySelect = container.querySelector('select[name="difficulty"]')!; + const assetSelect = container.querySelector('select[name="asset"]')!; + const minRewardInput = container.querySelector('input[name="minReward"]')!; + const maxRewardInput = container.querySelector('input[name="maxReward"]')!; + const sortSelect = container.querySelector('select[name="sort"]')!; + + expect(difficultySelect.disabled).toBe(false); + expect(assetSelect.disabled).toBe(false); + expect(minRewardInput.disabled).toBe(false); + expect(maxRewardInput.disabled).toBe(false); + expect(sortSelect.disabled).toBe(false); + + fireEvent.submit(form); + + // On submit, default selects and empty inputs should be disabled so GET omits them + expect(difficultySelect.disabled).toBe(true); + expect(assetSelect.disabled).toBe(true); + expect(minRewardInput.disabled).toBe(true); + expect(maxRewardInput.disabled).toBe(true); + expect(sortSelect.disabled).toBe(true); + + // After timer runs, fields re-enable for interactivity + jest.runAllTimers(); + expect(difficultySelect.disabled).toBe(false); + expect(assetSelect.disabled).toBe(false); + expect(minRewardInput.disabled).toBe(false); + expect(maxRewardInput.disabled).toBe(false); + expect(sortSelect.disabled).toBe(false); + }); + + it("keeps non-default and non-empty inputs enabled on submit", () => { + const { container } = render( + + + + + + + + + , + ); + + const form = container.querySelector("form")!; + const statusInput = container.querySelector('input[name="status"]')!; + const difficultySelect = container.querySelector('select[name="difficulty"]')!; + const assetSelect = container.querySelector('select[name="asset"]')!; + const minRewardInput = container.querySelector('input[name="minReward"]')!; + const maxRewardInput = container.querySelector('input[name="maxReward"]')!; + const sortSelect = container.querySelector('select[name="sort"]')!; + + fireEvent.submit(form); + + // Active filters remain enabled + expect(statusInput.disabled).toBe(false); + expect(difficultySelect.disabled).toBe(false); + expect(minRewardInput.disabled).toBe(false); + expect(sortSelect.disabled).toBe(false); + + // Defaults and empty inputs are disabled + expect(assetSelect.disabled).toBe(true); + expect(maxRewardInput.disabled).toBe(true); + }); + + it("restores disabled elements immediately if submit is prevented", () => { + const onSubmit = jest.fn((e: React.FormEvent) => { + e.preventDefault(); + }); + + const { container } = render( + + + + , + ); + + const form = container.querySelector("form")!; + const difficultySelect = container.querySelector('select[name="difficulty"]')!; + const minRewardInput = container.querySelector('input[name="minReward"]')!; + + fireEvent.submit(form); + + expect(onSubmit).toHaveBeenCalled(); + // Because preventDefault was called, inputs should be restored immediately + expect(difficultySelect.disabled).toBe(false); + expect(minRewardInput.disabled).toBe(false); + }); +}); diff --git a/src/app/issues/IssuesFilterForm.tsx b/src/app/issues/IssuesFilterForm.tsx new file mode 100644 index 0000000..2e673ed --- /dev/null +++ b/src/app/issues/IssuesFilterForm.tsx @@ -0,0 +1,66 @@ +"use client"; + +import type { ComponentPropsWithoutRef } from "react"; + +const DEFAULT_FILTER_VALUES: Record = { + status: "all", + difficulty: "all", + asset: "all", + sort: "default", +}; + +/** + * Filter form that wraps the GET query submission on `/issues`. + * + * Disables empty inputs and default-valued select fields right before native + * form submission so the browser only appends active filters to the query string. + * This keeps the submitted URL clean and canonical, matching `buildIssuesHref` (#701). + */ +export function IssuesFilterForm({ + onSubmit, + ...props +}: ComponentPropsWithoutRef<"form">) { + return ( +
{ + const form = e.currentTarget; + const elements = Array.from(form.elements); + const disabledByHandler: (HTMLInputElement | HTMLSelectElement)[] = []; + + for (const el of elements) { + if ( + (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) && + el.name && + !el.disabled + ) { + const val = el.value.trim(); + const isDefault = + val === "" || DEFAULT_FILTER_VALUES[el.name] === val; + + if (isDefault) { + el.disabled = true; + disabledByHandler.push(el); + } + } + } + + onSubmit?.(e); + + if (disabledByHandler.length > 0) { + if (e.defaultPrevented) { + for (const el of disabledByHandler) { + el.disabled = false; + } + } else { + setTimeout(() => { + for (const el of disabledByHandler) { + el.disabled = false; + } + }, 0); + } + } + }} + /> + ); +} diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index 00dd35f..e2c2c78 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button"; import { DataSourceNotice } from "@/components/ui/DataSourceNotice"; import { formatInteger } from "@/lib/utils"; import { messages, t } from "@/lib/messages"; +import { IssuesFilterForm } from "./IssuesFilterForm"; import { applyBountyQuery, buildIssuesHref, @@ -124,12 +125,13 @@ export default async function IssuesPage({ - {/* Difficulty/asset/reward-range/sort are exposed as a plain GET form - rather than client-side state — every combination stays a shareable, - bookmarkable URL and the page works with JavaScript disabled. The + {/* Difficulty/asset/reward-range/sort are exposed as a GET form wrapped in + IssuesFilterForm to omit default and empty parameters on submit (#701). + Every combination stays a shareable, bookmarkable URL matching + buildIssuesHref, and the page works even if JavaScript is disabled. The hidden `status` field carries the pill selection above through the same submit, since HTML forms only send their own named fields. */} - )} -
+ {visible.length === 0 ? ( filterActive ? (