diff --git a/frontend/src/app/settings/__tests__/settings-content.test.tsx b/frontend/src/app/settings/__tests__/settings-content.test.tsx new file mode 100644 index 00000000..e32d6244 --- /dev/null +++ b/frontend/src/app/settings/__tests__/settings-content.test.tsx @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import React from "react"; + +// ─── Mocks ────────────────────────────────────────────────────────────── + +const push = vi.fn(); +const disconnect = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), +})); + +vi.mock("react-hot-toast", () => ({ + default: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + session: { + publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + network: "TESTNET", + walletName: "Freighter", + }, + disconnect, + isHydrated: true, + }), +})); + +vi.mock("@/lib/wallet", () => ({ + shortenPublicKey: (key: string) => `${key.slice(0, 4)}...${key.slice(-4)}`, + formatNetwork: (n: string) => n, + STELLAR_NETWORK: "TESTNET", +})); + +vi.mock("@/lib/api/_shared", () => ({ + getApiBaseUrl: () => "http://localhost:4000", +})); + +import SettingsContent from "../settings-content"; + +function getThemeButtons(): HTMLElement[] { + const buttons = screen.getAllByRole("button"); + return buttons.filter( + (b) => b.textContent === "Light" || b.textContent === "Dark" || b.textContent === "System" + ); +} + +function getCurrencySelect(): HTMLSelectElement | null { + return screen.queryByLabelText(/default token/i) as HTMLSelectElement | null; +} + +describe("SettingsContent dirty-state detection", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Set default localStorage values + localStorage.clear(); + localStorage.setItem("flowfi-theme", "dark"); + localStorage.setItem("flowfi-currency", "USD"); + localStorage.setItem("flowfi-amount-format", "full"); + localStorage.setItem("flowfi-decimal-places", "7"); + vi.spyOn(window, "confirm").mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts clean (not dirty) on initial render", () => { + render(); + + // Navigate away without changes — should proceed without confirmation + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(push).toHaveBeenCalledWith("/"); + }); + + it("detects dirty state when theme is changed", () => { + render(); + + const themeButtons = getThemeButtons(); + const lightButton = themeButtons.find((b) => b.textContent === "Light"); + expect(lightButton).toBeDefined(); + + // Change theme to Light (different from initial "dark") + act(() => { + fireEvent.click(lightButton!); + }); + + // Try to navigate away — confirm should fire (mock returns false = declined) + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); // Declined navigation + }); + + it("detects dirty state when display currency is changed", () => { + // Accept the confirm dialog + vi.mocked(window.confirm).mockReturnValue(true); + + render(); + + const select = getCurrencySelect(); + expect(select).not.toBeNull(); + + // Change currency value + act(() => { + fireEvent.change(select!, { target: { value: "XLM" } }); + }); + + // Try to navigate away — confirm should fire + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(push).toHaveBeenCalled(); // Accepted navigation + }); + + it("detects dirty state when amount format is changed", () => { + render(); + + // Find the amount format buttons + const allButtons = screen.getAllByRole("button"); + const compactButton = allButtons.find((b) => b.textContent?.includes("Compact")); + expect(compactButton).toBeDefined(); + + act(() => { + fireEvent.click(compactButton!); + }); + + // Navigate with the disconnect button + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); + + it("detects dirty state when decimal places is changed", () => { + render(); + + // Find the decimal places buttons + const allButtons = screen.getAllByRole("button"); + const fourDecimalsBtn = allButtons.find((b) => b.textContent?.includes("4 decimals")); + expect(fourDecimalsBtn).toBeDefined(); + + act(() => { + fireEvent.click(fourDecimalsBtn!); + }); + + // Navigate with the disconnect button + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); + + it("does not show confirm when no changes were made", () => { + render(); + + // Navigate away with disconnect — no changes made + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + // Should NOT show confirm + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + }); + + it("restores dirty state to clean after disconnect", () => { + render(); + + // No changes were made, so disconnect should work without confirm + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 5483dd9b..7d62e751 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -1,11 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react"; import { STELLAR_NETWORK, shortenPublicKey } from "@/lib/wallet"; import { useWallet } from "@/context/wallet-context"; import { useRouter } from "next/navigation"; -import Link from "next/link"; import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; import { getApiBaseUrl } from "@/lib/api/_shared"; @@ -18,6 +17,12 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; const INDEXER_URL = `${getApiBaseUrl()}/v1`; +function useDirtyTracker(initial: Record, current: Record): boolean { + return useMemo(() => { + return Object.keys(initial).some((key) => initial[key] !== current[key]); + }, [initial, current]); +} + export default function SettingsContent() { const router = useRouter(); const { session, disconnect, isHydrated } = useWallet(); @@ -64,6 +69,51 @@ export default function SettingsContent() { const [copied, setCopied] = useState(false); + // ── Track initial values for dirty detection ────────────────────────── + const initialValuesRef = useRef({ + browserPush: false, + theme: theme as string, + displayCurrency: displayCurrency as string, + amountFormat: amountFormat as string, + decimalPlaces: decimalPlaces as number, + }); + + const currentValues = { + browserPush, + theme, + displayCurrency, + amountFormat, + decimalPlaces, + }; + + const isDirty = useDirtyTracker(initialValuesRef.current, currentValues); + + // ── beforeunload handler for tab close / refresh / back ─────────────── + useEffect(() => { + if (!isDirty) return; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + // ── Confirm before internal navigation ──────────────────────────────── + const navigateWithConfirm = useCallback( + (path: string) => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Are you sure you want to leave?" + ); + if (!confirmed) return; + } + router.push(path); + },[isDirty, router] + ); + const toggleTheme = (newTheme: "light" | "dark" | "system") => { setTheme(newTheme); localStorage.setItem("flowfi-theme", newTheme); @@ -75,6 +125,19 @@ export default function SettingsContent() { } }; + // ── Confirm-aware disconnect ────────────────────────────────────────── + const handleDisconnectWithConfirm = useCallback(() => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Are you sure you want to disconnect?" + ); + if (!confirmed) return; + } + disconnect(); + toast.success("Wallet disconnected"); + router.push("/"); + }, [isDirty, disconnect, router]); + const copyAddress = async () => { if (session?.publicKey) { await navigator.clipboard.writeText(session.publicKey); @@ -84,13 +147,10 @@ export default function SettingsContent() { } }; - const handleDisconnect = () => { - disconnect(); - toast.success("Wallet disconnected"); - router.push("/"); - }; + const handleBrowserPushToggle = async () => { + // toggling notifications is tracked by isDirty via browserPush state if (!browserPush) { try { await Notification.requestPermission(); @@ -360,9 +420,12 @@ export default function SettingsContent() {

Not connected - +
)} @@ -422,7 +485,7 @@ export default function SettingsContent() { {/* Disconnect */} {session && (