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..13355ec7 --- /dev/null +++ b/frontend/src/app/settings/__tests__/settings-content.test.tsx @@ -0,0 +1,261 @@ +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("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + ...props + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +vi.mock("react-hot-toast", () => ({ + default: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +let mockSession: { + publicKey: string; + network: string; + walletName: string; +} | null = { + publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + network: "TESTNET", + walletName: "Freighter", +}; + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + session: mockSession, + 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; +} + +function getSaveButton(): HTMLButtonElement { + return screen.getByRole("button", { name: /save changes/i }); +} + +function getConnectWalletLink(): HTMLElement { + return screen.getByText(/connect wallet/i); +} + +function getDisconnectButton(): HTMLElement { + return screen.getByText(/disconnect wallet/i); +} + +describe("SettingsContent draft-and-save dirty-state handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSession = { + publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + network: "TESTNET", + walletName: "Freighter", + }; + // Set default saved settings + 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(); + + expect(getSaveButton()).toBeDisabled(); + + // Disconnect without changes — no confirmation needed + act(() => { + fireEvent.click(getDisconnectButton()); + }); + + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/"); + }); + + it("detects dirty state when theme is changed", () => { + render(); + + const lightButton = getThemeButtons().find((b) => b.textContent === "Light"); + expect(lightButton).toBeDefined(); + + act(() => { + fireEvent.click(lightButton!); + }); + + expect(getSaveButton()).toBeEnabled(); + + // Navigate away while dirty — confirm should fire (mock returns false = declined) + act(() => { + fireEvent.click(getDisconnectButton()); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it("accepts navigation when the user confirms leaving with unsaved changes", () => { + vi.mocked(window.confirm).mockReturnValue(true); + + render(); + + const lightButton = getThemeButtons().find((b) => b.textContent === "Light"); + act(() => { + fireEvent.click(lightButton!); + }); + + act(() => { + fireEvent.click(getDisconnectButton()); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/"); + }); + + it("detects dirty state when display currency is changed", () => { + render(); + + const select = getCurrencySelect(); + expect(select).not.toBeNull(); + + act(() => { + fireEvent.change(select!, { target: { value: "XLM" } }); + }); + + expect(getSaveButton()).toBeEnabled(); + + act(() => { + fireEvent.click(getDisconnectButton()); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); + + it("detects dirty state when amount format is changed", () => { + render(); + + const compactButton = screen + .getAllByRole("button") + .find((b) => b.textContent?.includes("Compact")); + expect(compactButton).toBeDefined(); + + act(() => { + fireEvent.click(compactButton!); + }); + + expect(getSaveButton()).toBeEnabled(); + }); + + it("detects dirty state when decimal places is changed", () => { + render(); + + const fourDecimalsBtn = screen + .getAllByRole("button") + .find((b) => b.textContent?.includes("4 decimals")); + expect(fourDecimalsBtn).toBeDefined(); + + act(() => { + fireEvent.click(fourDecimalsBtn!); + }); + + expect(getSaveButton()).toBeEnabled(); + }); + + it("saving changes persists the draft and clears the dirty state", () => { + render(); + + const lightButton = getThemeButtons().find((b) => b.textContent === "Light"); + act(() => { + fireEvent.click(lightButton!); + }); + + act(() => { + fireEvent.click(getSaveButton()); + }); + + expect(localStorage.getItem("flowfi-theme")).toBe("light"); + expect(getSaveButton()).toBeDisabled(); + + // No longer dirty — disconnect proceeds without confirmation + act(() => { + fireEvent.click(getDisconnectButton()); + }); + + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + }); + + it("guards internal link navigation when dirty (not connected)", () => { + mockSession = null; + render(); + + // Pristine — internal link navigation is not blocked + act(() => { + fireEvent.click(getConnectWalletLink()); + }); + expect(window.confirm).not.toHaveBeenCalled(); + + // Make a change, then try to leave via the internal link + const lightButton = getThemeButtons().find((b) => b.textContent === "Light"); + act(() => { + fireEvent.click(lightButton!); + }); + + act(() => { + fireEvent.click(getConnectWalletLink()); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 5483dd9b..5986a628 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, 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"; @@ -9,70 +9,123 @@ import Link from "next/link"; import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; import { getApiBaseUrl } from "@/lib/api/_shared"; +import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; type DisplayCurrency = "USD" | "XLM" | "USDC"; type AmountFormat = "full" | "compact"; type DecimalPlaces = 2 | 4 | 7; +interface Settings { + theme: "light" | "dark" | "system"; + displayCurrency: DisplayCurrency; + amountFormat: AmountFormat; + decimalPlaces: DecimalPlaces; +} + 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`; +const STORAGE_KEYS = { + theme: "flowfi-theme", + displayCurrency: "flowfi-currency", + amountFormat: "flowfi-amount-format", + decimalPlaces: "flowfi-decimal-places", +} as const; + +const DEFAULT_SETTINGS: Settings = { + theme: "dark", + displayCurrency: "USD", + amountFormat: "full", + decimalPlaces: 7, +}; + +function loadSavedSettings(): Settings { + if (typeof window === "undefined") return DEFAULT_SETTINGS; + const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces); + return { + theme: + (localStorage.getItem(STORAGE_KEYS.theme) as Settings["theme"]) || + DEFAULT_SETTINGS.theme, + displayCurrency: + (localStorage.getItem(STORAGE_KEYS.displayCurrency) as DisplayCurrency) || + DEFAULT_SETTINGS.displayCurrency, + amountFormat: + (localStorage.getItem(STORAGE_KEYS.amountFormat) as AmountFormat) || + DEFAULT_SETTINGS.amountFormat, + decimalPlaces: savedDecimals + ? (parseInt(savedDecimals, 10) as DecimalPlaces) + : DEFAULT_SETTINGS.decimalPlaces, + }; +} + +function applyThemeClass(theme: Settings["theme"]): void { + if (typeof window === "undefined") return; + if (theme === "system") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.classList.toggle("dark", prefersDark); + } else { + document.documentElement.classList.toggle("dark", theme === "dark"); + } +} + +function persistSettings(settings: Settings): void { + localStorage.setItem(STORAGE_KEYS.theme, settings.theme); + localStorage.setItem(STORAGE_KEYS.displayCurrency, settings.displayCurrency); + localStorage.setItem(STORAGE_KEYS.amountFormat, settings.amountFormat); + localStorage.setItem(STORAGE_KEYS.decimalPlaces, settings.decimalPlaces.toString()); +} + export default function SettingsContent() { const router = useRouter(); const { session, disconnect, isHydrated } = useWallet(); const [browserPush, setBrowserPush] = useState(false); - const [theme, setTheme] = useState<"light" | "dark" | "system">(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("flowfi-theme") as - | "light" - | "dark" - | "system" - | null; - if (saved) { - document.documentElement.classList.toggle("dark", saved === "dark"); - return saved; - } - } - return "dark"; - }); + const [draft, setDraft] = useState(() => loadSavedSettings()); + const [saved, setSaved] = useState(() => loadSavedSettings()); + const [lastLedger, setLastLedger] = useState("Loading..."); + const [copied, setCopied] = useState(false); - const [displayCurrency, setDisplayCurrency] = useState(() => { - if (typeof window !== "undefined") { - return (localStorage.getItem("flowfi-currency") as DisplayCurrency) || "USD"; - } - return "USD"; - }); + const isDirty = useMemo( + () => + draft.theme !== saved.theme || + draft.displayCurrency !== saved.displayCurrency || + draft.amountFormat !== saved.amountFormat || + draft.decimalPlaces !== saved.decimalPlaces, + [draft, saved] + ); - const [amountFormat, setAmountFormat] = useState(() => { - if (typeof window !== "undefined") { - return (localStorage.getItem("flowfi-amount-format") as AmountFormat) || "full"; - } - return "full"; - }); + // Covers tab close/refresh, internal link navigation, and back/forward + useUnsavedChangesGuard(isDirty); - const [decimalPlaces, setDecimalPlaces] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("flowfi-decimal-places"); - return (saved ? parseInt(saved, 10) : 7) as DecimalPlaces; - } - return 7; - }); + // Apply the saved theme on mount + useEffect(() => { + applyThemeClass(draft.theme); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const [lastLedger, setLastLedger] = useState("Loading..."); + const handleThemeChange = (newTheme: Settings["theme"]) => { + setDraft((prev) => ({ ...prev, theme: newTheme })); + applyThemeClass(newTheme); // live preview; persisted on save + }; - const [copied, setCopied] = useState(false); + const handleSave = () => { + persistSettings(draft); + applyThemeClass(draft.theme); + setSaved({ ...draft }); + toast.success("Settings saved"); + }; - const toggleTheme = (newTheme: "light" | "dark" | "system") => { - setTheme(newTheme); - localStorage.setItem("flowfi-theme", newTheme); - if (newTheme === "system") { - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - document.documentElement.classList.toggle("dark", prefersDark); - } else { - document.documentElement.classList.toggle("dark", newTheme === "dark"); + const handleDisconnect = () => { + 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("/"); }; const copyAddress = async () => { @@ -84,12 +137,6 @@ export default function SettingsContent() { } }; - const handleDisconnect = () => { - disconnect(); - toast.success("Wallet disconnected"); - router.push("/"); - }; - const handleBrowserPushToggle = async () => { if (!browserPush) { try { @@ -195,7 +242,7 @@ export default function SettingsContent() {
- {theme === "dark" ? : theme === "light" ? : } + {draft.theme === "dark" ? : draft.theme === "light" ? : }

@@ -211,9 +258,9 @@ export default function SettingsContent() { {(["light", "dark", "system"] as const).map((t) => (