From 1eedf0777825e7b36b02194f2d5879f9c1b57947 Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Tue, 21 Jul 2026 15:34:20 +0100 Subject: [PATCH 1/2] refactor(types): remove unused LiquidityEntry interface --- src/lib/types.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index bed4f26..e0a70b4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,14 +9,6 @@ export interface Pool { anchors: number; } -/** A single anchor's liquidity contribution to an asset pool. */ -export interface LiquidityEntry { - anchor: string; - asset: string; - amount: number; - updatedAt: string; -} - /** A request to route `amount` of `asset` through available liquidity. */ export interface QuoteRequest { asset: string; From ec1133384a0a35a0b516f889ea311540f94513e5 Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Tue, 21 Jul 2026 16:43:07 +0100 Subject: [PATCH 2/2] fix(settlements): add per-row in-flight guard against duplicate execute/cancel clicks --- src/components/SettlementTable.test.tsx | 34 +++++++++ src/components/SettlementTable.tsx | 85 ++++++++++++---------- src/components/SettlementsPanel.test.tsx | 90 ++++++++++++++++++++++++ src/components/SettlementsPanel.tsx | 14 ++++ 4 files changed, 184 insertions(+), 39 deletions(-) diff --git a/src/components/SettlementTable.test.tsx b/src/components/SettlementTable.test.tsx index 0204453..e65f26e 100644 --- a/src/components/SettlementTable.test.tsx +++ b/src/components/SettlementTable.test.tsx @@ -77,3 +77,37 @@ describe("SettlementTable sorting", () => { expect(header).toHaveAttribute("aria-sort", "descending"); }); }); + +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(); + }); +}); diff --git a/src/components/SettlementTable.tsx b/src/components/SettlementTable.tsx index e39b0d2..da2510a 100644 --- a/src/components/SettlementTable.tsx +++ b/src/components/SettlementTable.tsx @@ -19,10 +19,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, @@ -71,46 +73,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} + - ) : null} - - ))} + {s.anchor} + {s.asset} + {formatAmount(s.amount)} + {formatAmount(s.fee)} + + + + {actionable ? ( + + {s.status === "pending" ? ( + + {onExecute ? ( + + ) : null} + {onCancel ? ( + + ) : null} + + ) : null} + + ) : null} + + ); + })} diff --git a/src/components/SettlementsPanel.test.tsx b/src/components/SettlementsPanel.test.tsx index 93b59e7..440a1cc 100644 --- a/src/components/SettlementsPanel.test.tsx +++ b/src/components/SettlementsPanel.test.tsx @@ -45,6 +45,7 @@ vi.mock("@/lib/api", () => ({ beforeEach(() => { vi.clearAllMocks(); mockSearchParamsString = ""; + vi.mocked(fetchPools).mockResolvedValue([]); }); function page( @@ -523,4 +524,93 @@ describe("SettlementsPanel", () => { // Check if the download link was created 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 row1 = screen.getByText("anchor1").closest("tr")!; + const row2 = screen.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 row1 = screen.getByText("anchor1").closest("tr")!; + const row2 = screen.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); + }); + }); }); diff --git a/src/components/SettlementsPanel.tsx b/src/components/SettlementsPanel.tsx index 567bded..2bae03d 100644 --- a/src/components/SettlementsPanel.tsx +++ b/src/components/SettlementsPanel.tsx @@ -52,6 +52,9 @@ export function SettlementsPanel() { const [pending, setPending] = useState(false); const [exporting, setExporting] = useState(false); const [pendingCancelId, setPendingCancelId] = useState(null); + const [pendingSettlementIds, setPendingSettlementIds] = useState>( + () => new Set(), + ); const [pools, setPools] = useState([]); const { notify } = useToast(); const searchRef = useRef(null); @@ -156,9 +159,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) => @@ -176,6 +181,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; + }); } } @@ -284,11 +295,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 ? ( @@ -324,6 +337,7 @@ export function SettlementsPanel() { setPendingCancelId(null); if (id !== null) { runSettlementAction( + id, () => cancelSettlement(id), `Cancelled settlement #${id}.`, );