Skip to content
Merged
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
64 changes: 64 additions & 0 deletions src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,70 @@ describe("ErrorBoundary", () => {
expect(screen.getByText("Mounted successfully")).toBeInTheDocument();
});

it("calls onRetry when the user clicks try again", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const onRetry = vi.fn();

render(
<ErrorBoundary onRetry={onRetry}>
<ThrowError />
</ErrorBoundary>
);

expect(onRetry).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: /try again/i }));

expect(onRetry).toHaveBeenCalledTimes(1);
});

it("calls onRetry from a custom fallback's reset callback", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const onRetry = vi.fn();

render(
<ErrorBoundary
onRetry={onRetry}
fallback={(_error, reset) => (
<button onClick={reset}>Reset Custom</button>
)}
>
<ThrowError />
</ErrorBoundary>
);

fireEvent.click(screen.getByText("Reset Custom"));

expect(onRetry).toHaveBeenCalledTimes(1);
});

it("runs onRetry before children re-mount so the retry uses fresh state", () => {
vi.spyOn(console, "error").mockImplementation(() => {});

let clientReady = false;
const reinitialise = vi.fn(() => {
clientReady = true;
});

const NeedsClient = () => {
if (!clientReady) throw new Error("Client not initialized");
return <div data-testid="ready">Client ready</div>;
};

render(
<ErrorBoundary onRetry={reinitialise}>
<NeedsClient />
</ErrorBoundary>
);

expect(screen.getByText("Something went wrong")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /try again/i }));

expect(reinitialise).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("ready")).toBeInTheDocument();
});

it("applies scoped container styling when isolate is true", () => {
vi.spyOn(console, "error").mockImplementation(() => {});

Expand Down
14 changes: 13 additions & 1 deletion src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ interface Props {
fallback?: (error: Error, reset: () => void) => ReactNode;
/** Called when the boundary catches an error. */
onError?: (error: Error, info: ErrorInfo) => void;
/**
* Called when the user retries, before the boundary clears its error state.
* Use it to re-attempt the work that failed — e.g. re-initialising the
* Sorokit client after `createSorokitClient()` threw — so the retried render
* has a working dependency instead of throwing again immediately.
*/
onRetry?: () => void;
/** Render fallback content as an in-page scoped panel instead of a full-page state. */
isolate?: boolean;
/** Optional support URL shown in the default fallback. */
Expand Down Expand Up @@ -42,11 +49,16 @@ export class ErrorBoundary extends Component<Props, State> {
}
}

reset = () =>
reset = () => {
// Runs before the state clears so a re-initialisation attempt is already
// done by the time children re-mount under the new resetKey.
this.props.onRetry?.();
this.setState((state) => ({
error: null,
resetKey: state.resetKey + 1,
componentStack: null,
}));
};

render() {
const { error, resetKey, componentStack } = this.state;
Expand Down
23 changes: 20 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
import './index.css'

import React from 'react'
import React, { useCallback,useState } from 'react'
import ReactDOM from 'react-dom/client'

import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary'
import type { SorokitClient } from './lib/client.ts'
import { createMockClient } from './lib/mock-client'

// Initialize mock client for development
const client = createMockClient() as SorokitClient
const createClient = (): SorokitClient => createMockClient() as SorokitClient

function Root() {
const [client, setClient] = useState<SorokitClient>(createClient)

// Retrying re-attempts client creation, so a boundary tripped by a failed
// initialisation comes back with a fresh client instead of the broken one.
const handleRetry = useCallback(() => {
setClient(createClient())
}, [])

return (
<ErrorBoundary onRetry={handleRetry}>
<App client={client} />
</ErrorBoundary>
)
}

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App client={client} />
<Root />
</React.StrictMode>,
)
155 changes: 155 additions & 0 deletions src/screens/Dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import type { NavSection } from "@/components/Sidebar";

import { Dashboard } from "./Dashboard";

// Dashboard composes every screen; stub the chrome and screens so these tests
// cover only Dashboard's own controlled/uncontrolled section logic.
vi.mock("@/components/Sidebar", () => ({
Sidebar: ({
active,
onNavigate,
}: {
active: NavSection;
onNavigate: (s: NavSection) => void;
}) => (
<nav aria-label="Main navigation">
<span data-testid="sidebar-active">{active}</span>
{(["wallet", "transactions", "soroban", "network"] as NavSection[]).map(
(section) => (
<button key={section} onClick={() => onNavigate(section)}>
{section}
</button>
),
)}
</nav>
),
}));
vi.mock("@/components/TopBar", () => ({
TopBar: ({ active }: { active: NavSection }) => (
<div data-testid="topbar-active">{active}</div>
),
}));
vi.mock("@/components/NetworkBanner", () => ({
NetworkBanner: () => null,
}));

function stubScreen(name: string) {
return () => <div data-testid={`screen-${name}`}>{name} screen</div>;
}

vi.mock("@/screens/WalletScreen", () => ({
WalletScreen: stubScreen("wallet"),
}));
vi.mock("@/screens/AccountScreen", () => ({
AccountScreen: stubScreen("account"),
}));
vi.mock("@/screens/TransactionsScreen", () => ({
TransactionsScreen: stubScreen("transactions"),
}));
vi.mock("@/screens/SorobanScreen", () => ({
SorobanScreen: stubScreen("soroban"),
}));
vi.mock("@/screens/NetworkScreen", () => ({
NetworkScreen: stubScreen("network"),
}));
vi.mock("@/screens/RecoveryScreen", () => ({
RecoveryScreen: stubScreen("recovery"),
}));
vi.mock("@/screens/ChartingScreen", () => ({
ChartingScreen: stubScreen("charts"),
}));
vi.mock("@/screens/YieldFarmingScreen", () => ({
YieldFarmingScreen: stubScreen("farming"),
}));
vi.mock("@/screens/BudgetScreen", () => ({
BudgetScreen: stubScreen("budget"),
}));
vi.mock("@/screens/NFTScreen", () => ({
NFTScreen: stubScreen("nfts"),
}));

describe("Dashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});

describe("uncontrolled mode", () => {
it("starts on the Wallet screen by default", () => {
render(<Dashboard />);
expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();
});

it("initialises to defaultSection when provided", () => {
render(<Dashboard defaultSection="soroban" />);
expect(screen.getByTestId("screen-soroban")).toBeInTheDocument();
expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
});

it("changes the rendered screen when a nav item is clicked", () => {
render(<Dashboard />);
fireEvent.click(screen.getByRole("button", { name: "transactions" }));

expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
});

it("still reports navigation through onSectionChange", () => {
const onSectionChange = vi.fn();
render(<Dashboard onSectionChange={onSectionChange} />);

fireEvent.click(screen.getByRole("button", { name: "network" }));

expect(onSectionChange).toHaveBeenCalledWith("network");
expect(screen.getByTestId("screen-network")).toBeInTheDocument();
});
});

describe("controlled mode", () => {
it("renders the screen named by activeSection", () => {
render(<Dashboard activeSection="transactions" />);
expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
});

it("fires onSectionChange but does not change the screen itself", () => {
const onSectionChange = vi.fn();
render(
<Dashboard
activeSection="transactions"
onSectionChange={onSectionChange}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "soroban" }));

expect(onSectionChange).toHaveBeenCalledWith("soroban");
// The parent owns the state, so the view is unchanged until it updates.
expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
expect(screen.queryByTestId("screen-soroban")).not.toBeInTheDocument();
});

it("follows the parent when activeSection changes", () => {
const { rerender } = render(<Dashboard activeSection="wallet" />);
expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();

rerender(<Dashboard activeSection="network" />);
expect(screen.getByTestId("screen-network")).toBeInTheDocument();
expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
});

it("ignores defaultSection when activeSection is set", () => {
render(<Dashboard activeSection="wallet" defaultSection="soroban" />);
expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();
});

it("passes the active section down to the chrome", () => {
render(<Dashboard activeSection="soroban" />);
expect(screen.getByTestId("sidebar-active")).toHaveTextContent("soroban");
expect(screen.getByTestId("topbar-active")).toHaveTextContent("soroban");
});
});
});
48 changes: 39 additions & 9 deletions src/screens/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { type ComponentType,useState } from "react";
import { type ComponentType,useCallback,useState } from "react";

