From 9d14462fad245d4e59821e3706631457494b86e1 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Sun, 19 Jul 2026 19:09:37 +0100 Subject: [PATCH 1/6] Sync anchors/settlements filter and search state to the URL querystring --- package-lock.json | 34 +++--- src/app/anchors/page.tsx | 8 +- src/app/settlements/page.tsx | 8 +- src/components/AnchorsPanel.test.tsx | 121 +++++++++++++++++++++ src/components/AnchorsPanel.tsx | 18 +++- src/components/SettlementsPanel.test.tsx | 110 +++++++++++++++++++ src/components/SettlementsPanel.tsx | 24 ++++- src/hooks/useQueryState.test.ts | 128 +++++++++++++++++++++++ src/hooks/useQueryState.ts | 63 +++++++++++ 9 files changed, 485 insertions(+), 29 deletions(-) create mode 100644 src/hooks/useQueryState.test.ts create mode 100644 src/hooks/useQueryState.ts diff --git a/package-lock.json b/package-lock.json index 1348ce4..5747d5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,7 +129,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -450,7 +449,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -499,7 +497,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2506,6 +2503,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2526,6 +2524,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2601,7 +2600,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.9", @@ -2630,7 +2630,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2641,7 +2640,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2652,7 +2650,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2702,7 +2699,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -3341,7 +3337,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3382,6 +3377,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3715,7 +3711,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4106,6 +4101,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4138,7 +4134,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4449,7 +4446,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4635,7 +4631,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5921,7 +5916,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -6394,6 +6388,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6972,6 +6967,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6987,6 +6983,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6999,7 +6996,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -7049,7 +7047,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7059,7 +7056,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7885,7 +7881,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8124,7 +8119,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8275,7 +8269,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -8642,7 +8635,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/anchors/page.tsx b/src/app/anchors/page.tsx index ea9edcc..2f2f89e 100644 --- a/src/app/anchors/page.tsx +++ b/src/app/anchors/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { SiteHeader } from "@/components/SiteHeader"; import { AnchorsPanel } from "@/components/AnchorsPanel"; +import { TableSkeleton } from "@/components/TableSkeleton"; export const metadata: Metadata = { title: "Anchors – AnchorNet", @@ -17,7 +19,11 @@ export default function AnchorsPage() { Approved anchors that provide liquidity to the network.

