- {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 && (
+
+ )}
+
+ )}
+
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}.`,
);