From d4d8ff11c2c1ffb4f1a8f84ba9cd57a1ef78f1bd Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Wed, 22 Jul 2026 19:28:57 +0100 Subject: [PATCH 1/2] Add dashboard error boundary test --- src/app/dashboard/error.test.tsx | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/app/dashboard/error.test.tsx diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx new file mode 100644 index 0000000..2dd4533 --- /dev/null +++ b/src/app/dashboard/error.test.tsx @@ -0,0 +1,50 @@ +/** + * Tests for the dashboard route error boundary. + * + * Verifies that: + * - The boundary renders the RouteError fallback with the correct title. + * - Clicking "Try again" fires the Next.js `reset` callback (not a full + * page reload via window.location.reload). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import DashboardError from "./error"; + +// Stub SiteHeader so we don't need a WalletProvider in boundary tests. +vi.mock("@/components/SiteHeader", () => ({ + SiteHeader: () =>
, +})); + +describe("DashboardError boundary", () => { + let reloadMock: ReturnType; + + beforeEach(() => { + reloadMock = vi.fn(); + vi.stubGlobal("location", { ...window.location, reload: reloadMock }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the 'Could not load the dashboard' title", () => { + render( {}} />); + expect( + screen.getByText("Could not load the dashboard"), + ).toBeInTheDocument(); + }); + + it("calls the reset callback when 'Try again' is clicked", () => { + const reset = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("does NOT call window.location.reload when 'Try again' is clicked", () => { + const reset = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reloadMock).not.toHaveBeenCalled(); + }); +}); From b93befa0621ebce3e1925a48f99d19b24de269f9 Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Wed, 22 Jul 2026 19:39:30 +0100 Subject: [PATCH 2/2] Debounce settlements search query --- src/components/SettlementsPanel.test.tsx | 63 ++++++++++++++++++++++-- src/components/SettlementsPanel.tsx | 13 ++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/components/SettlementsPanel.test.tsx b/src/components/SettlementsPanel.test.tsx index cdbc076..3a78262 100644 --- a/src/components/SettlementsPanel.test.tsx +++ b/src/components/SettlementsPanel.test.tsx @@ -5,6 +5,7 @@ import { fireEvent, waitFor, within, + act, } from "@testing-library/react"; import { SettlementsPanel } from "./SettlementsPanel"; import { ToastProvider } from "./ToastProvider"; @@ -115,8 +116,55 @@ describe("SettlementsPanel", () => { target: { value: "anchorA" }, }); + // Filtering is debounced, so the non-matching row only drops out once the + // debounce delay has elapsed. + await waitFor(() => + expect(screen.queryByText("other")).not.toBeInTheDocument(), + ); expect(screen.getByText("anchorA")).toBeInTheDocument(); - expect(screen.queryByText("other")).not.toBeInTheDocument(); + }); + + it("debounces the search filter, updating the list only after the delay", async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetchSettlements).mockResolvedValue( + page([sample, { ...sample, id: 2, anchor: "other" }]), + ); + + renderPanel(); + + // Flush the mocked fetch promise and mount effects so the list renders. + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText("anchorA")).toBeInTheDocument(); + expect(screen.getByText("other")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Search settlements"), { + target: { value: "anchorA" }, + }); + + // The input reflects the keystroke immediately (no typing lag)... + expect(screen.getByLabelText("Search settlements")).toHaveValue("anchorA"); + // ...but the filtered list has not been recomputed yet. + expect(screen.getByText("other")).toBeInTheDocument(); + + // Just before the debounce elapses, the list is still unchanged. + await act(async () => { + await vi.advanceTimersByTimeAsync(199); + }); + expect(screen.getByText("other")).toBeInTheDocument(); + + // Once the debounce delay elapses, the non-matching row is filtered out. + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.queryByText("other")).not.toBeInTheDocument(); + expect(screen.getByText("anchorA")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); it("shows the no-data empty state without a clear-filters action", async () => { @@ -142,14 +190,19 @@ describe("SettlementsPanel", () => { target: { value: "zzz" }, }); - expect( - screen.getByText("No settlements match your search."), - ).toBeInTheDocument(); + // The no-results state appears only after the debounce delay elapses. + await waitFor(() => + expect( + screen.getByText("No settlements match your search."), + ).toBeInTheDocument(), + ); expect(screen.queryByText("No settlements yet.")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); - expect(screen.getByText("anchorA")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText("anchorA")).toBeInTheDocument(), + ); expect(screen.getByLabelText("Search settlements")).toHaveValue(""); }); diff --git a/src/components/SettlementsPanel.tsx b/src/components/SettlementsPanel.tsx index 4487aec..94bed15 100644 --- a/src/components/SettlementsPanel.tsx +++ b/src/components/SettlementsPanel.tsx @@ -12,6 +12,7 @@ import { Settlement, Pagination, Pool } from "@/lib/types"; import { fetchPools } from "@/lib/api"; import { pluralize } from "@/lib/format"; import { matchesQuery } from "@/lib/search"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useToast } from "@/hooks/useToast"; import { useFocusShortcut } from "@/hooks/useFocusShortcut"; import { useQueryState } from "@/hooks/useQueryState"; @@ -22,6 +23,12 @@ import { SettlementTable } from "./SettlementTable"; import { ConfirmDialog } from "./ConfirmDialog"; import { EmptyState } from "./EmptyState"; +/** + * Delay (ms) before a paused search query is applied to the filtered list. + * The input itself stays bound to the immediate value, so typing never lags. + */ +const SEARCH_DEBOUNCE_MS = 200; + /** Selectable page sizes for the settlements list; the first is the default. */ const PAGE_SIZE_OPTIONS = [10, 25, 50]; @@ -215,10 +222,14 @@ export function SettlementsPanel() { } } + // Debounce only the value that drives filtering so large settlement lists + // aren't re-filtered on every keystroke; the input stays bound to `query`. + const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS); + const visibleSettlements = state.status === "ready" ? state.settlements.filter((s) => - matchesQuery([s.id, s.anchor, s.asset], query), + matchesQuery([s.id, s.anchor, s.asset], debouncedQuery), ) : [];