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
67 changes: 67 additions & 0 deletions frontend/src/components/Dashboard/NetworkSelector.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,70 @@
font-size: 0.8rem;
line-height: 1.4;
}

/* ---- Confirmation dialog ---- */
.network-selector__confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}

.network-selector__confirm-dialog {
background: var(--color-surface, #fff);
border-radius: var(--radius, 8px);
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
}

.network-selector__confirm-title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}

.network-selector__confirm-body {
margin: 0 0 20px;
font-size: 0.9rem;
color: var(--color-text-muted, var(--color-text));
line-height: 1.5;
}

.network-selector__confirm-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}

.network-selector__btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius, 6px);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}

.network-selector__btn--danger {
background: var(--color-danger, #e53e3e);
color: #fff;
}

.network-selector__btn--danger:hover {
opacity: 0.9;
}

.network-selector__btn--secondary {
background: var(--color-input-border, #ccc);
color: var(--color-text);
}

.network-selector__btn--secondary:hover {
opacity: 0.85;
}
131 changes: 131 additions & 0 deletions frontend/src/components/Dashboard/NetworkSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Tests for NetworkSelector confirmation dialog (Issue #1).
*
* Verifies that:
* - switching networks without dirty form state switches immediately.
* - switching networks with dirty form state shows a confirmation prompt.
* - confirming the switch actually changes the network.
* - cancelling the switch leaves the network unchanged.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import "@testing-library/jest-dom";
import { render, screen, fireEvent } from "@testing-library/react";
import NetworkSelector from "./NetworkSelector";

// ---- Mock useNetwork -------------------------------------------------------
const mockSetNetwork = vi.fn();
let currentNetwork: "testnet" | "mainnet" = "testnet";

vi.mock("../../hooks/useNetwork", () => ({
useNetwork: () => ({
network: currentNetwork,
setNetwork: mockSetNetwork,
isMainnet: currentNetwork === "mainnet",
rpcUrl: "https://soroban-testnet.stellar.org",
hasNetworkMismatch: false,
walletPassphrase: null,
walletNetwork: null,
isCheckingWallet: false,
}),
}));

// ---- Helpers ---------------------------------------------------------------

function registerDirtyForm(key = "test-form") {
if (!window.__networkSelectorDirtyForms) {
window.__networkSelectorDirtyForms = new Set();
}
window.__networkSelectorDirtyForms.add(key);
}

function clearDirtyForms() {
window.__networkSelectorDirtyForms?.clear();
}

// ---- Tests -----------------------------------------------------------------

describe("NetworkSelector", () => {
beforeEach(() => {
currentNetwork = "testnet";
mockSetNetwork.mockClear();
clearDirtyForms();
});

afterEach(() => {
clearDirtyForms();
});

it("renders the network selector with the current network selected", () => {
render(<NetworkSelector />);
const select = screen.getByRole("combobox", { name: /select network/i });
expect(select).toHaveValue("testnet");
});

it("switches network immediately when no dirty forms are registered", () => {
render(<NetworkSelector />);
const select = screen.getByRole("combobox", { name: /select network/i });
fireEvent.change(select, { target: { value: "mainnet" } });
// Dialog should NOT appear
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// Network should switch directly
expect(mockSetNetwork).toHaveBeenCalledWith("mainnet");
});

it("shows the confirmation dialog when switching with dirty form state", () => {
registerDirtyForm("settlement-proposal");
render(<NetworkSelector />);
const select = screen.getByRole("combobox", { name: /select network/i });
fireEvent.change(select, { target: { value: "mainnet" } });

// Dialog must be visible
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument();
// The dialog body mentions the pending network name
expect(screen.getByText(/switching to/i)).toBeInTheDocument();

// Network must NOT have switched yet
expect(mockSetNetwork).not.toHaveBeenCalled();
});

it("confirms the switch when the user clicks 'Switch network'", () => {
registerDirtyForm("settlement-proposal");
render(<NetworkSelector />);

fireEvent.change(screen.getByRole("combobox"), {
target: { value: "mainnet" },
});

// Confirm
fireEvent.click(screen.getByTestId("ns-confirm-switch"));

expect(mockSetNetwork).toHaveBeenCalledWith("mainnet");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("cancels the switch when the user clicks 'Keep editing'", () => {
registerDirtyForm("settlement-proposal");
render(<NetworkSelector />);

fireEvent.change(screen.getByRole("combobox"), {
target: { value: "mainnet" },
});

// Cancel
fireEvent.click(screen.getByTestId("ns-cancel-switch"));

expect(mockSetNetwork).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("shows the mainnet warning when on mainnet", () => {
currentNetwork = "mainnet";
render(<NetworkSelector />);
expect(screen.getByRole("alert")).toHaveTextContent(/mainnet/i);
});

it("does not show the mainnet warning when on testnet", () => {
currentNetwork = "testnet";
render(<NetworkSelector />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
91 changes: 90 additions & 1 deletion frontend/src/components/Dashboard/NetworkSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,115 @@
import { useState, useCallback } from "react";
import { useNetwork } from "../../hooks/useNetwork";
import "./NetworkSelector.css";

/**
* Context key used by any form in the dashboard to register itself as "dirty".
* Forms call `window.__networkSelectorDirtyForms.add(key)` on change and
* `window.__networkSelectorDirtyForms.delete(key)` on submit/reset.
*
* NetworkSelector reads this set before switching networks so it can prompt
* the user when unsaved state would be lost.
*/
declare global {
interface Window {
__networkSelectorDirtyForms?: Set<string>;
}
}

/** Returns true when at least one form has registered unsaved state. */
function hasDirtyForms(): boolean {
return (window.__networkSelectorDirtyForms?.size ?? 0) > 0;
}

export default function NetworkSelector() {
const { network, setNetwork, isMainnet } = useNetwork();
const [pendingNetwork, setPendingNetwork] = useState<
"testnet" | "mainnet" | null
>(null);

const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const next = e.target.value as "testnet" | "mainnet";
if (next === network) return;

if (hasDirtyForms()) {
// Park the requested switch and ask for confirmation.
setPendingNetwork(next);
} else {
setNetwork(next);
}
},
[network, setNetwork],
);

const confirmSwitch = useCallback(() => {
if (pendingNetwork) {
setNetwork(pendingNetwork);
}
setPendingNetwork(null);
}, [pendingNetwork, setNetwork]);

const cancelSwitch = useCallback(() => {
setPendingNetwork(null);
}, []);

return (
<div className="network-selector">
<select
className="network-selector__select"
value={network}
onChange={(e) => setNetwork(e.target.value as "testnet" | "mainnet")}
onChange={handleChange}
aria-label="Select network"
>
<option value="testnet">Testnet</option>
<option value="mainnet">Mainnet</option>
</select>

{isMainnet && (
<div className="network-selector__warning" role="alert">
⚠️ You are connected to <strong>Mainnet</strong>. Transactions are
irreversible and use real funds.
</div>
)}

{pendingNetwork !== null && (
<div
className="network-selector__confirm-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="ns-confirm-title"
aria-describedby="ns-confirm-desc"
>
<div className="network-selector__confirm-dialog">
<h3 id="ns-confirm-title" className="network-selector__confirm-title">
Unsaved changes
</h3>
<p id="ns-confirm-desc" className="network-selector__confirm-body">
You have unsaved form data. Switching to{" "}
<strong>{pendingNetwork}</strong> will discard those changes. Do
you want to continue?
</p>
<div className="network-selector__confirm-actions">
<button
type="button"
className="network-selector__btn network-selector__btn--danger"
onClick={confirmSwitch}
data-testid="ns-confirm-switch"
>
Switch network
</button>
<button
type="button"
className="network-selector__btn network-selector__btn--secondary"
onClick={cancelSwitch}
data-testid="ns-cancel-switch"
>
Keep editing
</button>
</div>
</div>
</div>
)}
</div>
);
}