diff --git a/src/components/SettlementTable.test.tsx b/src/components/SettlementTable.test.tsx index a969ed1..3b0c334 100644 --- a/src/components/SettlementTable.test.tsx +++ b/src/components/SettlementTable.test.tsx @@ -129,6 +129,40 @@ describe("SettlementTable sorting", () => { }); }); +describe("SettlementTable per-row in-flight actions", () => { + it("disables Execute and Cancel buttons only for pending settlement IDs", () => { + const onExecute = () => {}; + const onCancel = () => {}; + render( + , + ); + + const rows = within(document.querySelector("tbody")!).getAllByRole("row"); + const row1Execute = within(rows[0]).getByRole("button", { name: "Execute" }); + const row1Cancel = within(rows[0]).getByRole("button", { name: "Cancel" }); + + const row2Execute = within(rows[1]).getByRole("button", { name: "Execute" }); + const row2Cancel = within(rows[1]).getByRole("button", { name: "Cancel" }); + + const row3Execute = within(rows[2]).getByRole("button", { name: "Execute" }); + const row3Cancel = within(rows[2]).getByRole("button", { name: "Cancel" }); + + expect(row1Execute).not.toBeDisabled(); + expect(row1Cancel).not.toBeDisabled(); + + expect(row2Execute).toBeDisabled(); + expect(row2Cancel).toBeDisabled(); + + expect(row3Execute).not.toBeDisabled(); + expect(row3Cancel).not.toBeDisabled(); + }); +}); + describe("SettlementTable mobile layout", () => { it("renders a card for each settlement with correct data", () => { render(); diff --git a/src/components/SettlementTable.tsx b/src/components/SettlementTable.tsx index cafbde0..e715d04 100644 --- a/src/components/SettlementTable.tsx +++ b/src/components/SettlementTable.tsx @@ -22,10 +22,12 @@ export function SettlementTable({ settlements, onExecute, onCancel, + pendingIds, }: { settlements: Settlement[]; onExecute?: (id: number) => void; onCancel?: (id: number) => void; + pendingIds?: Set; }) { const { sorted, sort, requestSort } = useSortableData( settlements, @@ -76,46 +78,51 @@ export function SettlementTable({ - {sorted.map((s) => ( - - - - {s.id} - - - {s.anchor} - {s.asset} - {formatAmount(s.amount)} - {formatAmount(s.fee)} - - - - {actionable ? ( - - {s.status === "pending" ? ( - - {onExecute ? ( - - ) : null} - {onCancel ? ( - - ) : null} - - ) : null} + {sorted.map((s) => { + const isPending = pendingIds?.has(s.id) ?? false; + return ( + + + + {s.id} + + + {s.anchor} + {s.asset} + {formatAmount(s.amount)} + {formatAmount(s.fee)} + + - ) : null} - - ))} + {actionable ? ( + + {s.status === "pending" ? ( + + {onExecute ? ( + + ) : null} + {onCancel ? ( + + ) : null} + + ) : null} + + ) : null} + + ); + })} @@ -132,46 +139,51 @@ export function SettlementTable({ {/* Card view for small screens */}
- {sorted.map((s) => ( -
-
- - Settlement #{s.id} - - -
-
-
Anchor
-
{s.anchor}
-
Asset
-
{s.asset}
-
Amount
-
{formatAmount(s.amount)}
-
Fee
-
{formatAmount(s.fee)}
-
- {actionable && s.status === "pending" && ( -
- {onExecute && ( - - )} - {onCancel && ( - - )} + {sorted.map((s) => { + const isPending = pendingIds?.has(s.id) ?? false; + return ( +
+
+ + Settlement #{s.id} + +
- )} -
- ))} +
+
Anchor
+
{s.anchor}
+
Asset
+
{s.asset}
+
Amount
+
{formatAmount(s.amount)}
+
Fee
+
{formatAmount(s.fee)}
+
+ {actionable && s.status === "pending" && ( +
+ {onExecute && ( + + )} + {onCancel && ( + + )} +
+ )} +
+ ); + })} {/* Totals card */}
Total (visible rows)
diff --git a/src/components/SettlementsPanel.test.tsx b/src/components/SettlementsPanel.test.tsx index 0a74f97..8a7591e 100644 --- a/src/components/SettlementsPanel.test.tsx +++ b/src/components/SettlementsPanel.test.tsx @@ -30,19 +30,6 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/settlements", })); -// --------------------------------------------------------------------------- -// 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: () => "/settlements", -})); - vi.mock("@/lib/settlementsApi", () => ({ fetchSettlements: vi.fn(), openSettlement: vi.fn(), @@ -58,6 +45,7 @@ vi.mock("@/lib/api", () => ({ beforeEach(() => { vi.clearAllMocks(); mockSearchParamsString = ""; + vi.mocked(fetchPools).mockResolvedValue([]); }); function page( @@ -667,6 +655,97 @@ describe("SettlementsPanel", () => { expect(createObjectURL).toHaveBeenCalled(); }); + it("disables row action buttons while executeSettlement is in flight, keeping other rows enabled", async () => { + const s1 = { ...sample, id: 1, anchor: "anchor1" }; + const s2 = { ...sample, id: 2, anchor: "anchor2" }; + vi.mocked(fetchSettlements).mockResolvedValue(page([s1, s2])); + + let resolveExecute!: (val: Settlement) => void; + const executePromise = new Promise((resolve) => { + resolveExecute = resolve; + }); + vi.mocked(executeSettlement).mockImplementation(() => executePromise); + + renderPanel(); + await screen.findByText("anchor1"); + + const table = within(document.querySelector("table")!); + const row1 = table.getByText("anchor1").closest("tr")!; + const row2 = table.getByText("anchor2").closest("tr")!; + + const row1Execute = within(row1).getByRole("button", { name: "Execute" }); + const row1Cancel = within(row1).getByRole("button", { name: "Cancel" }); + const row2Execute = within(row2).getByRole("button", { name: "Execute" }); + const row2Cancel = within(row2).getByRole("button", { name: "Cancel" }); + + expect(row1Execute).not.toBeDisabled(); + expect(row1Cancel).not.toBeDisabled(); + expect(row2Execute).not.toBeDisabled(); + expect(row2Cancel).not.toBeDisabled(); + + fireEvent.click(row1Execute); + + await waitFor(() => expect(row1Execute).toBeDisabled()); + expect(row1Cancel).toBeDisabled(); + + // Other row remains enabled + expect(row2Execute).not.toBeDisabled(); + expect(row2Cancel).not.toBeDisabled(); + + // Resolve the promise + resolveExecute({ ...s1, status: "executed" }); + + await waitFor(() => { + expect(executeSettlement).toHaveBeenCalledWith(1); + }); + }); + + it("disables row action buttons while cancelSettlement is in flight, keeping other rows enabled", async () => { + const s1 = { ...sample, id: 1, anchor: "anchor1" }; + const s2 = { ...sample, id: 2, anchor: "anchor2" }; + vi.mocked(fetchSettlements).mockResolvedValue(page([s1, s2])); + + let resolveCancel!: (val: Settlement) => void; + const cancelPromise = new Promise((resolve) => { + resolveCancel = resolve; + }); + vi.mocked(cancelSettlement).mockImplementation(() => cancelPromise); + + renderPanel(); + await screen.findByText("anchor1"); + + const table = within(document.querySelector("table")!); + const row1 = table.getByText("anchor1").closest("tr")!; + const row2 = table.getByText("anchor2").closest("tr")!; + + const row1Execute = within(row1).getByRole("button", { name: "Execute" }); + const row1Cancel = within(row1).getByRole("button", { name: "Cancel" }); + const row2Execute = within(row2).getByRole("button", { name: "Execute" }); + const row2Cancel = within(row2).getByRole("button", { name: "Cancel" }); + + fireEvent.click(row1Cancel); + + // Confirm dialog is open + const dialog = screen.getByRole("alertdialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "Cancel settlement" }), + ); + + await waitFor(() => expect(row1Execute).toBeDisabled()); + expect(row1Cancel).toBeDisabled(); + + // Other row remains enabled + expect(row2Execute).not.toBeDisabled(); + expect(row2Cancel).not.toBeDisabled(); + + // Resolve cancel promise + resolveCancel({ ...s1, status: "cancelled" }); + + await waitFor(() => { + expect(cancelSettlement).toHaveBeenCalledWith(1); + }); + }); + it("indicates when the CSV export ignores the active search filter", async () => { mockSearchParamsString = "q=anchorA"; vi.mocked(fetchSettlements).mockResolvedValue( diff --git a/src/components/SettlementsPanel.tsx b/src/components/SettlementsPanel.tsx index 41d4664..47c1f03 100644 --- a/src/components/SettlementsPanel.tsx +++ b/src/components/SettlementsPanel.tsx @@ -51,7 +51,11 @@ export function SettlementsPanel() { const [loadMoreAnnouncement, setLoadMoreAnnouncement] = useState(""); const [pending, setPending] = useState(false); const [pendingCancelId, setPendingCancelId] = useState(null); + const [pendingSettlementIds, setPendingSettlementIds] = useState>( + () => new Set(), + ); const [pools, setPools] = useState([]); + const [exporting, setExporting] = useState(false); const { notify } = useToast(); const searchRef = useRef(null); useFocusShortcut("/", searchRef); @@ -161,9 +165,11 @@ export function SettlementsPanel() { } async function runSettlementAction( + id: number, action: () => Promise, successMessage: string, ) { + setPendingSettlementIds((prev) => new Set(prev).add(id)); try { const updatedSettlement = await action(); setState((previous) => @@ -181,6 +187,12 @@ export function SettlementsPanel() { notify("success", successMessage); } catch (err: unknown) { notify("error", err instanceof Error ? err.message : "Request failed"); + } finally { + setPendingSettlementIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); } } @@ -232,7 +244,12 @@ export function SettlementsPanel() { : []; // Determine count to display in the footer - const displayCount = query.trim() ? visibleSettlements.length : state.pagination.total; + const displayCount = + state.status === "ready" + ? query.trim() + ? visibleSettlements.length + : state.pagination.total + : 0; return (
@@ -301,11 +318,13 @@ export function SettlementsPanel() { settlements={visibleSettlements} onExecute={(id) => runSettlementAction( + id, () => executeSettlement(id), `Executed settlement #${id}.`, ) } onCancel={setPendingCancelId} + pendingIds={pendingSettlementIds} /> )} {state.pagination.page < state.pagination.totalPages ? ( @@ -341,6 +360,7 @@ export function SettlementsPanel() { setPendingCancelId(null); if (id !== null) { runSettlementAction( + id, () => cancelSettlement(id), `Cancelled settlement #${id}.`, );