Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/components/SettlementTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SettlementTable
settlements={settlements}
onExecute={onExecute}
onCancel={onCancel}
pendingIds={new Set([2])}
/>,
);

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(<SettlementTable settlements={settlements} />);
Expand Down
168 changes: 90 additions & 78 deletions src/components/SettlementTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ export function SettlementTable({
settlements,
onExecute,
onCancel,
pendingIds,
}: {
settlements: Settlement[];
onExecute?: (id: number) => void;
onCancel?: (id: number) => void;
pendingIds?: Set<number>;
}) {
const { sorted, sort, requestSort } = useSortableData<Settlement, SortKey>(
settlements,
Expand Down Expand Up @@ -76,46 +78,51 @@ export function SettlementTable({
</tr>
</thead>
<tbody>
{sorted.map((s) => (
<tr key={s.id} className="border-b border-zinc-900">
<td className="py-2 text-zinc-500">
<Link href={`/settlements/${s.id}`} className="block hover:underline">
{s.id}
</Link>
</td>
<td className="py-2 font-mono text-xs text-zinc-300">{s.anchor}</td>
<td className="py-2 font-mono text-zinc-100">{s.asset}</td>
<td className="py-2 text-zinc-200">{formatAmount(s.amount)}</td>
<td className="py-2 text-zinc-400">{formatAmount(s.fee)}</td>
<td className="py-2">
<StatusBadge status={s.status} />
</td>
{actionable ? (
<td className="py-2 text-right">
{s.status === "pending" ? (
<span className="flex justify-end gap-2">
{onExecute ? (
<button
onClick={() => onExecute(s.id)}
className="rounded-md px-2 py-1 text-xs text-emerald-400 hover:text-emerald-300"
>
Execute
</button>
) : null}
{onCancel ? (
<button
onClick={() => onCancel(s.id)}
className="rounded-md px-2 py-1 text-xs text-red-400 hover:text-red-300"
>
Cancel
</button>
) : null}
</span>
) : null}
{sorted.map((s) => {
const isPending = pendingIds?.has(s.id) ?? false;
return (
<tr key={s.id} className="border-b border-zinc-900">
<td className="py-2 text-zinc-500">
<Link href={`/settlements/${s.id}`} className="block hover:underline">
{s.id}
</Link>
</td>
<td className="py-2 font-mono text-xs text-zinc-300">{s.anchor}</td>
<td className="py-2 font-mono text-zinc-100">{s.asset}</td>
<td className="py-2 text-zinc-200">{formatAmount(s.amount)}</td>
<td className="py-2 text-zinc-400">{formatAmount(s.fee)}</td>
<td className="py-2">
<StatusBadge status={s.status} />
</td>
) : null}
</tr>
))}
{actionable ? (
<td className="py-2 text-right">
{s.status === "pending" ? (
<span className="flex justify-end gap-2">
{onExecute ? (
<button
onClick={() => onExecute(s.id)}
disabled={isPending}
className="rounded-md px-2 py-1 text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Execute
</button>
) : null}
{onCancel ? (
<button
onClick={() => onCancel(s.id)}
disabled={isPending}
className="rounded-md px-2 py-1 text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
Cancel
</button>
) : null}
</span>
) : null}
</td>
) : null}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-zinc-800 font-medium">
Expand All @@ -132,46 +139,51 @@ export function SettlementTable({

{/* Card view for small screens */}
<div className="block sm:hidden space-y-4">
{sorted.map((s) => (
<div key={s.id} data-testid="settlement-card" className="border border-zinc-800 rounded p-4 bg-zinc-900">
<div className="flex justify-between items-center mb-2">
<Link href={`/settlements/${s.id}`} className="text-zinc-500 hover:underline font-medium">
Settlement #{s.id}
</Link>
<StatusBadge status={s.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="font-medium text-zinc-400">Anchor</div>
<div className="font-mono text-zinc-300">{s.anchor}</div>
<div className="font-medium text-zinc-400">Asset</div>
<div className="font-mono text-zinc-100">{s.asset}</div>
<div className="font-medium text-zinc-400">Amount</div>
<div className="text-zinc-200">{formatAmount(s.amount)}</div>
<div className="font-medium text-zinc-400">Fee</div>
<div className="text-zinc-400">{formatAmount(s.fee)}</div>
</div>
{actionable && s.status === "pending" && (
<div className="mt-2 flex justify-end gap-2">
{onExecute && (
<button
onClick={() => onExecute(s.id)}
className="rounded-md px-2 py-1 text-xs text-emerald-400 hover:text-emerald-300"
>
Execute
</button>
)}
{onCancel && (
<button
onClick={() => onCancel(s.id)}
className="rounded-md px-2 py-1 text-xs text-red-400 hover:text-red-300"
>
Cancel
</button>
)}
{sorted.map((s) => {
const isPending = pendingIds?.has(s.id) ?? false;
return (
<div key={s.id} data-testid="settlement-card" className="border border-zinc-800 rounded p-4 bg-zinc-900">
<div className="flex justify-between items-center mb-2">
<Link href={`/settlements/${s.id}`} className="text-zinc-500 hover:underline font-medium">
Settlement #{s.id}
</Link>
<StatusBadge status={s.status} />
</div>
)}
</div>
))}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="font-medium text-zinc-400">Anchor</div>
<div className="font-mono text-zinc-300">{s.anchor}</div>
<div className="font-medium text-zinc-400">Asset</div>
<div className="font-mono text-zinc-100">{s.asset}</div>
<div className="font-medium text-zinc-400">Amount</div>
<div className="text-zinc-200">{formatAmount(s.amount)}</div>
<div className="font-medium text-zinc-400">Fee</div>
<div className="text-zinc-400">{formatAmount(s.fee)}</div>
</div>
{actionable && s.status === "pending" && (
<div className="mt-2 flex justify-end gap-2">
{onExecute && (
<button
onClick={() => onExecute(s.id)}
disabled={isPending}
className="rounded-md px-2 py-1 text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Execute
</button>
)}
{onCancel && (
<button
onClick={() => onCancel(s.id)}
disabled={isPending}
className="rounded-md px-2 py-1 text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
)}
</div>
);
})}
{/* Totals card */}
<div className="border border-zinc-800 rounded p-4 bg-zinc-900">
<div className="font-medium text-zinc-400 mb-1">Total (visible rows)</div>
Expand Down
105 changes: 92 additions & 13 deletions src/components/SettlementsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -58,6 +45,7 @@ vi.mock("@/lib/api", () => ({
beforeEach(() => {
vi.clearAllMocks();
mockSearchParamsString = "";
vi.mocked(fetchPools).mockResolvedValue([]);
});

function page(
Expand Down Expand Up @@ -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<Settlement>((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<Settlement>((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(
Expand Down
Loading
Loading