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
133 changes: 133 additions & 0 deletions src/app/issues/IssuesFilterForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IssuesFilterForm>
<select name="difficulty" defaultValue="all">
<option value="all">Any difficulty</option>
<option value="beginner">Beginner</option>
</select>
<select name="asset" defaultValue="all">
<option value="all">Any asset</option>
<option value="USDC">USDC</option>
</select>
<input name="minReward" defaultValue="" />
<input name="maxReward" defaultValue="" />
<select name="sort" defaultValue="default">
<option value="default">Default</option>
<option value="reward-desc">Reward</option>
</select>
<button type="submit">Apply</button>
</IssuesFilterForm>,
);

const form = container.querySelector("form")!;
const difficultySelect = container.querySelector<HTMLSelectElement>('select[name="difficulty"]')!;
const assetSelect = container.querySelector<HTMLSelectElement>('select[name="asset"]')!;
const minRewardInput = container.querySelector<HTMLInputElement>('input[name="minReward"]')!;
const maxRewardInput = container.querySelector<HTMLInputElement>('input[name="maxReward"]')!;
const sortSelect = container.querySelector<HTMLSelectElement>('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(
<IssuesFilterForm>
<input type="hidden" name="status" value="open" />
<select name="difficulty" defaultValue="beginner">
<option value="all">Any difficulty</option>
<option value="beginner">Beginner</option>
</select>
<select name="asset" defaultValue="all">
<option value="all">Any asset</option>
<option value="USDC">USDC</option>
</select>
<input name="minReward" defaultValue="50" />
<input name="maxReward" defaultValue="" />
<select name="sort" defaultValue="reward-desc">
<option value="default">Default</option>
<option value="reward-desc">Reward</option>
</select>
<button type="submit">Apply</button>
</IssuesFilterForm>,
);

const form = container.querySelector("form")!;
const statusInput = container.querySelector<HTMLInputElement>('input[name="status"]')!;
const difficultySelect = container.querySelector<HTMLSelectElement>('select[name="difficulty"]')!;
const assetSelect = container.querySelector<HTMLSelectElement>('select[name="asset"]')!;
const minRewardInput = container.querySelector<HTMLInputElement>('input[name="minReward"]')!;
const maxRewardInput = container.querySelector<HTMLInputElement>('input[name="maxReward"]')!;
const sortSelect = container.querySelector<HTMLSelectElement>('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<HTMLFormElement>) => {
e.preventDefault();
});

const { container } = render(
<IssuesFilterForm onSubmit={onSubmit}>
<select name="difficulty" defaultValue="all">
<option value="all">Any difficulty</option>
</select>
<input name="minReward" defaultValue="" />
</IssuesFilterForm>,
);

const form = container.querySelector("form")!;
const difficultySelect = container.querySelector<HTMLSelectElement>('select[name="difficulty"]')!;
const minRewardInput = container.querySelector<HTMLInputElement>('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);
});
});
66 changes: 66 additions & 0 deletions src/app/issues/IssuesFilterForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import type { ComponentPropsWithoutRef } from "react";

const DEFAULT_FILTER_VALUES: Record<string, string> = {
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 (
<form
{...props}
onSubmit={(e) => {
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);
}
}
}}
/>
);
}
12 changes: 7 additions & 5 deletions src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -124,12 +125,13 @@ export default async function IssuesPage({
</span>
</div>

{/* 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. */}
<form
<IssuesFilterForm
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"
Expand Down Expand Up @@ -218,7 +220,7 @@ export default async function IssuesPage({
Clear all
</Link>
)}
</form>
</IssuesFilterForm>

{visible.length === 0 ? (
filterActive ? (
Expand Down