import { NetworkBanner } from "@/components/NetworkBanner";
import { type NavSection,Sidebar } from "@/components/Sidebar";
import { TopBar } from "@/components/TopBar";
import { AccountScreen } from "@/screens/AccountScreen";
import { NFTScreen } from "@/screens/NFTScreen";
import { BudgetScreen } from "@/screens/BudgetScreen";
import { ChartingScreen } from "@/screens/ChartingScreen";
import { NetworkScreen } from "@/screens/NetworkScreen";
import { NFTScreen } from "@/screens/NFTScreen";
import { RecoveryScreen } from "@/screens/RecoveryScreen";
import { SorobanScreen } from "@/screens/SorobanScreen";
import { TransactionsScreen } from "@/screens/TransactionsScreen";
import { WalletScreen } from "@/screens/WalletScreen";
import { RecoveryScreen } from "@/screens/RecoveryScreen";
import { ChartingScreen } from "@/screens/ChartingScreen";
import { YieldFarmingScreen } from "@/screens/YieldFarmingScreen";
import { BudgetScreen } from "@/screens/BudgetScreen";

const SCREENS: Record<NavSection, ComponentType> = {
wallet: WalletScreen,
Expand All @@ -27,17 +27,47 @@ const SCREENS: Record<NavSection, ComponentType> = {
nfts: NFTScreen,
};

export function Dashboard() {
const [active, setActive] = useState<NavSection>("wallet");
export interface DashboardProps {
/**
* Controlled active section. When provided, `Dashboard` renders this section
* and never changes it internally — the parent owns the state and should
* update it from `onSectionChange`.
*/
activeSection?: NavSection;
/** Fired whenever a nav item is chosen, in both controlled and uncontrolled mode. */
onSectionChange?: (section: NavSection) => void;
/** Initial section in uncontrolled mode. Ignored when `activeSection` is set. */
defaultSection?: NavSection;
}

export function Dashboard({
activeSection,
onSectionChange,
defaultSection = "wallet",
}: DashboardProps = {}) {
const isControlled = activeSection !== undefined;
const [internalActive, setInternalActive] =
useState<NavSection>(defaultSection);
const [sidebarOpen, setSidebarOpen] = useState(false);

const ActiveScreen = SCREENS[active];
const active = isControlled ? activeSection : internalActive;

const handleNavigate = useCallback(
(section: NavSection) => {
// In controlled mode the parent decides what renders next; only report.
if (!isControlled) setInternalActive(section);
onSectionChange?.(section);
},
[isControlled, onSectionChange],
);

const ActiveScreen = SCREENS[active] ?? SCREENS.wallet;

return (
<div className="flex h-screen overflow-hidden bg-base">
<Sidebar
active={active}
onNavigate={setActive}
onNavigate={handleNavigate}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
Expand Down
Loading