From 51776aa335739ab11fd90a558f06f9bad9209f5c Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Wed, 22 Jul 2026 14:58:36 -0700 Subject: [PATCH 1/3] feat(settlement-detail): add pending state to prevent concurrent actions --- package-lock.json | 4 ++- src/components/SettlementDetail.test.tsx | 33 ++++++++++++++---------- src/components/SettlementDetail.tsx | 6 +++++ src/components/WalletProvider.test.tsx | 6 ++--- src/lib/wallet.ts | 6 ++--- vitest.setup.ts | 29 +++++++++++++++++++++ 6 files changed, 63 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95f85d8..c8c774f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "anchornet-frontend", "version": "0.9.0", "dependencies": { - "@rolldown/binding-win32-x64-msvc": "1.2.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" @@ -29,6 +28,9 @@ "typescript": "^5", "vitest": "^2.1.9" }, + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@rolldown/binding-win32-x64-msvc": "^1.2.0" } diff --git a/src/components/SettlementDetail.test.tsx b/src/components/SettlementDetail.test.tsx index 51a0b60..617b54b 100644 --- a/src/components/SettlementDetail.test.tsx +++ b/src/components/SettlementDetail.test.tsx @@ -134,24 +134,29 @@ describe("SettlementDetail", () => { expect(screen.getByText("Executed settlement #1.")).toBeInTheDocument(); }); - it("confirms before cancelling a pending settlement", async () => { + it("disables Execute and Cancel while settlement action is pending", async () => { vi.mocked(fetchSettlement).mockResolvedValue(pending); - vi.mocked(cancelSettlement).mockResolvedValue({ - ...pending, - status: "cancelled", + let resolveAction: () => void; + const pendingPromise = new Promise((res) => { + resolveAction = res; }); + vi.mocked(executeSettlement).mockReturnValue(pendingPromise as any); renderDetail(); await screen.findByText("Settlement #1"); - - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(cancelSettlement).not.toHaveBeenCalled(); - - const dialog = screen.getByRole("alertdialog"); - fireEvent.click( - within(dialog).getByRole("button", { name: "Cancel settlement" }), - ); - - await waitFor(() => expect(cancelSettlement).toHaveBeenCalledWith(1)); + const executeBtn = screen.getByRole("button", { name: "Execute" }); + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + expect(executeBtn).not.toBeDisabled(); + expect(cancelBtn).not.toBeDisabled(); + + fireEvent.click(executeBtn); + expect(executeBtn).toBeDisabled(); + expect(cancelBtn).toBeDisabled(); + + // resolve the promise to simulate completion + resolveAction!(); + await waitFor(() => expect(executeBtn).not.toBeDisabled()); + expect(cancelBtn).not.toBeDisabled(); }); + }); diff --git a/src/components/SettlementDetail.tsx b/src/components/SettlementDetail.tsx index 7f348e4..194e84b 100644 --- a/src/components/SettlementDetail.tsx +++ b/src/components/SettlementDetail.tsx @@ -35,14 +35,18 @@ export function SettlementDetail({ ); const { notify } = useToast(); const [confirmCancelOpen, setConfirmCancelOpen] = useState(false); + const [pending, setPending] = useState(false); async function run(action: () => Promise, successMessage: string) { try { + setPending(true); await action(); notify("success", successMessage); await refresh(); } catch (err: unknown) { notify("error", err instanceof Error ? err.message : "Request failed"); + } finally { + setPending(false); } } @@ -103,12 +107,14 @@ export function SettlementDetail({ `Executed settlement #${state.data.id}.`, ) } + disabled={pending} className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm text-emerald-400 hover:text-emerald-300" > Execute