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
64 changes: 33 additions & 31 deletions tensormap-frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suspense, lazy } from "react";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import AppTopBar from "./components/AppTopBar";
import ErrorBoundary from "./components/ErrorBoundary";
import ErrorBoundary, { PageLoader } from "./components/ErrorBoundary";
import WorkspaceLayout from "./components/Workspace/WorkspaceLayout";

const ProjectsPage = lazy(() => import("./containers/ProjectsPage/ProjectsPage"));
Expand All @@ -10,47 +10,49 @@ const DataProcess = lazy(() => import("./containers/DataProcess/DataProcess"));
const DeepLearning = lazy(() => import("./containers/DeepLearning/DeepLearning"));
const Training = lazy(() => import("./containers/Training/Training"));

const withErrorBoundary = (Component) => (
<ErrorBoundary>
<Component />
</ErrorBoundary>
);

/**
* Root application component that defines all client-side routes.
*
* Uses lazy-loaded page components wrapped in an ErrorBoundary and a Suspense
* fallback. Routes are organised into a projects listing and a per-project
* workspace layout with nested dataset, process, model, and training pages.
* Uses lazy-loaded page components wrapped in per-route ErrorBoundary components
* and a Suspense fallback. Each route is isolated — if one page crashes, the
* others remain functional.
*/
function App() {
return (
<BrowserRouter>
<AppTopBar />
<ErrorBoundary>
<Suspense
fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}
>
<Routes>
{/* Projects landing page */}
<Route path="/projects" element={<ProjectsPage />} />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Projects landing page */}
<Route path="/projects" element={withErrorBoundary(ProjectsPage)} />

{/* Workspace with sidebar */}
<Route path="/workspace/:projectId" element={<WorkspaceLayout />}>
<Route index element={<Navigate to="dataset" replace />} />
<Route path="dataset" element={<DataUpload />} />
<Route path="datasets" element={<Navigate to="../dataset" replace />} />
<Route path="process" element={<DataProcess />} />
<Route path="models" element={<DeepLearning />} />
<Route path="training" element={<Training />} />
</Route>
{/* Workspace with sidebar */}
<Route path="/workspace/:projectId" element={<WorkspaceLayout />}>
<Route index element={<Navigate to="dataset" replace />} />
<Route path="dataset" element={withErrorBoundary(DataUpload)} />
<Route path="datasets" element={<Navigate to="../dataset" replace />} />
<Route path="process" element={withErrorBoundary(DataProcess)} />
<Route path="models" element={withErrorBoundary(DeepLearning)} />
<Route path="training" element={withErrorBoundary(Training)} />
</Route>

{/* Legacy redirects */}
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/home" element={<Navigate to="/projects" replace />} />
<Route path="/data-upload" element={<Navigate to="/projects" replace />} />
<Route path="/data-process" element={<Navigate to="/projects" replace />} />
<Route path="/deep-learning" element={<Navigate to="/projects" replace />} />
{/* Legacy redirects */}
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/home" element={<Navigate to="/projects" replace />} />
<Route path="/data-upload" element={<Navigate to="/projects" replace />} />
<Route path="/data-process" element={<Navigate to="/projects" replace />} />
<Route path="/deep-learning" element={<Navigate to="/projects" replace />} />

{/* Catch-all */}
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Expand Down
56 changes: 44 additions & 12 deletions tensormap-frontend/src/components/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component } from "react";
import { useNavigate } from "react-router-dom";

/**
* React Error Boundary that catches rendering errors in its subtree and
Expand All @@ -18,25 +19,56 @@ class ErrorBoundary extends Component {
console.error("ErrorBoundary caught an error:", error, info);
}

handleRetry = () => {
this.setState({ hasError: false, error: null });
};

render() {
if (this.state.hasError) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8 text-center">
<h2 className="text-2xl font-semibold text-destructive">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
An unexpected error occurred. Please refresh the page or try again later.
</p>
return <ErrorFallback error={this.state.error} onRetry={this.handleRetry} />;
}
return this.props.children;
}
}

export function ErrorFallback({ error, onRetry }) {
const navigate = useNavigate();

return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8 text-center">
<h2 className="text-2xl font-semibold text-destructive">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
{error?.message || "An unexpected error occurred. Please try again."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
{onRetry && (
<button
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
onClick={() => this.setState({ hasError: false, error: null })}
onClick={onRetry}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
)}
<button
className="rounded-md border border-border bg-background px-4 py-2 text-foreground hover:bg-accent"
onClick={() => navigate("/projects")}
>
Back to Projects
</button>
</div>
</div>
);
}

export function PageLoader() {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<span className="text-muted-foreground">Loading page...</span>
</div>
</div>
);
}

export default ErrorBoundary;
109 changes: 109 additions & 0 deletions tensormap-frontend/src/components/ErrorBoundary.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi } from "vitest";
import ErrorBoundary, { ErrorFallback, PageLoader } from "./ErrorBoundary";

describe("ErrorFallback", () => {
it("renders default message when no error provided", () => {
render(
<MemoryRouter>
<ErrorFallback />
</MemoryRouter>,
);
expect(screen.getByText(/something went wrong/i)).toBeDefined();
});

it("renders error message when error is provided", () => {
render(
<MemoryRouter>
<ErrorFallback error={new Error("Test error message")} />
</MemoryRouter>,
);
expect(screen.getByText("Test error message")).toBeDefined();
});

it("shows try again button when onRetry is provided", () => {
render(
<MemoryRouter>
<ErrorFallback onRetry={() => {}} />
</MemoryRouter>,
);
expect(screen.getByText("Try again")).toBeDefined();
});

it("hides try again button when onRetry is not provided", () => {
render(
<MemoryRouter>
<ErrorFallback />
</MemoryRouter>,
);
expect(screen.queryByText("Try again")).toBeNull();
});

it("renders back to projects link", () => {
render(
<MemoryRouter>
<ErrorFallback />
</MemoryRouter>,
);
expect(screen.getByText("Back to Projects")).toBeDefined();
});
});

describe("PageLoader", () => {
it("renders loading spinner and text", () => {
render(<PageLoader />);
expect(screen.getByText("Loading page...")).toBeDefined();
});
});

describe("ErrorBoundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("renders children when there is no error", () => {
render(
<MemoryRouter>
<ErrorBoundary>
<div>Child content</div>
</ErrorBoundary>
</MemoryRouter>,
);
expect(screen.getByText("Child content")).toBeDefined();
});

it("renders error fallback when a child throws", () => {
const ThrowingComponent = () => {
throw new Error("Boom!");
};

render(
<MemoryRouter>
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
</MemoryRouter>,
);

expect(screen.getByText(/something went wrong/i)).toBeDefined();
expect(screen.getByText("Boom!")).toBeDefined();
});

it("recovers after retry resets state", () => {
const ErrorBoundaryInstance = new ErrorBoundary({ children: null });

expect(ErrorBoundaryInstance.state.hasError).toBe(false);

ErrorBoundaryInstance.setState({ hasError: true, error: new Error("test") });

ErrorBoundaryInstance.handleRetry();

expect(ErrorBoundaryInstance.state.hasError).toBe(false);
expect(ErrorBoundaryInstance.state.error).toBeNull();
});
});
Loading