Skip to content
Open
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
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import React from "react";

/** Why a list is empty: no data exists yet, or active filters matched nothing. */
export type EmptyStateReason = "no-data" | "no-results";

type EmptyStateProps = {
message: string;
/** Optional icon rendered above the message */
icon?: React.ReactNode;
/** Defaults to "no-data" (genuinely empty list). */
reason?: EmptyStateReason;
/** Shown as a "Clear filters" action when reason is "no-results". */
Expand All @@ -14,9 +18,11 @@ export function EmptyState({
message,
reason = "no-data",
onClearFilters,
icon,
}: EmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-zinc-800 px-4 py-8 text-center text-sm text-zinc-500">
{icon && <div className="mb-3 flex justify-center">{icon}</div>}
<p>{message}</p>
{reason === "no-results" && onClearFilters ? (
<button
Expand Down
33 changes: 19 additions & 14 deletions src/components/SettlementDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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();
});

});
6 changes: 6 additions & 0 deletions src/components/SettlementDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>, 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);
}
}

Expand Down Expand Up @@ -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
</button>
<button
onClick={() => setConfirmCancelOpen(true)}
disabled={pending}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm text-red-400 hover:text-red-300"
>
Cancel
Expand Down
33 changes: 33 additions & 0 deletions src/components/ToastProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,39 @@ describe("ToastProvider", () => {
expect(toast).not.toBeInTheDocument();
});

it("dismisses a toast with Escape key when focused", () => {
render(
<ToastProvider>
<Trigger message="Escape me" />
</ToastProvider>,
);
const toast = screen.getByText("Escape me");
const region = screen.getByRole("status");

// Focus the toast region
fireEvent.focus(region);
// Press Escape
fireEvent.keyDown(region, { key: "Escape" });
expect(screen.queryByText("Escape me")).not.toBeInTheDocument();
});

it("Escape dismisses only the focused toast in a stack", () => {
render(
<ToastProvider>
<Trigger message="First toast" />
<Trigger message="Second toast" />
</ToastProvider>,
);
const firstToast = screen.getByText("First toast");
const secondToast = screen.getByText("Second toast");
const regions = screen.getAllByRole("status");
// Focus the first toast region
fireEvent.focus(regions[0]);
fireEvent.keyDown(regions[0], { key: "Escape" });
expect(screen.queryByText("First toast")).not.toBeInTheDocument();
expect(screen.getByText("Second toast")).toBeInTheDocument();
});

it("treats a second pause while already paused as a no-op (no double counting)", () => {
render(
<ToastProvider>
Expand Down
2 changes: 2 additions & 0 deletions src/components/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,12 @@ function ToastItem({
<div
role="status"
aria-live="polite"
tabIndex={0}
onMouseEnter={pauseTimer}
onMouseLeave={startTimer}
onFocus={pauseTimer}
onBlur={startTimer}
onKeyDown={(e) => { if (e.key === 'Escape') onDismiss(toast.id); }}
className={`pointer-events-auto w-full max-w-sm rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur ${styles}`}
>
<div className="flex items-start justify-between gap-3">
Expand Down
6 changes: 3 additions & 3 deletions src/components/WalletProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("WalletProvider", () => {
new StorageEvent("storage", {
key: "anchornet:wallet",
newValue: JSON.stringify({ address }),
storageArea: localStorage,

}),
);

Expand Down Expand Up @@ -147,7 +147,7 @@ describe("WalletProvider", () => {
new StorageEvent("storage", {
key: "anchornet:wallet",
newValue: null,
storageArea: localStorage,

}),
);

Expand Down Expand Up @@ -176,7 +176,7 @@ describe("WalletProvider", () => {
new StorageEvent("storage", {
key: "some-other-key",
newValue: "irrelevant",
storageArea: localStorage,

}),
);

Expand Down
6 changes: 3 additions & 3 deletions src/lib/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ const STELLAR_ADDRESS_PATTERN = /^G[A-Z0-9]{55}$/;

/** Persists the connected wallet account so it survives a page refresh. */
export function saveAccount(account: WalletAccount): void {
if (typeof window === "undefined") return;
if (typeof window === "undefined" || !window.localStorage) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(account));
}

/** Reads a previously persisted wallet account, if any and well-formed. */
export function loadAccount(): WalletAccount | null {
if (typeof window === "undefined") return null;
if (typeof window === "undefined" || !window.localStorage) return null;
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
Expand All @@ -42,7 +42,7 @@ export function loadAccount(): WalletAccount | null {

/** Clears any persisted wallet account and its session seed. */
export function clearAccount(): void {
if (typeof window === "undefined") return;
if (typeof window === "undefined" || !window.localStorage) return;
window.localStorage.removeItem(STORAGE_KEY);
window.localStorage.removeItem(SEED_STORAGE_KEY);
}
Expand Down
29 changes: 29 additions & 0 deletions vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,32 @@ import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

// Ensure a simple in‑memory localStorage implementation exists in the jsdom
// environment where `window.localStorage` may be undefined. This mimics the
// browser API sufficiently for the wallet utilities and related tests.
if (typeof window !== "undefined" && !window.localStorage) {
const store = new Map<string, string>();
// @ts-ignore – extending the global window object for test purposes
window.localStorage = {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
get length() {
return store.size;
},
} as Storage;
}