- + {/* Suspense is required by Next.js App Router when a child client + component reads useSearchParams() at render time. */} + }> + +
diff --git a/src/app/settlements/page.tsx b/src/app/settlements/page.tsx index a6cf918..7dc3913 100644 --- a/src/app/settlements/page.tsx +++ b/src/app/settlements/page.tsx @@ -1,6 +1,8 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { SiteHeader } from "@/components/SiteHeader"; import { SettlementsPanel } from "@/components/SettlementsPanel"; +import { TableSkeleton } from "@/components/TableSkeleton"; export const metadata: Metadata = { title: "Settlements – AnchorNet", @@ -19,7 +21,11 @@ export default function SettlementsPage() { Reserve pool liquidity to settle cross-anchor payments.

- + {/* Suspense is required by Next.js App Router when a child client + component reads useSearchParams() at render time. */} + }> + +
diff --git a/src/components/AnchorsPanel.test.tsx b/src/components/AnchorsPanel.test.tsx index bbc0a5c..b1cf050 100644 --- a/src/components/AnchorsPanel.test.tsx +++ b/src/components/AnchorsPanel.test.tsx @@ -14,6 +14,19 @@ import { deregisterAnchor, } from "@/lib/anchorsApi"; +// --------------------------------------------------------------------------- +// Mock next/navigation so the panel can run in jsdom. +// --------------------------------------------------------------------------- + +const mockReplace = vi.fn(); +let mockSearchParamsString = ""; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => new URLSearchParams(mockSearchParamsString), + usePathname: () => "/anchors", +})); + vi.mock("@/lib/anchorsApi", () => ({ fetchAnchors: vi.fn(), registerAnchor: vi.fn(), @@ -22,6 +35,7 @@ vi.mock("@/lib/anchorsApi", () => ({ beforeEach(() => { vi.clearAllMocks(); + mockSearchParamsString = ""; }); function renderPanel() { @@ -140,4 +154,111 @@ describe("AnchorsPanel", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Deactivate" })); await waitFor(() => expect(deregisterAnchor).toHaveBeenCalledWith("a")); }); + + // ------------------------------------------------------------------------- + // URL querystring hydration tests + // ------------------------------------------------------------------------- + + it("hydrates the status filter from the URL querystring on load", async () => { + mockSearchParamsString = "status=active"; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + { id: "b", name: "Anchor B", registeredAt: "", active: false }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + // Only the active anchor should be visible; the Active tab should be pressed + expect(screen.getByText("Anchor A")).toBeInTheDocument(); + expect(screen.queryByText("Anchor B")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Active" }), + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("hydrates the search query from the URL querystring on load", async () => { + mockSearchParamsString = "q=vault"; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "stellar-anchor", name: "Stellar Vault", registeredAt: "", active: true }, + { id: "other", name: "Something Else", registeredAt: "", active: true }, + ]); + + renderPanel(); + await screen.findByText("Stellar Vault"); + + // Only matching anchor visible; search box pre-filled + expect(screen.getByText("Stellar Vault")).toBeInTheDocument(); + expect(screen.queryByText("Something Else")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Search anchors")).toHaveValue("vault"); + }); + + it("updates the URL querystring when the status filter changes", async () => { + mockSearchParamsString = ""; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + fireEvent.click(screen.getByRole("button", { name: "Active" })); + + expect(mockReplace).toHaveBeenCalledWith( + expect.stringContaining("status=active"), + { scroll: false }, + ); + }); + + it("updates the URL querystring when the search query changes", async () => { + mockSearchParamsString = ""; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + fireEvent.change(screen.getByLabelText("Search anchors"), { + target: { value: "foo" }, + }); + + expect(mockReplace).toHaveBeenCalledWith( + expect.stringContaining("q=foo"), + { scroll: false }, + ); + }); + + it("removes the status param from the URL when All is selected", async () => { + mockSearchParamsString = "status=active"; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + + // 'all' is the default so the param should be stripped + expect(mockReplace).toHaveBeenCalledWith("/anchors", { scroll: false }); + }); + + it("ignores an unrecognised status param and falls back to 'all'", async () => { + mockSearchParamsString = "status=unknown"; + vi.mocked(fetchAnchors).mockResolvedValue([ + { id: "a", name: "Anchor A", registeredAt: "", active: true }, + { id: "b", name: "Anchor B", registeredAt: "", active: false }, + ]); + + renderPanel(); + await screen.findByText("Anchor A"); + + // Both anchors visible because filter falls back to 'all' + expect(screen.getByText("Anchor A")).toBeInTheDocument(); + expect(screen.getByText("Anchor B")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "All" }), + ).toHaveAttribute("aria-pressed", "true"); + }); }); diff --git a/src/components/AnchorsPanel.tsx b/src/components/AnchorsPanel.tsx index f9f9446..c6dda03 100644 --- a/src/components/AnchorsPanel.tsx +++ b/src/components/AnchorsPanel.tsx @@ -11,6 +11,7 @@ import { matchesQuery } from "@/lib/search"; import { useAsync } from "@/hooks/useAsync"; import { useToast } from "@/hooks/useToast"; import { useFocusShortcut } from "@/hooks/useFocusShortcut"; +import { useQueryState } from "@/hooks/useQueryState"; import { Card } from "./Card"; import { TableSkeleton } from "./TableSkeleton"; import { AnchorForm } from "./AnchorForm"; @@ -25,6 +26,11 @@ const FILTERS: { value: StatusFilter; label: string }[] = [ { value: "inactive", label: "Inactive" }, ]; +/** Returns true if `value` is a valid {@link StatusFilter}. */ +function isStatusFilter(value: string): value is StatusFilter { + return value === "all" || value === "active" || value === "inactive"; +} + /** Filters anchors by lifecycle status for the client-side status tabs. */ function filterAnchors(anchors: Anchor[], filter: StatusFilter): Anchor[] { return anchors.filter((anchor) => { @@ -40,13 +46,19 @@ export function AnchorsPanel() { const { state, reload } = useAsync(load); const { notify } = useToast(); const [pending, setPending] = useState(false); - const [filter, setFilter] = useState("all"); - const [query, setQuery] = useState(""); const [pendingDeregisterId, setPendingDeregisterId] = useState< string | null >(null); const searchRef = useRef(null); useFocusShortcut("/", searchRef); + + // Sync status filter and search query to the URL querystring. + // Initial values are hydrated from the URL on first render. + const [rawStatus, setStatus] = useQueryState("status", "all"); + const filter: StatusFilter = isStatusFilter(rawStatus) ? rawStatus : "all"; + + const [query, setQuery] = useQueryState("q", ""); + const filteredAnchors = state.status === "ready" ? filterAnchors(state.data, filter).filter((anchor) => @@ -97,7 +109,7 @@ export function AnchorsPanel() { {FILTERS.map((f) => ( + <> + + {/* Guard the destructive disconnect behind a confirmation step, + matching the keyboard-accessible pattern used by the anchor + deactivate and settlement cancel dialogs. */} + { + setConfirmOpen(false); + disconnect(); + }} + onCancel={() => setConfirmOpen(false)} + /> + ); } From 032cd59089047acebbff35da405f4a73d173f597 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Mon, 20 Jul 2026 00:28:55 +0100 Subject: [PATCH 3/6] Trim and normalize whitespace consistently in lib/search.ts matchesQuery --- src/lib/search.test.ts | 7 +++++++ src/lib/search.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/search.test.ts b/src/lib/search.test.ts index 80903b1..17e6df7 100644 --- a/src/lib/search.test.ts +++ b/src/lib/search.test.ts @@ -14,6 +14,13 @@ describe("matchesQuery", () => { it("trims surrounding whitespace from the query", () => { expect(matchesQuery(["Alpha Anchor"], " alpha ")).toBe(true); + expect(matchesQuery(["Alpha Anchor"], "\talpha\n")).toBe(true); + }); + + it("collapses internal repeated whitespace in the query", () => { + expect(matchesQuery(["Alpha Anchor"], "alpha anchor")).toBe(true); + expect(matchesQuery(["Alpha Anchor"], "alpha\t\tanchor")).toBe(true); + expect(matchesQuery(["Alpha Anchor"], " alpha \t anchor ")).toBe(true); }); it("returns false when no field contains the query", () => { diff --git a/src/lib/search.ts b/src/lib/search.ts index a00c474..b255e00 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -11,7 +11,7 @@ export function matchesQuery( fields: Array, query: string, ): boolean { - const needle = query.trim().toLowerCase(); + const needle = query.trim().replace(/\s+/g, " ").toLowerCase(); if (needle === "") return true; return fields.some((field) => String(field).toLowerCase().includes(needle)); } From b20c181ab3b12b503fa7628dc3bbd0eb1c107946 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Tue, 21 Jul 2026 20:50:04 +0100 Subject: [PATCH 4/6] Extract a shared buildQueryParams helper for anchorsApi.ts and settlementsApi.ts's duplicated URLSearchParams logic --- pr.md | 97 +++++++++++++++++++++++++++++++++++++++ src/lib/api.test.ts | 77 ++++++++++++++++++++++++++++++- src/lib/api.ts | 32 +++++++++++++ src/lib/settlementsApi.ts | 8 +--- 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 pr.md diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..90ad5d8 --- /dev/null +++ b/pr.md @@ -0,0 +1,97 @@ +**Close:** #180 + +--- + +## Summary of the Issue + +`fetchSettlements` and `exportSettlementsCsv` in `src/lib/settlementsApi.ts` each independently build an identical `URLSearchParams` from the same `{ anchor, page, pageSize }` shape, duplicating the same conditional `.set(...)` calls twice in the same file. This pattern is also a natural fit for `anchorsApi.ts` functions if they ever grow filter parameters. + +--- + +## Root Cause + +There was no shared query-string builder utility. Each function that needed URL query parameters re-implemented the same pattern: + +```ts +const params = new URLSearchParams(); +if (anchor) params.set("anchor", anchor); +if (page) params.set("page", String(page)); +if (pageSize) params.set("pageSize", String(pageSize)); +const query = params.toString() ? `?${params.toString()}` : ""; +``` + +--- + +## Solution Implemented + +### 1. `buildQueryParams` helper (`src/lib/api.ts`) + +A reusable, tested utility function: + +```ts +buildQueryParams(params: Record): string +``` + +- Accepts an object of key-value pairs +- **Skips keys whose values are `undefined`** (so callers can spread optional params freely) +- Returns `"?key=value&key2=value2"` or `""` when no params are present +- Placed alongside `apiRequest` in `src/lib/api.ts` so both `settlementsApi.ts` and `anchorsApi.ts` can reuse it + +### 2. `fetchSettlements` updated (`src/lib/settlementsApi.ts`) + +Replaced the inline `URLSearchParams` construction with a single call to `buildQueryParams`: + +```ts +const query = buildQueryParams({ anchor, page, pageSize }); +``` + +### 3. Unit tests (`src/lib/api.test.ts`) + +Added 11 test cases covering: + +| Test | Description | +| ------------------------------- | ---------------------------------- | +| Empty params object | Returns `""` | +| All values undefined | Returns `""` | +| Single parameter | `"?anchor=a"` | +| Multiple parameters | `"?anchor=a&page=1&pageSize=20"` | +| Mixed defined/undefined | Skips undefined, includes defined | +| Number conversion | `"?page=5"` | +| URL encoding | `"?name=hello+world"` | +| Insertion order | Preserved | +| `FetchSettlementsOptions` shape | Matches expected output | +| Original logic equivalence | Confirms identical to inline logic | +| Empty options equivalence | Confirms `""` for all-undefined | + +--- + +## Key Changes Made + +| File | Change | +| --------------------------- | -------------------------------------------------------- | +| `src/lib/api.ts` | Added `buildQueryParams()` helper alongside `apiRequest` | +| `src/lib/settlementsApi.ts` | Updated `fetchSettlements` to use `buildQueryParams` | +| `src/lib/api.test.ts` | Added 11 test cases for `buildQueryParams` | + +--- + +## Trade-offs & Considerations + +- **Pure refactor**: No behavior change — the generated request URLs are identical for all existing test cases +- **No new dependencies**: Uses built-in `URLSearchParams` (available in Node.js, browsers, and jsdom test environment) +- **Type safety**: The `Record` type catches accidental `null` values while allowing optional params to be omitted naturally +- **Extensible**: `anchorsApi.ts` can adopt the same helper when its endpoints grow filter parameters + +--- + +## Testing Steps (How to Verify the Fix) + +1. **Run the existing tests**: `npx vitest run` + - All existing `settlementsApi.test.ts` tests pass with identical request URLs + - All `api.test.ts` tests pass including 11 new `buildQueryParams` tests +2. **Verify URL equivalence**: The new `buildQueryParams` tests include equivalence checks that confirm the output matches the original inline `URLSearchParams` logic +3. **Edge cases**: Empty params, all-undefined params, single param, multiple params, URL encoding + +--- + +Please kindly review this task. If there are any corrections, improvements, adjustments, or merge conflicts that you notice regarding my implementation, I'd really appreciate your feedback. I'd also love to hear your overall review of my work on this branch. Thank you! diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index fc1b0cc..4e0dce3 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { fetchPools, requestQuote, apiRequest, ApiRequestError } from "./api"; +import { + fetchPools, + requestQuote, + apiRequest, + ApiRequestError, + buildQueryParams, +} from "./api"; function mockFetch(status: number, body: unknown) { return vi.fn().mockResolvedValue({ @@ -93,3 +99,72 @@ describe("requestQuote", () => { ).rejects.toMatchObject({ code: "INSUFFICIENT_LIQUIDITY" }); }); }); + +describe("buildQueryParams", () => { + it("returns an empty string for an empty params object", () => { + expect(buildQueryParams({})).toBe(""); + }); + + it("returns an empty string when all values are undefined", () => { + expect(buildQueryParams({ a: undefined, b: undefined })).toBe(""); + }); + + it("builds a single query parameter", () => { + expect(buildQueryParams({ anchor: "a" })).toBe("?anchor=a"); + }); + + it("builds multiple query parameters", () => { + expect(buildQueryParams({ anchor: "a", page: 1, pageSize: 20 })).toBe( + "?anchor=a&page=1&pageSize=20", + ); + }); + + it("skips undefined values while including defined ones", () => { + expect( + buildQueryParams({ anchor: "a", page: undefined, pageSize: 20 }), + ).toBe("?anchor=a&pageSize=20"); + }); + + it("converts number values to strings", () => { + const result = buildQueryParams({ page: 5 }); + expect(result).toBe("?page=5"); + }); + + it("handles string values that need encoding", () => { + expect(buildQueryParams({ name: "hello world" })).toBe("?name=hello+world"); + }); + + it("preserves order of insertion", () => { + const result = buildQueryParams({ a: "1", b: "2", c: "3" }); + expect(result).toBe("?a=1&b=2&c=3"); + }); + + it("works with the same shape as FetchSettlementsOptions", () => { + const options: Record = { + anchor: "test-anchor", + page: 2, + pageSize: 10, + }; + expect(buildQueryParams(options)).toBe( + "?anchor=test-anchor&page=2&pageSize=10", + ); + }); + + it("matches the behaviour of the original inline URLSearchParams logic", () => { + // This test replicates the pattern from the original fetchSettlements + // to confirm the extracted helper produces identical output. + const { anchor, page, pageSize } = { anchor: "a", page: 1, pageSize: 20 }; + const query = buildQueryParams({ anchor, page, pageSize }); + expect(query).toBe("?anchor=a&page=1&pageSize=20"); + }); + + it("matches empty-options behaviour of the original logic", () => { + const { anchor, page, pageSize } = { + anchor: undefined, + page: undefined, + pageSize: undefined, + } as Record; + const query = buildQueryParams({ anchor, page, pageSize }); + expect(query).toBe(""); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 006907d..aa8b723 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,38 @@ import { Pool, Quote, QuoteRequest, ApiErrorBody } from "./types"; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +// ── Shared query-string builder ───────────────────────────────────────────── + +/** + * Build a URL query string from an object of parameters, skipping keys whose + * values are `undefined`. + * + * Returns an empty string when no parameters are provided, or a string + * starting with `?` otherwise. + * + * @example + * buildQueryParams({ anchor: "a", page: 1, pageSize: undefined }) + * // => "?anchor=a&page=1" + * + * buildQueryParams({}) + * // => "" + */ +export function buildQueryParams( + params: Record, +): string { + const entries = Object.entries(params).filter( + (entry): entry is [string, string | number] => entry[1] !== undefined, + ); + + if (entries.length === 0) return ""; + + const usp = new URLSearchParams(); + for (const [key, value] of entries) { + usp.set(key, String(value)); + } + return `?${usp.toString()}`; +} + /** Error thrown when the API responds with a non-2xx status. */ export class ApiRequestError extends Error { readonly status: number; diff --git a/src/lib/settlementsApi.ts b/src/lib/settlementsApi.ts index e86f863..4ac39ef 100644 --- a/src/lib/settlementsApi.ts +++ b/src/lib/settlementsApi.ts @@ -2,7 +2,7 @@ * API client for settlement endpoints. */ -import { apiRequest } from "./api"; +import { apiRequest, buildQueryParams } from "./api"; import { Settlement, SettlementsPage } from "./types"; /** Options for {@link fetchSettlements}. */ @@ -21,11 +21,7 @@ export async function fetchSettlements( options: FetchSettlementsOptions = {}, ): Promise { const { anchor, page, pageSize, signal } = options; - const params = new URLSearchParams(); - if (anchor) params.set("anchor", anchor); - if (page) params.set("page", String(page)); - if (pageSize) params.set("pageSize", String(pageSize)); - const query = params.toString() ? `?${params.toString()}` : ""; + const query = buildQueryParams({ anchor, page, pageSize }); return apiRequest(`/api/v1/settlements${query}`, { signal, }); From 4a09e36ab7e9b6d43dc28b33b722e2840d612540 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Tue, 21 Jul 2026 20:51:03 +0100 Subject: [PATCH 5/6] clean --- pr.md | 97 ----------------------------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 pr.md diff --git a/pr.md b/pr.md deleted file mode 100644 index 90ad5d8..0000000 --- a/pr.md +++ /dev/null @@ -1,97 +0,0 @@ -**Close:** #180 - ---- - -## Summary of the Issue - -`fetchSettlements` and `exportSettlementsCsv` in `src/lib/settlementsApi.ts` each independently build an identical `URLSearchParams` from the same `{ anchor, page, pageSize }` shape, duplicating the same conditional `.set(...)` calls twice in the same file. This pattern is also a natural fit for `anchorsApi.ts` functions if they ever grow filter parameters. - ---- - -## Root Cause - -There was no shared query-string builder utility. Each function that needed URL query parameters re-implemented the same pattern: - -```ts -const params = new URLSearchParams(); -if (anchor) params.set("anchor", anchor); -if (page) params.set("page", String(page)); -if (pageSize) params.set("pageSize", String(pageSize)); -const query = params.toString() ? `?${params.toString()}` : ""; -``` - ---- - -## Solution Implemented - -### 1. `buildQueryParams` helper (`src/lib/api.ts`) - -A reusable, tested utility function: - -```ts -buildQueryParams(params: Record): string -``` - -- Accepts an object of key-value pairs -- **Skips keys whose values are `undefined`** (so callers can spread optional params freely) -- Returns `"?key=value&key2=value2"` or `""` when no params are present -- Placed alongside `apiRequest` in `src/lib/api.ts` so both `settlementsApi.ts` and `anchorsApi.ts` can reuse it - -### 2. `fetchSettlements` updated (`src/lib/settlementsApi.ts`) - -Replaced the inline `URLSearchParams` construction with a single call to `buildQueryParams`: - -```ts -const query = buildQueryParams({ anchor, page, pageSize }); -``` - -### 3. Unit tests (`src/lib/api.test.ts`) - -Added 11 test cases covering: - -| Test | Description | -| ------------------------------- | ---------------------------------- | -| Empty params object | Returns `""` | -| All values undefined | Returns `""` | -| Single parameter | `"?anchor=a"` | -| Multiple parameters | `"?anchor=a&page=1&pageSize=20"` | -| Mixed defined/undefined | Skips undefined, includes defined | -| Number conversion | `"?page=5"` | -| URL encoding | `"?name=hello+world"` | -| Insertion order | Preserved | -| `FetchSettlementsOptions` shape | Matches expected output | -| Original logic equivalence | Confirms identical to inline logic | -| Empty options equivalence | Confirms `""` for all-undefined | - ---- - -## Key Changes Made - -| File | Change | -| --------------------------- | -------------------------------------------------------- | -| `src/lib/api.ts` | Added `buildQueryParams()` helper alongside `apiRequest` | -| `src/lib/settlementsApi.ts` | Updated `fetchSettlements` to use `buildQueryParams` | -| `src/lib/api.test.ts` | Added 11 test cases for `buildQueryParams` | - ---- - -## Trade-offs & Considerations - -- **Pure refactor**: No behavior change — the generated request URLs are identical for all existing test cases -- **No new dependencies**: Uses built-in `URLSearchParams` (available in Node.js, browsers, and jsdom test environment) -- **Type safety**: The `Record` type catches accidental `null` values while allowing optional params to be omitted naturally -- **Extensible**: `anchorsApi.ts` can adopt the same helper when its endpoints grow filter parameters - ---- - -## Testing Steps (How to Verify the Fix) - -1. **Run the existing tests**: `npx vitest run` - - All existing `settlementsApi.test.ts` tests pass with identical request URLs - - All `api.test.ts` tests pass including 11 new `buildQueryParams` tests -2. **Verify URL equivalence**: The new `buildQueryParams` tests include equivalence checks that confirm the output matches the original inline `URLSearchParams` logic -3. **Edge cases**: Empty params, all-undefined params, single param, multiple params, URL encoding - ---- - -Please kindly review this task. If there are any corrections, improvements, adjustments, or merge conflicts that you notice regarding my implementation, I'd really appreciate your feedback. I'd also love to hear your overall review of my work on this branch. Thank you! From 4302b7b43dd0195f9eb3e142b9a157a80ac16208 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Thu, 30 Jul 2026 20:32:34 +0100 Subject: [PATCH 6/6] fix: extract buildQueryParams for settlementsApi and restore isAbortError --- src/lib/api.ts | 11 +++++++++++ src/lib/settlementsApi.ts | 9 ++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index fa98582..006fd60 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,6 +42,17 @@ export function buildQueryParams( return `?${usp.toString()}`; } +/** + * Returns true when `err` is an AbortError — the rejection thrown by `fetch` + * (and other Web APIs) when an `AbortSignal` fires. Use this to distinguish + * a deliberate cancellation from a genuine network/server failure so callers + * never surface a user-facing error toast for a request the app itself + * cancelled on purpose. + */ +export function isAbortError(err: unknown): boolean { + return err instanceof DOMException && err.name === "AbortError"; +} + /** Error thrown when the API responds with a non-2xx status. */ export class ApiRequestError extends Error { readonly status: number; diff --git a/src/lib/settlementsApi.ts b/src/lib/settlementsApi.ts index b200936..5c437ee 100644 --- a/src/lib/settlementsApi.ts +++ b/src/lib/settlementsApi.ts @@ -2,7 +2,7 @@ * API client for settlement endpoints. */ -import { apiRequest, buildQueryParams } from "./api"; +import { apiRequest, apiTextRequest, buildQueryParams } from "./api"; import { Settlement, SettlementsPage } from "./types"; /** Options for {@link fetchSettlements}. */ @@ -32,12 +32,7 @@ export async function exportSettlementsCsv( options: FetchSettlementsOptions = {}, ): Promise { const { anchor, page, pageSize, signal } = options; - const params = new URLSearchParams(); - if (anchor) params.set("anchor", anchor); - if (page) params.set("page", String(page)); - if (pageSize) params.set("pageSize", String(pageSize)); - params.set("format", "csv"); - const query = params.toString() ? `?${params.toString()}` : ""; + const query = buildQueryParams({ anchor, page, pageSize, format: "csv" }); return apiTextRequest(`/api/v1/settlements${query}`, { signal }); }