diff --git a/frontend/src/components/Dashboard/NetworkSelector.css b/frontend/src/components/Dashboard/NetworkSelector.css index 7706587..3163bb0 100644 --- a/frontend/src/components/Dashboard/NetworkSelector.css +++ b/frontend/src/components/Dashboard/NetworkSelector.css @@ -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; +} diff --git a/frontend/src/components/Dashboard/NetworkSelector.test.tsx b/frontend/src/components/Dashboard/NetworkSelector.test.tsx new file mode 100644 index 0000000..4f2b457 --- /dev/null +++ b/frontend/src/components/Dashboard/NetworkSelector.test.tsx @@ -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(); + const select = screen.getByRole("combobox", { name: /select network/i }); + expect(select).toHaveValue("testnet"); + }); + + it("switches network immediately when no dirty forms are registered", () => { + render(); + 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(); + 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(); + + 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(); + + 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(); + expect(screen.getByRole("alert")).toHaveTextContent(/mainnet/i); + }); + + it("does not show the mainnet warning when on testnet", () => { + currentNetwork = "testnet"; + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Dashboard/NetworkSelector.tsx b/frontend/src/components/Dashboard/NetworkSelector.tsx index 236578c..79f830d 100644 --- a/frontend/src/components/Dashboard/NetworkSelector.tsx +++ b/frontend/src/components/Dashboard/NetworkSelector.tsx @@ -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; + } +} + +/** 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) => { + 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 (
+ {isMainnet && (
⚠️ You are connected to Mainnet. Transactions are irreversible and use real funds.
)} + + {pendingNetwork !== null && ( +
+
+

+ Unsaved changes +

+

+ You have unsaved form data. Switching to{" "} + {pendingNetwork} will discard those changes. Do + you want to continue? +

+
+ + +
+
+
+ )}
); }