From 78ce26d4eac4b0716a5e8f9dc972ccd8451a531c Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Wed, 2 Sep 2026 12:21:50 +0100 Subject: [PATCH] fix(provider,dashboard): combine error states in SorokitProvider and conditionally render Dashboard screens (#583, #585) --- src/context/SorokitProvider.test.tsx | 92 +++++++++++++++++++++++++--- src/context/SorokitProvider.tsx | 14 ++++- src/screens/Dashboard.test.tsx | 71 +++++++++++++++++++++ src/screens/Dashboard.tsx | 18 ++---- 4 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 src/screens/Dashboard.test.tsx diff --git a/src/context/SorokitProvider.test.tsx b/src/context/SorokitProvider.test.tsx index ba3b879..e1e95a0 100644 --- a/src/context/SorokitProvider.test.tsx +++ b/src/context/SorokitProvider.test.tsx @@ -5,13 +5,14 @@ import { getClient } from "@/lib/client"; import { renderWithProvider } from "@/__tests__/utils"; const TestComponent = () => { - const { address, account, balances, connectWallet, disconnectWallet, switchNetwork } = useSorokit(); + const { address, account, balances, error, connectWallet, disconnectWallet, switchNetwork } = useSorokit(); return (
{address || "none"}
{account ? account.sequence : "none"}
{balances.length}
+
{error || "none"}
@@ -39,10 +40,12 @@ describe("SorokitProvider", () => { } as unknown as ReturnType; }); - it("disconnectWallet clears address, account, and balances", async () => { + it("disconnectWallet clears address, account, balances, and error", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account error" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances error" }); + renderWithProvider(, { client: mockClient }); - // Initial load will hit getNetwork const connectBtn = screen.getByText("Connect"); const disconnectBtn = screen.getByText("Disconnect"); @@ -50,11 +53,8 @@ describe("SorokitProvider", () => { fireEvent.click(connectBtn); }); - expect(screen.getByTestId("address")).toHaveTextContent("GABC"); - await waitFor(() => { - expect(screen.getByTestId("account")).toHaveTextContent("100"); - expect(screen.getByTestId("balances")).toHaveTextContent("1"); + expect(screen.getByTestId("error")).toHaveTextContent("Account error; Balances error"); }); await act(async () => { @@ -64,6 +64,84 @@ describe("SorokitProvider", () => { expect(screen.getByTestId("address")).toHaveTextContent("none"); expect(screen.getByTestId("account")).toHaveTextContent("none"); expect(screen.getByTestId("balances")).toHaveTextContent("0"); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + }); + + it("combines error strings when both getAccount and getBalances fail", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account failed" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances failed" }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Account failed; Balances failed"); + }); + }); + + it("shows single error when only getAccount fails", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account not found" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "10" }], error: null }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Account not found"); + expect(screen.getByTestId("balances")).toHaveTextContent("1"); + }); + }); + + it("shows single error when only getBalances fails", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "100" }, error: null }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Failed to fetch balances" }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Failed to fetch balances"); + expect(screen.getByTestId("account")).toHaveTextContent("100"); + }); + }); + + it("clears error from previous session on reconnect", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValueOnce({ data: null, error: "Old error" }); + mockClient.account.getBalances = vi.fn().mockResolvedValueOnce({ data: [], error: null }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Old error"); + }); + + // Next connect with different address succeeds + mockClient.wallet.connect = vi.fn().mockResolvedValue({ data: { address: "GDEF" }, error: null }); + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "200" }, error: null }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "50" }], error: null }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("address")).toHaveTextContent("GDEF"); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + expect(screen.getByTestId("account")).toHaveTextContent("200"); + }); }); it("connectWallet populates address on success", async () => { diff --git a/src/context/SorokitProvider.tsx b/src/context/SorokitProvider.tsx index 5c0f18b..1366e25 100644 --- a/src/context/SorokitProvider.tsx +++ b/src/context/SorokitProvider.tsx @@ -34,7 +34,12 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) { // Load account when address changes useEffect(() => { - if (!address) return; + setError(null); + if (!address) { + setAccount(null); + setBalances([]); + return; + } let active = true; const timerId = window.setTimeout(() => { @@ -46,9 +51,11 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) { .then(([accountRes, balancesRes]) => { if (!active) return; if (accountRes.data) setAccount(accountRes.data); - if (accountRes.error) setError(accountRes.error); if (balancesRes.data) setBalances(balancesRes.data); - if (balancesRes.error) setError(balancesRes.error); + const combined = [accountRes.error, balancesRes.error] + .filter(Boolean) + .join("; "); + if (combined) setError(combined); }) .finally(() => { if (active) setIsLoadingAccount(false); @@ -81,6 +88,7 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) { setAddress(null); setAccount(null); setBalances([]); + setError(null); }, [client]); const switchNetwork = useCallback( diff --git a/src/screens/Dashboard.test.tsx b/src/screens/Dashboard.test.tsx new file mode 100644 index 0000000..27c1c5f --- /dev/null +++ b/src/screens/Dashboard.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { Dashboard } from "./Dashboard"; + +vi.mock("@/context/useSorokit", () => ({ + useSorokit: vi.fn(() => ({ + isConnected: true, + address: "GABC", + account: null, + balances: [], + network: { name: "testnet" }, + error: null, + clearError: vi.fn(), + })), +})); + +vi.mock("@/screens/WalletScreen", () => ({ + WalletScreen: () =>
WalletScreen
, +})); + +vi.mock("@/screens/AccountScreen", () => ({ + AccountScreen: () =>
AccountScreen
, +})); + +vi.mock("@/screens/TransactionsScreen", () => ({ + TransactionsScreen: () =>
TransactionsScreen
, +})); + +vi.mock("@/screens/SorobanScreen", () => ({ + SorobanScreen: () =>
SorobanScreen
, +})); + +vi.mock("@/screens/NetworkScreen", () => ({ + NetworkScreen: () =>
NetworkScreen
, +})); + +describe("Dashboard screen mounting", () => { + it("mounts only the default active screen (wallet) on load", () => { + render(); + + expect(screen.getByTestId("wallet-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("network-screen")).not.toBeInTheDocument(); + }); + + it("unmounts previous screen and mounts only the new active screen when navigating", () => { + render(); + + // Click Account in sidebar + fireEvent.click(screen.getByRole("button", { name: /account/i })); + expect(screen.getByTestId("account-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("wallet-screen")).not.toBeInTheDocument(); + + // Click Transactions + fireEvent.click(screen.getByRole("button", { name: /transactions/i })); + expect(screen.getByTestId("transactions-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument(); + + // Click Soroban + fireEvent.click(screen.getByRole("button", { name: /soroban/i })); + expect(screen.getByTestId("soroban-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument(); + + // Click Network + fireEvent.click(screen.getByRole("button", { name: /network/i })); + expect(screen.getByTestId("network-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument(); + }); +}); diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx index 401c564..38ad86d 100644 --- a/src/screens/Dashboard.tsx +++ b/src/screens/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, type ComponentType } from "react"; +import { useState } from "react"; import { Sidebar, type NavSection } from "@/components/Sidebar"; import { TopBar } from "@/components/TopBar"; import { NetworkBanner } from "@/components/NetworkBanner"; @@ -8,20 +8,10 @@ import { TransactionsScreen } from "@/screens/TransactionsScreen"; import { SorobanScreen } from "@/screens/SorobanScreen"; import { NetworkScreen } from "@/screens/NetworkScreen"; -const SCREENS: Record = { - wallet: WalletScreen, - account: AccountScreen, - transactions: TransactionsScreen, - soroban: SorobanScreen, - network: NetworkScreen, -}; - export function Dashboard() { const [active, setActive] = useState("wallet"); const [sidebarOpen, setSidebarOpen] = useState(false); - const ActiveScreen = SCREENS[active]; - return (
- + {active === "wallet" && } + {active === "account" && } + {active === "transactions" && } + {active === "soroban" && } + {active === "network" && }