Skip to content
Merged
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
50 changes: 50 additions & 0 deletions src/app/dashboard/error.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <header data-testid="site-header" />,
}));

describe("DashboardError boundary", () => {
let reloadMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
reloadMock = vi.fn();
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("renders the 'Could not load the dashboard' title", () => {
render(<DashboardError error={new Error("fetch failed")} reset={() => {}} />);
expect(
screen.getByText("Could not load the dashboard"),
).toBeInTheDocument();
});

it("calls the reset callback when 'Try again' is clicked", () => {
const reset = vi.fn();
render(<DashboardError error={new Error("fetch failed")} reset={reset} />);
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(<DashboardError error={new Error("fetch failed")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reloadMock).not.toHaveBeenCalled();
});
});
63 changes: 58 additions & 5 deletions src/components/SettlementsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fireEvent,
waitFor,
within,
act,
} from "@testing-library/react";
import { SettlementsPanel } from "./SettlementsPanel";
import { ToastProvider } from "./ToastProvider";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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("");
});

Expand Down
13 changes: 12 additions & 1 deletion src/components/SettlementsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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];

Expand Down Expand Up @@ -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),
)
: [];

Expand Down
Loading