diff --git a/src/components/AnchorDetail.test.tsx b/src/components/AnchorDetail.test.tsx index 186be2a..e5ef022 100644 --- a/src/components/AnchorDetail.test.tsx +++ b/src/components/AnchorDetail.test.tsx @@ -63,18 +63,19 @@ describe("AnchorDetail", () => { }); it("shows a not‑found message when the anchor returns 404", async () => { - vi.mocked(fetchAnchor).mockRejectedValue(new ApiRequestError(404, "NOT_FOUND", "Not found")); + vi.mocked(fetchAnchor).mockRejectedValue( + new ApiRequestError(404, "NOT_FOUND", "Not found"), + ); renderDetail(); // Expect the distinct not‑found text expect(await screen.findByText(/anchor not found/i)).toBeInTheDocument(); - const backLink = screen.getByRole("link", { name: /back to anchors/i }); + const backLink = screen.getByRole("link", { name: /← back to anchors/i }); expect(backLink).toBeInTheDocument(); expect(backLink).toHaveAttribute("href", "/anchors"); }); - it("hides the deactivate action for an already-inactive anchor", async () => { vi.mocked(fetchAnchor).mockResolvedValue({ id: "anchorA", @@ -119,6 +120,38 @@ describe("AnchorDetail", () => { ); }); + it("cancels deactivation when Cancel is clicked in the confirm dialog", async () => { + vi.mocked(fetchAnchor).mockResolvedValue({ + id: "anchorA", + name: "Anchor A", + registeredAt: "", + active: true, + }); + + renderDetail(); + await screen.findByText("Anchor A"); + + // Click "Deactivate" to open the dialog + fireEvent.click(screen.getByRole("button", { name: "Deactivate" })); + expect(deregisterAnchor).not.toHaveBeenCalled(); + + // Dialog should be open + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toBeInTheDocument(); + + // Click the Cancel button in the dialog + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + // Verify deregisterAnchor was not called + expect(deregisterAnchor).not.toHaveBeenCalled(); + + // Dialog should be closed (either not in the document or queryByRole returns null) + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + + // Anchor status is unchanged (remains Active) + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + it("deactivates an anchor without flashing a loading spinner", async () => { let resolveFetch: (value: any) => void = () => {}; const fetchPromise = new Promise((resolve) => { @@ -167,7 +200,9 @@ describe("AnchorDetail", () => { }); // Wait for the newly fetched status to be reflected - await waitFor(() => expect(screen.getByText("Inactive")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Inactive")).toBeInTheDocument(), + ); }); it("handles deactivation error gracefully", async () => { @@ -177,7 +212,9 @@ describe("AnchorDetail", () => { registeredAt: "", active: true, }); - vi.mocked(deregisterAnchor).mockRejectedValue(new Error("Deactivation failed test error")); + vi.mocked(deregisterAnchor).mockRejectedValue( + new Error("Deactivation failed test error"), + ); renderDetail(); expect(await screen.findByText("Anchor A")).toBeInTheDocument(); @@ -189,7 +226,7 @@ describe("AnchorDetail", () => { await waitFor(() => expect(deregisterAnchor).toHaveBeenCalledWith("anchorA"), ); - + // We expect a toast with the error message but we don't strictly test the toast component itself here, // just the error branch in the catch block of deactivate() }); diff --git a/src/components/AnchorsPanel.tsx b/src/components/AnchorsPanel.tsx index 28c797c..d0120a1 100644 --- a/src/components/AnchorsPanel.tsx +++ b/src/components/AnchorsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { fetchAnchors, registerAnchor, @@ -109,6 +109,15 @@ export function AnchorsPanel() { const [rawStatus, setStatus] = useQueryState("status", "all"); const filter: StatusFilter = isStatusFilter(rawStatus) ? rawStatus : "all"; + const [sortParam, setSortParam] = useQueryState("sort", ""); + const [dirParam, setDirParam] = useQueryState("dir", ""); + + const initialSort = useMemo | null>(() => { + if (!sortParam || !VALID_SORT_KEYS.has(sortParam)) return null; + const direction: SortDirection = dirParam === "desc" ? "desc" : "asc"; + return { key: sortParam as SortKey, direction }; + }, [sortParam, dirParam]); + // When the URL carries an invalid status value, correct it to the effective // fallback ("all") so the address bar always reflects what is displayed. useEffect(() => { diff --git a/src/components/PoolsPanel.tsx b/src/components/PoolsPanel.tsx index 0891622..1c7ce69 100644 --- a/src/components/PoolsPanel.tsx +++ b/src/components/PoolsPanel.tsx @@ -18,12 +18,7 @@ import { EmptyState } from "./EmptyState"; const SEARCH_DEBOUNCE_MS = 200; -/** - * Optional props allowing a parent (e.g. DashboardContent) to supply pool data - * it already fetched, avoiding a duplicate request. When omitted, the panel - * fetches its own data. - */ -export interface PoolsPanelProps { +interface PoolsPanelProps { pools?: Pool[]; isLoading?: boolean; error?: string; diff --git a/src/components/SettlementDetail.test.tsx b/src/components/SettlementDetail.test.tsx index 0f6e000..ead9e35 100644 --- a/src/components/SettlementDetail.test.tsx +++ b/src/components/SettlementDetail.test.tsx @@ -73,7 +73,7 @@ describe("SettlementDetail", () => { // Expect the distinct not‑found text expect(await screen.findByText(/settlement not found/i)).toBeInTheDocument(); - const backLink = screen.getByRole('link', { name: /back to settlements/i }); + const backLink = screen.getByRole('link', { name: /← back to settlements/i }); expect(backLink).toBeInTheDocument(); expect(backLink).toHaveAttribute('href', '/settlements'); }); @@ -161,4 +161,58 @@ describe("SettlementDetail", () => { expect(cancelBtn).not.toBeDisabled(); }); + it("cancels cancellation when Keep settlement is clicked in the confirm dialog", async () => { + vi.mocked(fetchSettlement).mockResolvedValue(pending); + + renderDetail(); + await screen.findByText("Settlement #1"); + + // Click "Cancel" to open the dialog + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(cancelSettlement).not.toHaveBeenCalled(); + + // Dialog should be open + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toBeInTheDocument(); + + // Click the "Keep settlement" button in the dialog + fireEvent.click(within(dialog).getByRole("button", { name: "Keep settlement" })); + + // Verify cancelSettlement was not called + expect(cancelSettlement).not.toHaveBeenCalled(); + + // Dialog should be closed + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + + // Settlement status is unchanged (remains Pending) + expect(screen.getByText("Pending")).toBeInTheDocument(); + }); + + it("confirms before cancelling a pending settlement", async () => { + vi.mocked(fetchSettlement).mockResolvedValue(pending); + vi.mocked(cancelSettlement).mockResolvedValue({ + ...pending, + status: "cancelled", + }); + + renderDetail(); + await screen.findByText("Settlement #1"); + + // Click "Cancel" to open the dialog + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(cancelSettlement).not.toHaveBeenCalled(); + + // Dialog should be open + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toBeInTheDocument(); + + // Click the "Cancel settlement" button in the dialog to confirm + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel settlement" })); + + // Verify cancelSettlement was called + await waitFor(() => + expect(cancelSettlement).toHaveBeenCalledWith(1), + ); + }); + });