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
192 changes: 192 additions & 0 deletions frontend/src/app/settings/__tests__/settings-content.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SettingsContent />);

// 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(<SettingsContent />);

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(<SettingsContent />);

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(<SettingsContent />);

// 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(<SettingsContent />);

// 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(<SettingsContent />);

// 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(<SettingsContent />);

// 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);
});
});
83 changes: 73 additions & 10 deletions frontend/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, unknown>, current: Record<string, unknown>): 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();
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -360,9 +420,12 @@ export default function SettingsContent() {
</p>
<div className="flex items-center justify-between bg-black/40 dark:bg-white/40 px-5 py-4 rounded-xl text-white dark:text-black border border-white/10 dark:border-black/10">
<span>Not connected</span>
<Link href="/" className="text-accent hover:opacity-80 transition font-semibold">
<button
onClick={() => navigateWithConfirm("/")}
className="text-accent hover:opacity-80 transition font-semibold bg-transparent border-none cursor-pointer"
>
Connect Wallet
</Link>
</button>
</div>
</div>
)}
Expand Down Expand Up @@ -422,7 +485,7 @@ export default function SettingsContent() {
{/* Disconnect */}
{session && (
<button
onClick={handleDisconnect}
onClick={handleDisconnectWithConfirm}
className="w-full flex items-center justify-center gap-2 bg-red-600/90 hover:bg-red-600 transition px-4 py-3 rounded-xl text-white font-medium shadow-lg hover:shadow-red-500/30"
>
<LogOut size={18} />
Expand Down
Loading