-
+
+
+
{state === "success" && (
diff --git a/src/components/SorobanPanel.test.tsx b/src/components/SorobanPanel.test.tsx
index f82eda2..44d250f 100644
--- a/src/components/SorobanPanel.test.tsx
+++ b/src/components/SorobanPanel.test.tsx
@@ -8,6 +8,12 @@ import { SorobanPanel } from "./SorobanPanel";
const mockInvokeContract = vi.fn();
const mockSimulateContract = vi.fn();
+/** Builds a format-valid contract ID ("C" + 55 uppercase/digits) carrying `n`. */
+function fakeContractId(n: number): string {
+ const suffix = String(n).padStart(2, "0");
+ return `C${suffix}${"A".repeat(55 - suffix.length)}`;
+}
+
vi.mock("@/context/useSorokit", () => ({
useSorokit: vi.fn(() => ({
isConnected: true,
@@ -33,6 +39,10 @@ vi.mock("../lib/client", () => ({
describe("SorobanPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
+ // The component persists its invocation-debugger history to
+ // sessionStorage; without clearing it, entries written by one test leak
+ // into the next and make history-dependent assertions order-dependent.
+ sessionStorage.clear();
vi.mocked(useSorokit).mockReturnValue({
isConnected: true,
address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA",
@@ -59,7 +69,7 @@ describe("SorobanPanel", () => {
data: { success: true },
error: null,
});
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), {
target: { value: "balance" },
});
@@ -83,17 +93,17 @@ describe("SorobanPanel", () => {
const { rerender } = render();
fireEvent.change(screen.getByPlaceholderText(/transfer/i), { target: { value: "mint" } });
fireEvent.change(screen.getByPlaceholderText(/\[.*\]/i), { target: { value: "invalid json {" } });
- rerender();
+ rerender();
fireEvent.click(screen.getByTestId("soroban-submit"));
expect(await screen.findByText(/Invalid JSON in arguments/i)).toBeInTheDocument();
});
it("rejects a JSON object with 'must be a JSON array' error", async () => {
const { rerender } = render( {}} />);
- fireEvent.change(screen.getByPlaceholderText(/c\.\.\./i), { target: { value: "C123" } });
+ fireEvent.change(screen.getByPlaceholderText(/c\.\.\./i), { target: { value: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } });
fireEvent.change(screen.getByPlaceholderText(/transfer/i), { target: { value: "mint" } });
fireEvent.change(screen.getByPlaceholderText(/\[.*\]/i), { target: { value: "{}" } });
- rerender( {}} />);
+ rerender( {}} />);
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
expect(await screen.findByText(/Arguments must be a JSON array/i)).toBeInTheDocument();
});
@@ -101,23 +111,23 @@ describe("SorobanPanel", () => {
it("accepts a valid JSON array and reaches success state", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { success: true }, error: null });
const { rerender } = render( {}} />);
- fireEvent.change(screen.getByPlaceholderText(/c\.\.\./i), { target: { value: "C123" } });
+ fireEvent.change(screen.getByPlaceholderText(/c\.\.\./i), { target: { value: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } });
fireEvent.change(screen.getByPlaceholderText(/transfer/i), { target: { value: "mint" } });
fireEvent.change(screen.getByPlaceholderText(/\[.*\]/i), { target: { value: '["arg1", 42]' } });
- rerender( {}} />);
+ rerender( {}} />);
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
expect(await screen.findByText("Result", { selector: "span" })).toBeInTheDocument();
});
it("calls invokeContract with correct parameters", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { success: true, balance: 1000 }, error: null });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.change(screen.getByLabelText("Arguments (JSON array)"), { target: { value: '["GAAZI...", 42]' } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
expect(mockInvokeContract).toHaveBeenCalledWith({
- contractId: "C123",
+ contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
method: "balance",
args: ["GAAZI...", 42],
sourceAccount: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA",
@@ -126,7 +136,7 @@ describe("SorobanPanel", () => {
it("shows error text when invokeContract returns an error", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: null, error: "Contract execution failed" });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "mint" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
expect(await screen.findByText("Contract execution failed")).toBeInTheDocument();
@@ -134,7 +144,7 @@ describe("SorobanPanel", () => {
it("resets state on Clear", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { success: true }, error: null });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -144,7 +154,7 @@ describe("SorobanPanel", () => {
it("invokes with Cmd+Enter from the arguments field", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { ok: true }, error: null });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), {
target: { value: "balance" },
});
@@ -159,7 +169,7 @@ describe("SorobanPanel", () => {
});
it("grows the argument textarea as lines are added and remains user-resizable", () => {
- render( {}} />);
+ render( {}} />);
const textarea = screen.getByLabelText("Arguments (JSON array)") as HTMLTextAreaElement;
Object.defineProperty(textarea, "scrollHeight", { value: 120, configurable: true });
@@ -170,7 +180,7 @@ describe("SorobanPanel", () => {
});
it("grows the argument textarea as lines are added and remains user-resizable", () => {
- render( {}} />);
+ render( {}} />);
const textarea = screen.getByLabelText(
"Arguments (JSON array)",
) as HTMLTextAreaElement;
@@ -190,7 +200,7 @@ describe("SorobanPanel", () => {
// with its label, and its validation error with the field itself.
describe("arguments textarea accessibility", () => {
it("associates the label with the textarea via htmlFor and id", () => {
- render( {}} />);
+ render( {}} />);
const textarea = screen.getByRole("textbox", {
name: "Arguments (JSON array)",
});
@@ -203,7 +213,7 @@ describe("SorobanPanel", () => {
});
it("points aria-describedby at the error message when the JSON is invalid", () => {
- render( {}} />);
+ render( {}} />);
const textarea = screen.getByLabelText("Arguments (JSON array)");
expect(textarea).not.toHaveAttribute("aria-describedby");
expect(textarea).toHaveAttribute("aria-invalid", "false");
@@ -221,7 +231,7 @@ describe("SorobanPanel", () => {
describe("simulate mode", () => {
it("renders Simulate badge and subtitle", () => {
- render( {}} mode="simulate" />);
+ render( {}} mode="simulate" />);
const badges = screen.getAllByText("Simulate");
expect(badges.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/read-only/i)).toBeInTheDocument();
@@ -229,7 +239,7 @@ describe("SorobanPanel", () => {
it("calls simulateContract instead of invokeContract", async () => {
mockSimulateContract.mockResolvedValueOnce({ data: { gasEstimate: 123456 }, error: null });
- render( {}} mode="simulate" />);
+ render( {}} mode="simulate" />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /simulate/i }));
await screen.findByText("Simulation Result", { selector: "span" });
@@ -239,7 +249,7 @@ describe("SorobanPanel", () => {
it("shows Simulation Result badge on success", async () => {
mockSimulateContract.mockResolvedValueOnce({ data: { gasEstimate: 123456 }, error: null });
- render( {}} mode="simulate" />);
+ render( {}} mode="simulate" />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /simulate/i }));
expect(await screen.findByText("Simulation Result", { selector: "span" })).toBeInTheDocument();
@@ -280,7 +290,7 @@ describe("SorobanPanel", () => {
});
it("updates the textarea height style dynamically on input", () => {
- render( {}} />);
+ render( {}} />);
const textarea = screen.getByLabelText("Arguments (JSON array)") as HTMLTextAreaElement;
Object.defineProperty(textarea, "scrollHeight", { value: 120, configurable: true });
@@ -371,7 +381,7 @@ describe("SorobanPanel", () => {
it("shows Simulating… label while loading", async () => {
let resolveSimulate: (v: { data: unknown; error: null }) => void = () => {};
mockSimulateContract.mockReturnValueOnce(new Promise((resolve) => { resolveSimulate = resolve; }));
- render( {}} mode="simulate" />);
+ render( {}} mode="simulate" />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /simulate/i }));
expect(screen.getByText("Simulating…")).toBeInTheDocument();
@@ -381,12 +391,12 @@ describe("SorobanPanel", () => {
it("passes correct params to simulateContract", async () => {
mockSimulateContract.mockResolvedValueOnce({ data: { gasEstimate: 50000 }, error: null });
- render( {}} mode="simulate" />);
+ render( {}} mode="simulate" />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /simulate/i }));
await screen.findByText("Simulation Result", { selector: "span" });
expect(mockSimulateContract).toHaveBeenCalledWith({
- contractId: "C123",
+ contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
method: "balance",
args: [],
sourceAccount: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA",
@@ -395,12 +405,12 @@ describe("SorobanPanel", () => {
it("caps history at 20 by default and evicts the oldest entry when 21st contract is added", async () => {
mockInvokeContract.mockResolvedValue({ data: { ok: true }, error: null });
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "test" } });
// Add 21 contracts sequentially
for (let i = 1; i <= 21; i++) {
- const id = `C${i}`;
+ const id = fakeContractId(i);
rerender( {}} />);
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -410,17 +420,17 @@ describe("SorobanPanel", () => {
const raw = localStorage.getItem("sorokit-soroban-contract-history");
const stored = JSON.parse(raw || "[]");
expect(stored.length).toBe(20);
- expect(stored[0]).toBe("C21");
- expect(stored).not.toContain("C1");
+ expect(stored[0]).toBe(fakeContractId(21));
+ expect(stored).not.toContain(fakeContractId(1));
});
it("respects custom maxHistory prop", async () => {
mockInvokeContract.mockResolvedValue({ data: { ok: true }, error: null });
- const { rerender } = render( {}} maxHistory={5} />);
+ const { rerender } = render( {}} maxHistory={5} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "test" } });
for (let i = 1; i <= 7; i++) {
- const id = `C${i}`;
+ const id = fakeContractId(i);
rerender( {}} maxHistory={5} />);
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -430,9 +440,9 @@ describe("SorobanPanel", () => {
const raw = localStorage.getItem("sorokit-soroban-contract-history");
const stored = JSON.parse(raw || "[]");
expect(stored.length).toBe(5);
- expect(stored[0]).toBe("C7");
- expect(stored).not.toContain("C1");
- expect(stored).not.toContain("C2");
+ expect(stored[0]).toBe(fakeContractId(7));
+ expect(stored).not.toContain(fakeContractId(1));
+ expect(stored).not.toContain(fakeContractId(2));
});
});
@@ -449,7 +459,7 @@ describe("SorobanPanel", () => {
it("toggles the ABI paste section when clicking Load ABI", async () => {
- render( {}} />);
+ render( {}} />);
const toggleBtn = screen.getByText("Load ABI");
fireEvent.click(toggleBtn);
expect(await screen.findByPlaceholderText(/paste contract abi/i)).toBeInTheDocument();
@@ -460,13 +470,13 @@ describe("SorobanPanel", () => {
});
it("populates the method dropdown from a valid ABI array spec", async () => {
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
await openAbiSection();
const textarea = screen.getByPlaceholderText(/paste contract abi/i);
const abiValue = JSON.stringify([{ name: "transfer" }, { name: "balance" }, { name: "mint" }]);
fireEvent.change(textarea, { target: { value: abiValue } });
// Force re-render so useCallback captures the new abiRaw
- rerender( {}} />);
+ rerender( {}} />);
fireEvent.click(screen.getByRole("button", { name: "Load" }));
expect(await screen.findByText(/3 methods loaded/i)).toBeInTheDocument();
@@ -481,13 +491,13 @@ describe("SorobanPanel", () => {
});
it("populates the method dropdown from a spec sub-field", async () => {
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
await openAbiSection();
const textarea = screen.getByPlaceholderText(/paste contract abi/i);
const abiValue = JSON.stringify({ spec: [{ name: "init" }, { name: "upgrade" }] });
fireEvent.change(textarea, { target: { value: abiValue } });
// Force re-render so useCallback captures the new abiRaw
- rerender( {}} />);
+ rerender( {}} />);
const buttons = screen.getAllByRole("button");
const loadBtn = buttons.find((b) => b.textContent === "Load");
fireEvent.click(loadBtn!);
@@ -500,13 +510,13 @@ describe("SorobanPanel", () => {
});
it("shows an error when no method names are found in the ABI", async () => {
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
await openAbiSection();
const textarea = screen.getByPlaceholderText(/paste contract abi/i);
fireEvent.change(textarea, {
target: { value: JSON.stringify([{ noName: true }]) },
});
- rerender( {}} />);
+ rerender( {}} />);
const buttons = screen.getAllByRole("button");
const loadBtn = buttons.find((b) => b.textContent === "Load");
fireEvent.click(loadBtn!);
@@ -514,11 +524,11 @@ describe("SorobanPanel", () => {
});
it("shows an error when the ABI JSON is invalid", async () => {
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
await openAbiSection();
const textarea = screen.getByPlaceholderText(/paste contract abi/i);
fireEvent.change(textarea, { target: { value: "not json" } });
- rerender( {}} />);
+ rerender( {}} />);
const buttons = screen.getAllByRole("button");
const loadBtn = buttons.find((b) => b.textContent === "Load");
fireEvent.click(loadBtn!);
@@ -526,7 +536,7 @@ describe("SorobanPanel", () => {
});
it("disables Load button when the ABI textarea is empty", async () => {
- render( {}} />);
+ render( {}} />);
fireEvent.click(screen.getByText("Load ABI"));
await screen.findByPlaceholderText(/paste contract abi/i);
const buttons = screen.getAllByRole("button");
@@ -535,14 +545,14 @@ describe("SorobanPanel", () => {
});
it("clears ABI state when the Clear button is clicked", async () => {
- const { rerender } = render( {}} />);
+ const { rerender } = render( {}} />);
fireEvent.click(screen.getByText("Load ABI"));
await screen.findByPlaceholderText(/paste contract abi/i);
const textarea = screen.getByPlaceholderText(/paste contract abi/i);
fireEvent.change(textarea, {
target: { value: JSON.stringify([{ name: "transfer" }]) },
});
- rerender( {}} />);
+ rerender( {}} />);
const loadBtn = screen.getAllByRole("button").find((b) => b.textContent === "Load");
fireEvent.click(loadBtn!);
expect(await screen.findByText(/1 method loaded/i)).toBeInTheDocument();
@@ -565,7 +575,7 @@ describe("SorobanPanel", () => {
it("renders a Copy as cURL button in the success result section", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { ok: true }, error: null });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -581,7 +591,7 @@ describe("SorobanPanel", () => {
configurable: true,
writable: true,
});
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -590,7 +600,7 @@ describe("SorobanPanel", () => {
const curlText = writeText.mock.calls[0][0] as string;
expect(curlText).toContain("curl -X POST");
expect(curlText).toContain("https://soroban-rpc.example.com/invoke");
- expect(curlText).toContain("C123");
+ expect(curlText).toContain("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
expect(curlText).toContain("balance");
expect(curlText).toContain("Content-Type: application/json");
});
@@ -603,7 +613,7 @@ describe("SorobanPanel", () => {
configurable: true,
writable: true,
});
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -614,7 +624,7 @@ describe("SorobanPanel", () => {
it("renders Copy as cURL in footbar for invoke mode success", async () => {
mockInvokeContract.mockResolvedValueOnce({ data: { ok: true }, error: null });
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
await screen.findByText("Result", { selector: "span" });
@@ -637,7 +647,7 @@ describe("SorobanPanel", () => {
it("ignores a second click while an invocation is in flight", async () => {
const finish = pendingInvoke();
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), {
target: { value: "balance" },
});
@@ -657,7 +667,7 @@ describe("SorobanPanel", () => {
it("ignores Cmd+Enter while an invocation is in flight", async () => {
const finish = pendingInvoke();
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), {
target: { value: "balance" },
});
@@ -678,7 +688,7 @@ describe("SorobanPanel", () => {
it("allows a fresh invocation once the previous one settles", async () => {
const finish = pendingInvoke();
- render( {}} />);
+ render( {}} />);
fireEvent.change(screen.getByLabelText("Method"), {
target: { value: "balance" },
});
@@ -696,7 +706,7 @@ describe("SorobanPanel", () => {
});
it("does not invoke at all when the guard's preconditions are unmet", () => {
- render( {}} />);
+ render( {}} />);
// No method entered, so canInvoke is false.
fireEvent.click(screen.getByRole("button", { name: /invoke/i }));
@@ -708,4 +718,143 @@ describe("SorobanPanel", () => {
expect(mockInvokeContract).not.toHaveBeenCalled();
});
});
+
+ // ── Contract ID format validation (#904) ────────────────────────────────
+ describe("contract ID format validation", () => {
+ it("shows inline feedback for a malformed contract ID once the field is blurred", () => {
+ render( {}} />);
+ const input = screen.getByLabelText("Contract ID");
+ fireEvent.blur(input);
+ expect(screen.getByText(/Invalid contract ID/i)).toBeInTheDocument();
+ });
+
+ it("does not show an error before the contract ID field has been touched", () => {
+ render( {}} />);
+ expect(screen.queryByText(/Invalid contract ID/i)).not.toBeInTheDocument();
+ });
+
+ it("disables Invoke for a malformed contract ID even with a method filled in", () => {
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
+ expect(screen.getByTestId("soroban-submit")).toBeDisabled();
+ });
+
+ it("never calls invokeContract for a malformed contract ID", () => {
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
+ fireEvent.click(screen.getByTestId("soroban-submit"));
+ expect(mockInvokeContract).not.toHaveBeenCalled();
+ });
+
+ it("trims leading/trailing whitespace from the contract ID on blur", () => {
+ const onContractIdChange = vi.fn();
+ const validId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+ render(
+ ,
+ );
+ fireEvent.blur(screen.getByLabelText("Contract ID"));
+ expect(onContractIdChange).toHaveBeenCalledWith(validId);
+ });
+
+ it("allows invoking once a valid, trimmed contract ID is entered", async () => {
+ mockInvokeContract.mockResolvedValueOnce({ data: { ok: true }, error: null });
+ const validId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
+ expect(screen.getByTestId("soroban-submit")).not.toBeDisabled();
+ fireEvent.click(screen.getByTestId("soroban-submit"));
+ await screen.findByText("Result", { selector: "span" });
+ expect(mockInvokeContract).toHaveBeenCalledOnce();
+ });
+
+ it("does not show an error for an empty contract ID", () => {
+ render( {}} />);
+ fireEvent.blur(screen.getByLabelText("Contract ID"));
+ expect(screen.queryByText(/Invalid contract ID/i)).not.toBeInTheDocument();
+ });
+ });
+
+ // ── ABI method categorization (#904) ─────────────────────────────────────
+ describe("ABI method categorization", () => {
+ async function loadAbi(abi: unknown) {
+ fireEvent.click(screen.getByText("Load ABI"));
+ const textarea = await screen.findByPlaceholderText(/paste contract abi/i);
+ fireEvent.change(textarea, { target: { value: JSON.stringify(abi) } });
+ const loadBtn = screen.getAllByRole("button").find((b) => b.textContent === "Load");
+ fireEvent.click(loadBtn!);
+ await screen.findByText(/methods? loaded/i);
+ }
+
+ it("groups ABI methods into Read (View) and Write (State Changes) sections", async () => {
+ render( {}} />);
+ await loadAbi([
+ { name: "get_balance", mutating: false },
+ { name: "transfer", mutating: true },
+ ]);
+
+ const select = screen.getByLabelText("Select ABI method");
+ const groups = Array.from(select.querySelectorAll("optgroup")).map(
+ (g) => g.getAttribute("label"),
+ );
+ expect(groups).toContain("Read (View)");
+ expect(groups).toContain("Write (State Changes)");
+ });
+
+ it("infers read-only methods from naming hints when mutability is unspecified", async () => {
+ render( {}} />);
+ await loadAbi([{ name: "view_balance" }, { name: "transfer" }]);
+
+ const select = screen.getByLabelText("Select ABI method");
+ const readGroup = Array.from(select.querySelectorAll("optgroup")).find(
+ (g) => g.getAttribute("label") === "Read (View)",
+ );
+ expect(readGroup).toBeDefined();
+ expect(readGroup!.querySelector("option")?.value).toBe("view_balance");
+ });
+
+ it("selecting a method from the ABI dropdown populates the Method field", async () => {
+ render( {}} />);
+ await loadAbi([{ name: "get_balance", mutating: false }]);
+
+ const select = screen.getByLabelText("Select ABI method") as HTMLSelectElement;
+ fireEvent.change(select, { target: { value: "get_balance" } });
+
+ expect(screen.getByLabelText("Method")).toHaveValue("get_balance");
+ });
+ });
+
+ // ── Load invocation history into form (#904) ────────────────────────────
+ describe("load invocation history into form", () => {
+ it("repopulates the builder inputs when 'Load into form' is clicked", async () => {
+ const validId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+ mockInvokeContract.mockResolvedValueOnce({ data: { ok: true }, error: null });
+ const onContractIdChange = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("Method"), { target: { value: "balance" } });
+ fireEvent.change(screen.getByLabelText("Arguments (JSON array)"), {
+ target: { value: '["arg1"]' },
+ });
+ fireEvent.click(screen.getByTestId("soroban-submit"));
+ await screen.findByText("Result", { selector: "span" });
+
+ // Change the method so the form no longer matches the history entry,
+ // then load the just-created entry back in via its history card.
+ fireEvent.change(screen.getByLabelText("Method"), { target: { value: "other_method" } });
+ fireEvent.click(screen.getByRole("button", { name: /show debugger/i }));
+
+ const loadButton = await screen.findByRole("button", { name: /load into form/i });
+ fireEvent.click(loadButton);
+
+ expect(onContractIdChange).toHaveBeenCalledWith(validId);
+ expect(screen.getByLabelText("Method")).toHaveValue("balance");
+ expect(screen.getByLabelText("Arguments (JSON array)")).toHaveValue(
+ JSON.stringify(["arg1"], null, 2),
+ );
+ });
+ });
});
diff --git a/src/components/SorobanPanel.tsx b/src/components/SorobanPanel.tsx
index c554ea2..6f59503 100644
--- a/src/components/SorobanPanel.tsx
+++ b/src/components/SorobanPanel.tsx
@@ -9,6 +9,37 @@ import { addDebugHistory, ContractInteractionDebugger, createDebuggerEntry, type
type State = "idle" | "loading" | "success" | "error";
+/** Stellar/Soroban contract IDs are strict-key-encoded: "C" + 55 base32 chars. */
+const CONTRACT_ID_PATTERN = /^C[A-Z0-9]{55}$/;
+
+/** True read-only ABI entries. Anything else is treated as state-changing. */
+const READ_ONLY_HINTS = ["view", "read", "query", "get"];
+
+function isReadOnlyMethod(name: string, mutating?: boolean): boolean {
+ if (typeof mutating === "boolean") return !mutating;
+ const lower = name.toLowerCase();
+ return READ_ONLY_HINTS.some((hint) => lower.startsWith(hint) || lower.includes(`_${hint}`));
+}
+
+interface AbiMethod {
+ name: string;
+ readOnly: boolean;
+}
+
+/**
+ * Validates and trims a contract ID before any RPC call is allowed. An empty
+ * string is not itself an error (nothing has been typed yet) — only a
+ * non-empty value that fails the format check produces a message.
+ */
+function validateContractId(raw: string): string | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ if (!CONTRACT_ID_PATTERN.test(trimmed)) {
+ return 'Invalid contract ID — expected "C" followed by 55 uppercase letters/digits';
+ }
+ return null;
+}
+
interface SorobanPanelProps {
contractId: string;
onContractIdChange: (contractId: string) => void;
@@ -111,8 +142,9 @@ export function SorobanPanel({
);
const [abiOpen, setAbiOpen] = useState(false);
const [abiRaw, setAbiRaw] = useState("");
- const [abiMethods, setAbiMethods] = useState([]);
+ const [abiMethods, setAbiMethods] = useState([]);
const [abiError, setAbiError] = useState(null);
+ const [contractIdTouched, setContractIdTouched] = useState(false);
const abortControllerRef = useRef(null);
const argsRef = useRef(args);
const formId = useId();
@@ -141,13 +173,20 @@ export function SorobanPanel({
}
}, [args]);
+ const contractIdError = validateContractId(contractId);
+
const canInvoke =
Boolean(isConnected && contractId.trim() && method.trim()) &&
isArgsJsonValid &&
+ !contractIdError &&
state !== "loading";
async function doInvoke() {
if (!canInvoke) return;
+ // Belt-and-suspenders: canInvoke already covers this, but doInvoke can
+ // also be reached via the Cmd+Enter shortcut, so re-check right before
+ // any RPC call is made rather than relying solely on the disabled button.
+ if (validateContractId(contractId)) return;
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
@@ -235,20 +274,33 @@ export function SorobanPanel({
setAbiError(null);
try {
const parsed = JSON.parse(abiRaw);
- const methods: string[] = [];
- if (Array.isArray(parsed)) {
- for (const entry of parsed) {
- if (entry.name && typeof entry.name === "string") {
- methods.push(entry.name);
- }
- }
- } else if (parsed.spec && Array.isArray(parsed.spec)) {
- for (const entry of parsed.spec) {
- if (entry.name && typeof entry.name === "string") {
- methods.push(entry.name);
- }
- }
+ const entries: unknown[] = Array.isArray(parsed)
+ ? parsed
+ : parsed?.spec && Array.isArray(parsed.spec)
+ ? parsed.spec
+ : [];
+
+ const methods: AbiMethod[] = [];
+ for (const entry of entries) {
+ if (!entry || typeof entry !== "object") continue;
+ const { name, mutating, readonly, stateMutability } = entry as {
+ name?: unknown;
+ mutating?: unknown;
+ readonly?: unknown;
+ stateMutability?: unknown;
+ };
+ if (typeof name !== "string") continue;
+ const isMutating =
+ typeof mutating === "boolean"
+ ? mutating
+ : typeof readonly === "boolean"
+ ? !readonly
+ : typeof stateMutability === "string"
+ ? !["view", "pure"].includes(stateMutability.toLowerCase())
+ : undefined;
+ methods.push({ name, readOnly: isReadOnlyMethod(name, isMutating) });
}
+
if (methods.length === 0) {
setAbiError("No method names found in the ABI spec");
return;
@@ -259,6 +311,13 @@ export function SorobanPanel({
}
}
+ /** Repopulates the builder inputs from a past invocation-history entry. */
+ function handleLoadHistoryEntry(entry: DebuggerEntry) {
+ onContractIdChange(entry.contractId);
+ setMethod(entry.method);
+ setArgs(JSON.stringify(entry.args ?? [], null, 2));
+ }
+
const handleCopyCurl = useCallback(() => {
const { parsedArgs } = parseArgsInput(argsRef.current);
const curl = buildCurlCommand(contractId.trim(), method.trim(), parsedArgs);
@@ -295,12 +354,19 @@ export function SorobanPanel({
placeholder="C..."
value={contractId}
onChange={(e) => onContractIdChange(e.target.value)}
+ onBlur={(e) => {
+ setContractIdTouched(true);
+ const trimmed = e.target.value.trim();
+ if (trimmed !== e.target.value) onContractIdChange(trimmed);
+ }}
disabled={state === "loading"}
list={
contractHistory.length > 0
? CONTRACT_HISTORY_DATALIST_ID
: undefined
}
+ error={contractIdTouched ? contractIdError ?? undefined : undefined}
+ aria-invalid={Boolean(contractIdTouched && contractIdError)}
/>
{contractHistory.length > 0 && (
{/* Issue #542: reuse the Input component's label-association logic
(htmlFor + aria-describedby) instead of a hand-wired textarea. */}
@@ -426,6 +530,7 @@ export function SorobanPanel({
error={error}
history={debugHistory}
onHistoryChange={setDebugHistory}
+ onLoadEntry={handleLoadHistoryEntry}
/>
)}
diff --git a/src/components/TransactionHistoryTable.test.tsx b/src/components/TransactionHistoryTable.test.tsx
index 88534ef..2a1cd68 100644
--- a/src/components/TransactionHistoryTable.test.tsx
+++ b/src/components/TransactionHistoryTable.test.tsx
@@ -223,6 +223,35 @@ describe("TransactionHistoryTable", () => {
});
});
+ it("renders an accessible copy-hash button for each transaction", async () => {
+ render(
);
+ await waitFor(() => screen.getByText(/25 transactions/));
+
+ const copyButtons = screen.getAllByLabelText("Copy transaction hash");
+ expect(copyButtons.length).toBeGreaterThan(0);
+ });
+
+ it("copies the transaction hash to the clipboard when clicked", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ writable: true,
+ });
+
+ render(
);
+ await waitFor(() => screen.getByText(/25 transactions/));
+
+ const [copyButton] = screen.getAllByLabelText("Copy transaction hash");
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(writeText).toHaveBeenCalledTimes(1);
+ const copiedHash = writeText.mock.calls[0][0];
+ expect(mockTransactions.some((tx) => tx.hash === copiedHash)).toBe(true);
+ });
+ });
+
describe("CSV Export via onExport callback", () => {
it("calls onExport callback with CSV content when export button clicked", async () => {
const mockExport = vi.fn();
diff --git a/src/components/TransactionHistoryTable.tsx b/src/components/TransactionHistoryTable.tsx
index 3443864..85ce312 100644
--- a/src/components/TransactionHistoryTable.tsx
+++ b/src/components/TransactionHistoryTable.tsx
@@ -3,10 +3,12 @@ import {
ArrowRight01Icon,
Cancel01Icon,
CheckmarkCircle01Icon,
+ Copy01Icon,
Download01Icon,
FilterIcon,
SortByDown01Icon,
SortByUp01Icon,
+ Tick01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -290,6 +292,50 @@ function FilterBar({ filters, onChange, show, onToggle }: FilterBarProps) {
);
}
+/* ------------------------------------------------------------------ */
+/* Copy Hash Button */
+/* ------------------------------------------------------------------ */
+
+const COPY_HASH_RESET_MS = 2000;
+
+function CopyHashButton({ hash }: { hash: string }) {
+ const [copied, setCopied] = useState(false);
+
+ async function handleCopy(e: React.MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ try {
+ await navigator.clipboard.writeText(hash);
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPY_HASH_RESET_MS);
+ } catch {
+ // Clipboard access can fail (e.g. non-secure context or denied
+ // permission); silently no-op rather than throw in the UI.
+ }
+ }
+
+ return (
+
+ );
+}
+
/* ------------------------------------------------------------------ */
/* Sortable Header */
/* ------------------------------------------------------------------ */
@@ -573,30 +619,33 @@ export function TransactionHistoryTable({
- {(() => {
- const url = explorerTxUrl(network, tx.hash);
- return url ? (
-
- {truncateAddress(tx.hash, 8, 6)}
-
- ↗
+
+ {(() => {
+ const url = explorerTxUrl(network, tx.hash);
+ return url ? (
+
+ {truncateAddress(tx.hash, 8, 6)}
+
+ ↗
+
+
+ ) : (
+
+ {truncateAddress(tx.hash, 8, 6)}
-
- ) : (
-
- {truncateAddress(tx.hash, 8, 6)}
-
- );
- })()}
+ );
+ })()}
+
+
{tx.memo && (
{tx.successful ? "Success" : "Failed"}
+
Ledger {tx.ledger}
diff --git a/src/screens/TransactionsScreen.test.tsx b/src/screens/TransactionsScreen.test.tsx
index 084a6c1..993ac4f 100644
--- a/src/screens/TransactionsScreen.test.tsx
+++ b/src/screens/TransactionsScreen.test.tsx
@@ -1,9 +1,9 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach,describe, expect, it, vi } from "vitest";
import { ToastProvider } from "@/context/ToastContext";
import { useSorokit } from "@/context/useSorokit";
-import type { SorokitClient } from "@/lib/client";
+import type { SorokitClient, Transaction } from "@/lib/client";
import { getClient } from "@/lib/client";
import { TransactionsScreen } from "./TransactionsScreen";
@@ -16,7 +16,11 @@ vi.mock("@/lib/client", () => ({
getClient: vi.fn(),
}));
+const getHistoryMock = vi.fn();
+
function mockClient() {
+ getHistoryMock.mockReset();
+ getHistoryMock.mockResolvedValue({ data: [], error: null, total: 0 });
vi.mocked(getClient).mockReturnValue({
network: {
getNetwork: vi.fn(),
@@ -29,7 +33,7 @@ function mockClient() {
getFeeScenarios: vi.fn(),
submit: vi.fn().mockReturnValue(new Promise(() => {})),
getStatus: vi.fn(),
- getHistory: vi.fn(),
+ getHistory: getHistoryMock,
},
wallet: {
connect: vi.fn(),
@@ -125,4 +129,148 @@ describe("TransactionsScreen", () => {
expect(feeIndex).toBeLessThan(panelIndex);
expect(panelIndex).toBeLessThan(timelineIndex);
});
+
+ describe("date range validation", () => {
+ it("clamps the end date to the start date when it would precede it", () => {
+ renderScreen();
+
+ const startInput = screen.getByLabelText(
+ "Export start date",
+ ) as HTMLInputElement;
+ const endInput = screen.getByLabelText(
+ "Export end date",
+ ) as HTMLInputElement;
+
+ fireEvent.change(endInput, { target: { value: "2026-01-20" } });
+ expect(endInput.value).toBe("2026-01-20");
+
+ // Setting a later start date should pull the end date forward with it.
+ fireEvent.change(startInput, { target: { value: "2026-01-25" } });
+ expect(startInput.value).toBe("2026-01-25");
+ expect(endInput.value).toBe("2026-01-25");
+ });
+
+ it("clamps an end date chosen before the current start date", () => {
+ renderScreen();
+
+ const startInput = screen.getByLabelText(
+ "Export start date",
+ ) as HTMLInputElement;
+ const endInput = screen.getByLabelText(
+ "Export end date",
+ ) as HTMLInputElement;
+
+ fireEvent.change(startInput, { target: { value: "2026-02-10" } });
+ fireEvent.change(endInput, { target: { value: "2026-01-01" } });
+
+ expect(endInput.value).toBe("2026-02-10");
+ });
+
+ it("clamps a future end date to today", () => {
+ renderScreen();
+
+ const endInput = screen.getByLabelText(
+ "Export end date",
+ ) as HTMLInputElement;
+ const future = new Date();
+ future.setFullYear(future.getFullYear() + 5);
+ const futureIso = future.toISOString().slice(0, 10);
+
+ fireEvent.change(endInput, { target: { value: futureIso } });
+
+ expect(endInput.value).not.toBe(futureIso);
+ expect(new Date(endInput.value).getTime()).toBeLessThanOrEqual(Date.now());
+ });
+ });
+
+ describe("export progress and cancellation", () => {
+ function makeTransactions(count: number): Transaction[] {
+ return Array.from({ length: count }, (_, i) => ({
+ hash: `h${i.toString().padStart(10, "0")}`,
+ ledger: 1000 + i,
+ createdAt: new Date(2026, 0, 1, 0, i).toISOString(),
+ successful: true,
+ operationCount: 1,
+ feePaid: "100",
+ })) as Transaction[];
+ }
+
+ it("shows a progress modal with percentage and record count for large exports", async () => {
+ const total = 600;
+ getHistoryMock.mockImplementation(
+ (_address: string, page = 1, limit = 200) =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ const start = (page - 1) * limit;
+ const slice = makeTransactions(total).slice(start, start + limit);
+ resolve({ data: slice, error: null, total });
+ }, 10);
+ }),
+ );
+
+ renderScreen();
+ fireEvent.click(screen.getByText("Export CSV"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Exporting transactions…")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText(/of 600 records/)).toBeInTheDocument();
+ });
+ });
+
+ it("does not show a progress modal for small exports", async () => {
+ getHistoryMock.mockResolvedValue({
+ data: makeTransactions(5),
+ error: null,
+ total: 5,
+ });
+
+ renderScreen();
+ fireEvent.click(screen.getByText("Export CSV"));
+
+ await waitFor(() => {
+ expect(getHistoryMock).toHaveBeenCalled();
+ });
+ expect(screen.queryByText("Exporting transactions…")).not.toBeInTheDocument();
+ });
+
+ it("cancels an in-progress export when Cancel is clicked", async () => {
+ const total = 1000;
+ let resolvePage2: (() => void) | null = null;
+ getHistoryMock.mockImplementation(
+ (_address: string, page = 1, limit = 200) => {
+ if (page === 1) {
+ const slice = makeTransactions(total).slice(0, limit);
+ return Promise.resolve({ data: slice, error: null, total });
+ }
+ return new Promise((resolve) => {
+ resolvePage2 = () => {
+ const start = (page - 1) * limit;
+ const slice = makeTransactions(total).slice(start, start + limit);
+ resolve({ data: slice, error: null, total });
+ };
+ });
+ },
+ );
+
+ renderScreen();
+ fireEvent.click(screen.getByText("Export CSV"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Exporting transactions…")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("Cancel"));
+ resolvePage2?.();
+
+ await waitFor(() => {
+ expect(screen.getByText("Export cancelled.")).toBeInTheDocument();
+ });
+ expect(
+ screen.queryByText("Exporting transactions…"),
+ ).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/src/screens/TransactionsScreen.tsx b/src/screens/TransactionsScreen.tsx
index f298abe..931d74d 100644
--- a/src/screens/TransactionsScreen.tsx
+++ b/src/screens/TransactionsScreen.tsx
@@ -1,3 +1,6 @@
+import * as Dialog from "@radix-ui/react-dialog";
+import { useRef, useState } from "react";
+
import { ActivityTimeline } from "@/components/ActivityTimeline";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { FeeEstimator } from "@/components/FeeEstimator";
@@ -5,8 +8,83 @@ import { GasOptimizer } from "@/components/GasOptimizer";
import { MultiSigTransactionBuilder } from "@/components/MultiSigTransactionBuilder";
import { TransactionHistory } from "@/components/TransactionHistory";
import { TransactionPanel } from "@/components/TransactionPanel";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { useSorokit } from "@/context/useSorokit";
+import type { Transaction } from "@/lib/client";
+import { getClient } from "@/lib/client";
import { SCREEN_LABELS } from "@/lib/nav-labels";
+/** Exports at or above this many records show the progress modal. */
+const EXPORT_PROGRESS_THRESHOLD = 500;
+/** Page size used when paging through history for export. */
+const EXPORT_PAGE_SIZE = 200;
+
+/** Today's date as a `YYYY-MM-DD` string in the viewer's local timezone. */
+function todayIsoDate(): string {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const day = String(now.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+function escapeCsv(value: string): string {
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+}
+
+function toCsv(rows: Transaction[]): string {
+ const headers = [
+ "Hash",
+ "Ledger",
+ "CreatedAt",
+ "Status",
+ "Fee (stroops)",
+ "Operations",
+ "Memo",
+ ];
+ const lines: string[] = [headers.join(",")];
+ for (const tx of rows) {
+ lines.push(
+ [
+ escapeCsv(tx.hash),
+ tx.ledger,
+ escapeCsv(tx.createdAt),
+ tx.successful ? "Success" : "Failed",
+ tx.feePaid,
+ tx.operationCount,
+ escapeCsv(tx.memo ?? ""),
+ ].join(","),
+ );
+ }
+ return lines.join("\n");
+}
+
+function downloadFile(content: string, filename: string, mimeType: string): void {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+type ExportFormat = "csv" | "json";
+
+interface ExportState {
+ format: ExportFormat;
+ fetched: number;
+ total: number;
+ cancelled: boolean;
+ done: boolean;
+ error: string | null;
+}
// Shared by GasOptimizer and FeeEstimator so both tick in lockstep;
// FeeEstimator's useFeeData() hook dedupes their estimateFee() calls to one
// request per tick regardless of how many components are polling.
@@ -14,6 +92,131 @@ const FEE_REFRESH_INTERVAL_MS = 15_000;
export function TransactionsScreen() {
const { title, sub } = SCREEN_LABELS.transactions;
+ const { address, client: contextClient } = useSorokit();
+ const client = contextClient ?? getClient();
+
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+ const [exportState, setExportState] = useState (null);
+ const cancelRef = useRef(false);
+
+ const today = todayIsoDate();
+
+ function handleStartDateChange(value: string) {
+ setStartDate(value);
+ // Keep the end date from preceding the (possibly later) new start date.
+ if (value && endDate && endDate < value) {
+ setEndDate(value);
+ }
+ }
+
+ function handleEndDateChange(value: string) {
+ // Clamp to [startDate, today] so an out-of-range pick never produces an
+ // inverted or future-dated range.
+ let clamped = value;
+ if (clamped > today) clamped = today;
+ if (startDate && clamped < startDate) clamped = startDate;
+ setEndDate(clamped);
+ }
+
+ async function runExport(format: ExportFormat) {
+ if (!address) return;
+ cancelRef.current = false;
+
+ const all: Transaction[] = [];
+ let page = 1;
+ let total = 0;
+ let showProgress = false;
+
+ setExportState({
+ format,
+ fetched: 0,
+ total: 0,
+ cancelled: false,
+ done: false,
+ error: null,
+ });
+
+ try {
+ while (true) {
+ if (cancelRef.current) {
+ setExportState((prev) =>
+ prev ? { ...prev, cancelled: true, done: true } : prev,
+ );
+ return;
+ }
+
+ const { data, error, total: t } = await client.transaction.getHistory(
+ address,
+ page,
+ EXPORT_PAGE_SIZE,
+ );
+ if (error) {
+ setExportState((prev) => (prev ? { ...prev, error, done: true } : prev));
+ return;
+ }
+
+ const batch = data ?? [];
+ all.push(...batch);
+ total = Number.isFinite(t) && t > 0 ? t : all.length;
+
+ if (total >= EXPORT_PROGRESS_THRESHOLD) showProgress = true;
+ if (showProgress) {
+ setExportState((prev) =>
+ prev ? { ...prev, fetched: all.length, total } : prev,
+ );
+ }
+
+ if (batch.length < EXPORT_PAGE_SIZE || all.length >= total) break;
+ page += 1;
+ }
+
+ if (cancelRef.current) {
+ setExportState((prev) =>
+ prev ? { ...prev, cancelled: true, done: true } : prev,
+ );
+ return;
+ }
+
+ const filtered = all.filter((tx) => {
+ if (startDate && new Date(tx.createdAt) < new Date(startDate)) return false;
+ if (endDate && new Date(tx.createdAt) > new Date(endDate + "T23:59:59"))
+ return false;
+ return true;
+ });
+
+ const content =
+ format === "csv" ? toCsv(filtered) : JSON.stringify(filtered, null, 2);
+ const extension = format === "csv" ? "csv" : "json";
+ const mimeType =
+ format === "csv" ? "text/csv;charset=utf-8;" : "application/json";
+ downloadFile(content, `transactions-${todayIsoDate()}.${extension}`, mimeType);
+
+ setExportState((prev) =>
+ prev ? { ...prev, fetched: filtered.length, total: filtered.length, done: true } : prev,
+ );
+ } catch {
+ setExportState((prev) =>
+ prev
+ ? { ...prev, error: "Export failed. Please try again.", done: true }
+ : prev,
+ );
+ }
+ }
+
+ function cancelExport() {
+ cancelRef.current = true;
+ }
+
+ function closeExportModal() {
+ setExportState(null);
+ }
+
+ const showModal = exportState !== null && !exportState.done;
+ const percent =
+ exportState && exportState.total > 0
+ ? Math.min(100, Math.round((exportState.fetched / exportState.total) * 100))
+ : 0;
return (
@@ -29,10 +232,109 @@ export function TransactionsScreen() {
+
+
+
-
+
+
+ !next && cancelExport()}>
+
+
+
+
+ Exporting transactions…
+
+
+ {exportState?.fetched ?? 0} of {exportState?.total ?? 0} records
+ {" "}({percent}%)
+
+
+
+
+
+
+
+
+
+ {exportState?.done && (exportState.cancelled || exportState.error) && (
+
+
+ {exportState.cancelled
+ ? "Export cancelled."
+ : exportState.error}
+
+
+
+ )}
);
}
|