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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const SetupPage = lazy(() => import("./pages/SetupPage"));
const BatchMultiCall = lazy(() => import("./pages/BatchMultiCall"));
const SubInvocationPage = lazy(() => import("./pages/SubInvocationPage"));
const RateLimitDashboard = lazy(() => import("./pages/RateLimitDashboard"));
const NotFound = lazy(() => import("./pages/NotFound"));

function Fallback() {
return <p style={{ padding: 32, textAlign: "center", color: "var(--muted)" }}>Loading…</p>;
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function App() {
<Route path="/batch" element={<BatchMultiCall />} />
<Route path="/sub-invocations" element={<SubInvocationPage />} />
<Route path="/admin/rate-limits" element={<RateLimitDashboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</main>
Expand Down
114 changes: 114 additions & 0 deletions src/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Link, useLocation } from "react-router-dom";

const NAV_LINKS: { label: string; to: string; description: string }[] = [
{ label: "Home", to: "/", description: "Recent contract events" },
{ label: "Search", to: "/search", description: "Find contracts, wallets & events" },
{ label: "Graph", to: "/graph", description: "Contract relationship graph" },
{ label: "XDR Inspector", to: "/xdr", description: "Decode XDR envelopes" },
{ label: "Sandbox", to: "/sandbox", description: "Prototype against live contracts" },
];

export default function NotFound() {
const { pathname } = useLocation();

return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 24,
padding: "64px 16px",
textAlign: "center",
}}
>
{/* Status code */}
<div
style={{
fontSize: 96,
fontWeight: 800,
lineHeight: 1,
color: "var(--accent)",
letterSpacing: "-4px",
}}
aria-hidden="true"
>
404
</div>

{/* Heading */}
<h1 style={{ fontSize: 24, margin: 0 }}>Page not found</h1>

{/* Path that was requested */}
<p style={{ color: "var(--muted)", margin: 0 }}>
<code
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "2px 8px",
}}
>
{pathname}
</code>{" "}
doesn't match any known route.
</p>

{/* Quick-nav back into the app */}
<nav aria-label="Return to app" style={{ width: "100%", maxWidth: 480 }}>
<p style={{ color: "var(--muted)", marginBottom: 12 }}>
Here are some places to get back on track:
</p>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{NAV_LINKS.map(({ label, to, description }) => (
<li key={to}>
<Link
to={to}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 16px",
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 6,
textDecoration: "none",
color: "inherit",
transition: "border-color 0.15s",
}}
>
<span style={{ fontWeight: 500 }}>{label}</span>
<span style={{ color: "var(--muted)", fontSize: 13 }}>{description}</span>
</Link>
</li>
))}
</ul>
</nav>

{/* Go back button */}
<button
onClick={() => window.history.back()}
style={{
background: "none",
border: "1px solid var(--border)",
borderRadius: 6,
padding: "8px 20px",
color: "var(--muted)",
cursor: "pointer",
}}
>
← Go back
</button>
</div>
);
}
116 changes: 116 additions & 0 deletions test/NotFound.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Issue #19 — Add a catch-all 404 route
*
* Tests:
* 1. Navigating to an unknown path renders the NotFound component.
* 2. The NotFound page displays the requested path.
* 3. The NotFound page provides at least one working link back into the app.
* 4. Known routes (e.g. "/search") are NOT swallowed by the catch-all.
*/

import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NotFound from "../src/pages/NotFound";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeQC() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}

/**
* Render the full route tree (mirroring App.tsx) at a given initial path.
* Keeps the test scope narrow: only the routes we need to exercise here.
*/
function renderAt(initialPath: string) {
const qc = makeQC();
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
{/* Known route — a lightweight stub so we can verify it wins */}
<Route path="/search" element={<div>SearchPage</div>} />
{/* Catch-all — must come last */}
<Route path="*" element={<NotFound />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}

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

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("NotFound — catch-all 404 route", () => {
it("renders '404' heading at an unknown path", () => {
renderAt("/this/does/not/exist");
expect(screen.getByText("404")).toBeDefined();
});

it("displays the unrecognised path in the message", () => {
renderAt("/totally/unknown");
// The component renders the pathname in a <code> block
expect(screen.getByText("/totally/unknown")).toBeDefined();
});

it("shows 'Page not found' heading", () => {
renderAt("/bad-path");
expect(screen.getByRole("heading", { name: /page not found/i })).toBeDefined();
});

it("renders at least one link back to the home page", () => {
renderAt("/nonexistent");
// The nav list includes a "Home" link pointing to "/"
const homeLink = screen.getByRole("link", { name: /home/i });
expect(homeLink).toBeDefined();
expect((homeLink as HTMLAnchorElement).getAttribute("href")).toBe("/");
});

it("renders all five quick-nav links", () => {
renderAt("/nonexistent");
const links = ["Home", "Search", "Graph", "XDR Inspector", "Sandbox"];
links.forEach((label) => {
expect(screen.getByRole("link", { name: new RegExp(label, "i") })).toBeDefined();
});
});

it("renders the go-back button", () => {
renderAt("/nonexistent");
expect(screen.getByRole("button", { name: /go back/i })).toBeDefined();
});
});

describe("Known routes — unaffected by catch-all", () => {
it("renders SearchPage at /search, not the NotFound page", () => {
renderAt("/search");
// Known route content should appear
expect(screen.getByText("SearchPage")).toBeDefined();
// 404 heading must NOT be visible
expect(screen.queryByText("404")).toBeNull();
});

it("does not show NotFound at the root path", () => {
const qc = makeQC();
render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<div>HomePage</div>} />
<Route path="*" element={<NotFound />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
expect(screen.getByText("HomePage")).toBeDefined();
expect(screen.queryByText("404")).toBeNull();
});
});
Loading