diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index ee3016b..00dd35f 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -9,7 +9,18 @@ import { Button } from "@/components/ui/Button"; import { DataSourceNotice } from "@/components/ui/DataSourceNotice"; import { formatInteger } from "@/lib/utils"; import { messages, t } from "@/lib/messages"; -import type { Bounty } from "@/types"; +import { + applyBountyQuery, + buildIssuesHref, + isFilterActive, + parseBountyQuery, + SORT_LABELS, + SORT_VALUES, + STATUS_VALUES, + DIFFICULTY_VALUES, + ASSET_VALUES, + type RawSearchParams, +} from "@/lib/bounty-query"; const issuesDescription = "Browse paid, escrow-backed GitHub issues funded through MergeFi and ready for contributors."; @@ -37,32 +48,34 @@ const STATUS_FILTERS = [ { value: "all", label: "All" }, { value: "open", label: "Open" }, { value: "in_review", label: "In review" }, -] as const; +] as const satisfies readonly { value: (typeof STATUS_VALUES)[number]; label: string }[]; -type StatusFilter = (typeof STATUS_FILTERS)[number]["value"]; - -function coerceStatusFilter(raw: string | string[] | undefined): StatusFilter { - const value = Array.isArray(raw) ? raw[0] : raw; - return STATUS_FILTERS.some((f) => f.value === value) ? (value as StatusFilter) : "all"; -} +const DIFFICULTY_LABELS: Record<(typeof DIFFICULTY_VALUES)[number], string> = { + all: "Any difficulty", + beginner: "Beginner", + intermediate: "Intermediate", + advanced: "Advanced", + expert: "Expert", +}; -function applyStatusFilter(bounties: Bounty[], filter: StatusFilter): Bounty[] { - if (filter === "all") return bounties; - return bounties.filter((b) => b.status === filter); -} +const ASSET_LABELS: Record<(typeof ASSET_VALUES)[number], string> = { + all: "Any asset", + USDC: "USDC", + XLM: "XLM", +}; export default async function IssuesPage({ searchParams, }: { // Next 16 App Router: searchParams is a promise on server components. - searchParams: Promise>; + searchParams: Promise; }) { const params = await searchParams; - const status = coerceStatusFilter(params.status); - const filterActive = status !== "all"; + const query = parseBountyQuery(params); + const filterActive = isFilterActive(query); - const { data: bounties, source } = await fetchBounties(mockBounties); - const visible = applyStatusFilter(bounties, status); + const { data: bounties, source } = await fetchBounties(mockBounties, query); + const { items: visible, page, totalPages, filteredCount } = applyBountyQuery(bounties, query); const total = bounties.length; return ( @@ -87,14 +100,14 @@ export default async function IssuesPage({ the other. */} {source === "mock" && } -
+
Status: {STATUS_FILTERS.map((f) => { - const active = f.value === status; + const active = f.value === query.status; return ( - {formatInteger(visible.length)} of {formatInteger(total)} + {formatInteger(filteredCount)} of {formatInteger(total)}
+ {/* 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 + 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 + + )} +
+ {visible.length === 0 ? ( filterActive ? ( /* Filtered-to-zero. Distinguished from a genuinely empty platform @@ -154,6 +263,41 @@ export default async function IssuesPage({ ))}
+ + {totalPages > 1 && ( + + )} )} diff --git a/src/components/ui/Avatar.test.tsx b/src/components/ui/Avatar.test.tsx index 9cfaf40..9cab042 100644 --- a/src/components/ui/Avatar.test.tsx +++ b/src/components/ui/Avatar.test.tsx @@ -1,13 +1,16 @@ /** * Avatar.test.tsx * - * Covers Avatar render logic (src vs fallback URL, unoptimized flag) and - * AvatarStack overflow counting (under-max, at-max, over-max) — #278. + * Covers Avatar render logic (src vs fallback URL, unoptimized flag), + * malicious/unallowlisted `src` rejection and load-failure fallback (#20), + * and AvatarStack overflow counting (under-max, at-max, over-max) — #278. */ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { Avatar, AvatarStack } from "./Avatar"; +const GITHUB_AVATAR = "https://avatars.githubusercontent.com/u/1?v=4"; + describe("Avatar", () => { it("renders with the given seed as alt text", () => { render(); @@ -15,11 +18,11 @@ describe("Avatar", () => { expect(img).toBeInTheDocument(); }); - it("uses the src prop when provided", () => { - render(); + it("uses the src prop when it's on the allowlisted GitHub avatar host", () => { + render(); const img = screen.getByRole("img", { name: "alice" }); // Next.js Image rewrites the src through its optimization pipeline - expect(img.getAttribute("src")).toContain("example.com%2Falice.png"); + expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com"); }); it("falls back to a dicebear URL when no src is provided", () => { @@ -36,11 +39,10 @@ describe("Avatar", () => { expect(img.getAttribute("src")).toContain("dicebear.com"); }); - it("does not set unoptimized for custom src URLs", () => { - render(); + it("does not set unoptimized for allowlisted custom src URLs", () => { + render(); const img = screen.getByRole("img", { name: "dave" }); - // Next.js Image rewrites src through optimization; just verify it's present - expect(img.getAttribute("src")).toContain("example.com"); + expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com"); }); it("applies custom size dimensions", () => { @@ -55,6 +57,36 @@ describe("Avatar", () => { const img = screen.getByRole("img", { name: "frank" }); expect(img.className).toMatch(/test-extra/); }); + + it("falls back to the dicebear identicon for a src on a non-allowlisted host", () => { + render(); + const img = screen.getByRole("img", { name: "gina" }); + expect(img.getAttribute("src")).toContain("dicebear.com"); + }); + + it("falls back to the dicebear identicon for a javascript: scheme src", () => { + render(); + const img = screen.getByRole("img", { name: "hank" }); + expect(img.getAttribute("src")).toContain("dicebear.com"); + }); + + it("falls back to the dicebear identicon for a data: scheme src", () => { + render(); + const img = screen.getByRole("img", { name: "ivy" }); + expect(img.getAttribute("src")).toContain("dicebear.com"); + }); + + it("falls back to the dicebear identicon when the allowlisted src fails to load", () => { + render(); + const img = screen.getByRole("img", { name: "jack" }); + expect(img.getAttribute("src")).toContain("avatars.githubusercontent.com"); + + fireEvent.error(img); + + expect(screen.getByRole("img", { name: "jack" }).getAttribute("src")).toContain( + "dicebear.com", + ); + }); }); describe("AvatarStack", () => { diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index 8abdd37..f0b7782 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -1,10 +1,31 @@ +"use client"; + import Image from "next/image"; +import { useState } from "react"; import { cn } from "@/lib/utils"; function seedToUrl(seed: string) { return `https://api.dicebear.com/9.x/identicon/svg?seed=${encodeURIComponent(seed)}&backgroundType=gradientLinear`; } +/** + * Must match next.config.ts's `images.remotePatterns` — the only hosts an + * avatar `src` is ever allowed to resolve to (#20). Backend `avatarUrl` is + * typed only as `string | null`, not validated as a safe image URL, so a + * `javascript:`/`data:` scheme or an unexpected third-party host must be + * rejected here rather than trusted through to ``. + */ +const ALLOWED_AVATAR_HOSTS = new Set(["avatars.githubusercontent.com", "api.dicebear.com"]); + +function isSafeAvatarUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && ALLOWED_AVATAR_HOSTS.has(url.hostname); + } catch { + return false; + } +} + export function Avatar({ seed, src, @@ -16,13 +37,27 @@ export function Avatar({ size?: number; className?: string; }) { + const fallback = seedToUrl(seed); + const requested = src && isSafeAvatarUrl(src) ? src : fallback; + + // Falls back to the dicebear identicon on a real load failure (404, + // unreachable host, ...) in addition to the safety check above. Tracking + // the specific src that errored — not a plain boolean — means a later + // render with a *different* `src` prop (e.g. the user updates their GitHub + // avatar) retries instead of staying stuck on the old fallback, while a + // repeat failure of the same src stays on the fallback instead of + // retrying it every render. + const [erroredSrc, setErroredSrc] = useState(null); + const resolved = erroredSrc === requested ? fallback : requested; + return ( {seed} setErroredSrc(requested)} className={cn( "rounded-full border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800", className, diff --git a/src/lib/api.ts b/src/lib/api.ts index 4efa30f..18374b0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,7 @@ import { type RawUserProfile, } from "./adapters"; import type { Bounty, Milestone, MaintenancePool, ReputationProfile } from "@/types"; +import { buildBountyQueryString, type BountyQuery } from "./bounty-query"; export class ApiUnavailableError extends Error {} @@ -173,9 +174,21 @@ export function apiPost(path: string, body?: unknown): Promise { * into the flat shapes the UI renders, falling back to mock data (already in * the target shape) when the backend is unreachable. */ -export async function fetchBounties(fallback: Bounty[]): Promise> { +/** + * `query`, if given, is forwarded to the backend as query params (#28) so + * this is ready for server-side filtering once `/bounties` supports it — + * today the backend ignores unknown params and returns the full unfiltered + * collection, which callers must still run through + * `applyBountyQuery`/`filterBounties` themselves (see src/lib/bounty-query.ts) + * to get correct behavior against both live and mock-fallback data. + */ +export async function fetchBounties( + fallback: Bounty[], + query?: BountyQuery, +): Promise> { + const qs = query ? buildBountyQueryString(query) : ""; try { - const raw = await request("/bounties"); + const raw = await request(`/bounties${qs}`); return { data: raw.map(adaptBounty), source: "live" }; } catch { return { data: fallback, source: "mock" }; diff --git a/src/lib/bounty-query.test.ts b/src/lib/bounty-query.test.ts new file mode 100644 index 0000000..9288c63 --- /dev/null +++ b/src/lib/bounty-query.test.ts @@ -0,0 +1,229 @@ +/** + * bounty-query.test.ts (#28) + * + * Covers the filter/sort/pagination pipeline that backs the bounty board's + * URL-driven query params: parsing degrades bad input instead of erroring, + * filtering/sorting/pagination compose correctly, and href-building merges + * overrides onto the current query while resetting `page`. + */ + +import { + applyBountyQuery, + buildBountyQueryString, + buildIssuesHref, + filterBounties, + isFilterActive, + parseBountyQuery, +} from "./bounty-query"; +import type { Bounty } from "@/types"; + +function bounty(overrides: Partial): Bounty { + return { + id: "b1", + title: "t", + description: "d", + reward: 100, + asset: "USDC", + difficulty: "intermediate", + status: "open", + org: "org", + repo: "repo", + issueNumber: 1, + labels: [], + deadline: null, + ...overrides, + }; +} + +describe("parseBountyQuery", () => { + it("defaults every field when params are empty", () => { + expect(parseBountyQuery({})).toEqual({ + status: "all", + difficulty: "all", + asset: "all", + minReward: undefined, + maxReward: undefined, + sort: "default", + page: 1, + }); + }); + + it("degrades an unrecognized enum value to its default instead of erroring", () => { + const query = parseBountyQuery({ status: "bogus", difficulty: "wizard", asset: "BTC" }); + expect(query.status).toBe("all"); + expect(query.difficulty).toBe("all"); + expect(query.asset).toBe("all"); + }); + + it("clamps a non-numeric or sub-1 page to 1", () => { + expect(parseBountyQuery({ page: "not-a-number" }).page).toBe(1); + expect(parseBountyQuery({ page: "0" }).page).toBe(1); + expect(parseBountyQuery({ page: "-5" }).page).toBe(1); + }); + + it("parses a valid page number", () => { + expect(parseBountyQuery({ page: "3" }).page).toBe(3); + }); + + it("drops an inverted reward range's max instead of producing an always-empty filter", () => { + const query = parseBountyQuery({ minReward: "200", maxReward: "50" }); + expect(query.minReward).toBe(200); + expect(query.maxReward).toBeUndefined(); + }); + + it("ignores a negative or non-numeric reward bound", () => { + expect(parseBountyQuery({ minReward: "-10" }).minReward).toBeUndefined(); + expect(parseBountyQuery({ maxReward: "nope" }).maxReward).toBeUndefined(); + }); + + it("takes the first value when a param repeats", () => { + expect(parseBountyQuery({ status: ["open", "in_review"] }).status).toBe("open"); + }); +}); + +describe("isFilterActive", () => { + it("is false for the all-defaults query", () => { + expect(isFilterActive(parseBountyQuery({}))).toBe(false); + }); + + it("is true when any facet is set", () => { + expect(isFilterActive(parseBountyQuery({ difficulty: "beginner" }))).toBe(true); + expect(isFilterActive(parseBountyQuery({ minReward: "10" }))).toBe(true); + }); +}); + +describe("filterBounties", () => { + const bounties = [ + bounty({ id: "a", status: "open", difficulty: "beginner", asset: "USDC", reward: 50 }), + bounty({ id: "b", status: "in_review", difficulty: "expert", asset: "XLM", reward: 500 }), + bounty({ id: "c", status: "open", difficulty: "advanced", asset: "USDC", reward: 200 }), + ]; + + it("combines status, difficulty, asset and reward-range filters", () => { + const result = filterBounties(bounties, { + status: "open", + difficulty: "all", + asset: "USDC", + minReward: 100, + maxReward: 300, + sort: "default", + page: 1, + }); + expect(result.map((b) => b.id)).toEqual(["c"]); + }); + + it("returns everything when no facet is active", () => { + const result = filterBounties(bounties, { + status: "all", + difficulty: "all", + asset: "all", + sort: "default", + page: 1, + }); + expect(result).toHaveLength(3); + }); +}); + +describe("applyBountyQuery", () => { + const bounties = Array.from({ length: 25 }, (_, i) => + bounty({ id: `b${i}`, reward: i, deadline: null }), + ); + + it("paginates with the given page size", () => { + const result = applyBountyQuery( + bounties, + { status: "all", difficulty: "all", asset: "all", sort: "default", page: 1 }, + 10, + ); + expect(result.items).toHaveLength(10); + expect(result.totalPages).toBe(3); + expect(result.filteredCount).toBe(25); + }); + + it("clamps a page number beyond the last page to the last page", () => { + const result = applyBountyQuery( + bounties, + { status: "all", difficulty: "all", asset: "all", sort: "default", page: 999 }, + 10, + ); + expect(result.page).toBe(3); + expect(result.items).toHaveLength(5); + }); + + it("sorts by reward descending", () => { + const result = applyBountyQuery( + bounties, + { status: "all", difficulty: "all", asset: "all", sort: "reward-desc", page: 1 }, + 10, + ); + expect(result.items[0].reward).toBe(24); + }); + + it("puts bounties without a deadline last when sorting by soonest deadline", () => { + const withDeadlines = [ + bounty({ id: "x", deadline: null, reward: 1 }), + bounty({ id: "y", deadline: new Date(Date.now() + 86400000).toISOString(), reward: 2 }), + bounty({ id: "z", deadline: new Date(Date.now() + 3600000).toISOString(), reward: 3 }), + ]; + const result = applyBountyQuery( + withDeadlines, + { status: "all", difficulty: "all", asset: "all", sort: "deadline-asc", page: 1 }, + 10, + ); + expect(result.items.map((b) => b.id)).toEqual(["z", "y", "x"]); + }); +}); + +describe("buildBountyQueryString", () => { + it("omits every param at its default", () => { + expect( + buildBountyQueryString({ + status: "all", + difficulty: "all", + asset: "all", + sort: "default", + page: 1, + }), + ).toBe(""); + }); + + it("includes only the non-default params", () => { + const qs = buildBountyQueryString({ + status: "open", + difficulty: "all", + asset: "USDC", + minReward: 50, + sort: "default", + page: 1, + }); + expect(qs).toBe("?status=open&asset=USDC&minReward=50"); + }); +}); + +describe("buildIssuesHref", () => { + it("returns the bare path when nothing is active", () => { + expect(buildIssuesHref({}, {})).toBe("/issues"); + }); + + it("merges an override onto the current params", () => { + expect(buildIssuesHref({ status: "open" }, { difficulty: "beginner" })).toBe( + "/issues?status=open&difficulty=beginner", + ); + }); + + it("resets page to 1 when a non-page override is applied", () => { + expect(buildIssuesHref({ status: "open", page: "4" }, { difficulty: "beginner" })).toBe( + "/issues?status=open&difficulty=beginner", + ); + }); + + it("preserves an explicit page override", () => { + expect(buildIssuesHref({ status: "open" }, { page: "2" })).toBe( + "/issues?status=open&page=2", + ); + }); + + it("clearing a facet back to its default drops it from the URL", () => { + expect(buildIssuesHref({ status: "open" }, { status: undefined })).toBe("/issues"); + }); +}); diff --git a/src/lib/bounty-query.ts b/src/lib/bounty-query.ts new file mode 100644 index 0000000..8551bb7 --- /dev/null +++ b/src/lib/bounty-query.ts @@ -0,0 +1,244 @@ +import type { Bounty } from "@/types"; + +/** + * URL-driven filter/sort/pagination contract for the bounty board (#28). + * + * The backend's `/bounties` endpoint does not currently accept query + * params (verified: `RawBounty[]` comes back unfiltered regardless of what + * `fetchBounties` forwards), so this module also doubles as the enforcement + * point: `applyBountyQuery` is run against whatever `fetchBounties` returns + * — live or mock — so filtering/sorting/pagination is always correct + * without depending on backend support landing first. If/when the backend + * does start honoring these params server-side, applying this filter again + * here is a no-op on already-filtered data, so nothing needs to change on + * the frontend to benefit from it (only the unfiltered-payload-size win + * requires removing the client-side pass). + */ + +export const PAGE_SIZE = 10; + +export const STATUS_VALUES = ["all", "open", "in_review"] as const; +export type StatusFilter = (typeof STATUS_VALUES)[number]; + +export const DIFFICULTY_VALUES = [ + "all", + "beginner", + "intermediate", + "advanced", + "expert", +] as const; +export type DifficultyFilter = (typeof DIFFICULTY_VALUES)[number]; + +export const ASSET_VALUES = ["all", "USDC", "XLM"] as const; +export type AssetFilter = (typeof ASSET_VALUES)[number]; + +export const SORT_VALUES = ["default", "reward-desc", "reward-asc", "deadline-asc"] as const; +export type SortOption = (typeof SORT_VALUES)[number]; + +export const SORT_LABELS: Record = { + default: "Newest", + "reward-desc": "Reward: high to low", + "reward-asc": "Reward: low to high", + "deadline-asc": "Deadline: soonest", +}; + +export interface BountyQuery { + status: StatusFilter; + difficulty: DifficultyFilter; + asset: AssetFilter; + minReward?: number; + maxReward?: number; + sort: SortOption; + page: number; +} + +export type RawSearchParams = Record; + +export function firstParam(raw: string | string[] | undefined): string | undefined { + return Array.isArray(raw) ? raw[0] : raw; +} + +function coerceEnum( + raw: string | string[] | undefined, + values: readonly T[], + fallback: T, +): T { + const value = firstParam(raw); + return (values as readonly string[]).includes(value ?? "") ? (value as T) : fallback; +} + +function coerceNonNegativeNumber(raw: string | string[] | undefined): number | undefined { + const value = firstParam(raw); + if (value === undefined || value === "") return undefined; + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : undefined; +} + +function coercePage(raw: string | string[] | undefined): number { + const value = firstParam(raw); + const n = value !== undefined ? parseInt(value, 10) : 1; + return Number.isFinite(n) && n >= 1 ? n : 1; +} + +/** + * Parses raw `searchParams` into a normalized query, degrading invalid or + * out-of-range values (unrecognized enum values, a non-numeric page, an + * inverted min/max reward range) to sensible defaults instead of erroring. + */ +export function parseBountyQuery(params: RawSearchParams): BountyQuery { + const minReward = coerceNonNegativeNumber(params.minReward); + const maxRewardRaw = coerceNonNegativeNumber(params.maxReward); + // An inverted range (min > max) can't ever match — treat it as "no max" + // rather than silently producing a permanently-empty result set. + const maxReward = + minReward !== undefined && maxRewardRaw !== undefined && maxRewardRaw < minReward + ? undefined + : maxRewardRaw; + + return { + status: coerceEnum(params.status, STATUS_VALUES, "all"), + difficulty: coerceEnum(params.difficulty, DIFFICULTY_VALUES, "all"), + asset: coerceEnum(params.asset, ASSET_VALUES, "all"), + minReward, + maxReward, + sort: coerceEnum(params.sort, SORT_VALUES, "default"), + page: coercePage(params.page), + }; +} + +export function isFilterActive(query: BountyQuery): boolean { + return ( + query.status !== "all" || + query.difficulty !== "all" || + query.asset !== "all" || + query.minReward !== undefined || + query.maxReward !== undefined + ); +} + +export function filterBounties(bounties: Bounty[], query: BountyQuery): Bounty[] { + return bounties.filter((b) => { + if (query.status !== "all" && b.status !== query.status) return false; + if (query.difficulty !== "all" && b.difficulty !== query.difficulty) return false; + if (query.asset !== "all" && b.asset !== query.asset) return false; + if (query.minReward !== undefined && b.reward < query.minReward) return false; + if (query.maxReward !== undefined && b.reward > query.maxReward) return false; + return true; + }); +} + +function sortBounties(bounties: Bounty[], sort: SortOption): Bounty[] { + const sorted = [...bounties]; + switch (sort) { + case "reward-desc": + sorted.sort((a, b) => b.reward - a.reward); + break; + case "reward-asc": + sorted.sort((a, b) => a.reward - b.reward); + break; + case "deadline-asc": + sorted.sort((a, b) => { + if (!a.deadline && !b.deadline) return 0; + if (!a.deadline) return 1; + if (!b.deadline) return -1; + return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + }); + break; + case "default": + break; + } + return sorted; +} + +export interface BountyQueryResult { + items: Bounty[]; + page: number; + totalPages: number; + /** Count after filtering, before pagination — what "N of total" reports. */ + filteredCount: number; +} + +/** + * Applies the full filter → sort → paginate pipeline. An out-of-range page + * (too high, zero, negative, non-numeric) clamps to the nearest valid page + * rather than rendering an error or a blank page. + */ +export function applyBountyQuery( + bounties: Bounty[], + query: BountyQuery, + pageSize = PAGE_SIZE, +): BountyQueryResult { + const filtered = filterBounties(bounties, query); + const sorted = sortBounties(filtered, query.sort); + const filteredCount = sorted.length; + const totalPages = Math.max(1, Math.ceil(filteredCount / pageSize)); + const page = Math.min(Math.max(1, query.page), totalPages); + const start = (page - 1) * pageSize; + + return { + items: sorted.slice(start, start + pageSize), + page, + totalPages, + filteredCount, + }; +} + +/** + * Forwards the filter/sort contract to the backend as query params, ahead + * of backend support existing, so the frontend needs no further change once + * `/bounties` starts honoring them server-side (#28). + */ +export function buildBountyQueryString(query: BountyQuery): string { + const sp = new URLSearchParams(); + if (query.status !== "all") sp.set("status", query.status); + if (query.difficulty !== "all") sp.set("difficulty", query.difficulty); + if (query.asset !== "all") sp.set("asset", query.asset); + if (query.minReward !== undefined) sp.set("minReward", String(query.minReward)); + if (query.maxReward !== undefined) sp.set("maxReward", String(query.maxReward)); + if (query.sort !== "default") sp.set("sort", query.sort); + const qs = sp.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Builds an `/issues` href that merges `overrides` into the current query + * params, dropping any param back to its default (omitted from the URL). + * Changing a filter always resets `page` back to 1, since the previous + * page number is unlikely to still be valid against the new result set. + */ +export function buildIssuesHref( + current: RawSearchParams, + overrides: Partial>, +): string { + const keys: (keyof BountyQuery)[] = [ + "status", + "difficulty", + "asset", + "minReward", + "maxReward", + "sort", + "page", + ]; + const defaults: Record = { + status: "all", + difficulty: "all", + asset: "all", + sort: "default", + page: "1", + }; + const resetsPage = Object.keys(overrides).some((k) => k !== "page"); + const sp = new URLSearchParams(); + for (const key of keys) { + const value = + key in overrides + ? overrides[key] + : key === "page" && resetsPage + ? "1" + : firstParam(current[key]); + if (value === undefined || value === "") continue; + if (defaults[key] === value) continue; + sp.set(key, value); + } + const qs = sp.toString(); + return qs ? `/issues?${qs}` : "/issues"; +}