feat(web): add dashboard error boundaries
This commit is contained in:
parent
129149e166
commit
52bcce8f2b
|
|
@ -29,7 +29,7 @@
|
|||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "pnpm --filter @composio/ao-cli^... build && tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"build": "next build && tsc -p tsconfig.server.json",
|
||||
"start": "next start",
|
||||
"start:all": "node dist-server/start-all.js",
|
||||
"typecheck": "pnpm --filter @composio/ao-web^... build && tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf .next dist-server",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const refresh = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh }),
|
||||
}));
|
||||
|
||||
import ErrorPage from "./error";
|
||||
|
||||
describe("Route error boundary", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("retries with reset and router refresh", () => {
|
||||
const reset = vi.fn();
|
||||
|
||||
render(<ErrorPage error={new Error("boom")} reset={reset} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a dashboard escape hatch", () => {
|
||||
render(<ErrorPage error={new Error("boom")} reset={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Something went wrong"
|
||||
message="The dashboard hit an unexpected error. Try reloading the route data or head back to the main dashboard."
|
||||
tone="warning"
|
||||
primaryAction={{
|
||||
label: "Try again",
|
||||
onClick: () => {
|
||||
reset();
|
||||
router.refresh();
|
||||
},
|
||||
}}
|
||||
secondaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
error={error}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const errorDisplaySpy = vi.fn();
|
||||
|
||||
vi.mock("@/components/ErrorDisplay", () => ({
|
||||
ErrorDisplay: (props: {
|
||||
title: string;
|
||||
message: string;
|
||||
primaryAction?: { label: string; onClick?: () => void };
|
||||
secondaryAction?: { label: string; onClick?: () => void };
|
||||
}) => {
|
||||
errorDisplaySpy(props);
|
||||
return (
|
||||
<div>
|
||||
<div>{props.title}</div>
|
||||
<div>{props.message}</div>
|
||||
{props.primaryAction ? (
|
||||
<button type="button" onClick={props.primaryAction.onClick}>
|
||||
{props.primaryAction.label}
|
||||
</button>
|
||||
) : null}
|
||||
{props.secondaryAction ? (
|
||||
<button type="button" onClick={props.secondaryAction.onClick}>
|
||||
{props.secondaryAction.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import GlobalError from "./global-error";
|
||||
|
||||
describe("Global error boundary", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
errorDisplaySpy.mockClear();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
it("calls reset when retrying", () => {
|
||||
const reset = vi.fn();
|
||||
|
||||
render(<GlobalError error={new Error("layout failed")} reset={reset} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reloads the page on demand", () => {
|
||||
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
|
||||
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the expected shell copy to ErrorDisplay", () => {
|
||||
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
|
||||
|
||||
expect(errorDisplaySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Something broke at the app shell",
|
||||
message:
|
||||
"The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
|
||||
<ErrorDisplay
|
||||
title="Something broke at the app shell"
|
||||
message="The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails."
|
||||
tone="error"
|
||||
primaryAction={{ label: "Try again", onClick: reset }}
|
||||
secondaryAction={{ label: "Reload page", onClick: () => window.location.reload() }}
|
||||
error={error}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,9 +18,18 @@ describe("NotFound (global)", () => {
|
|||
expect(screen.getByText("Page not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders descriptive copy", () => {
|
||||
render(<NotFound />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This route does not exist in the dashboard. Return to the main view to pick an active project or session.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a link back to the dashboard", () => {
|
||||
render(<NotFound />);
|
||||
const link = screen.getByText("← Back to dashboard");
|
||||
const link = screen.getByText("Back to dashboard");
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.closest("a")).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,12 @@
|
|||
import Link from "next/link";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
|
||||
<svg
|
||||
className="h-8 w-8 text-[var(--color-border-strong)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 9l4 3-4 3M13 15h5" />
|
||||
</svg>
|
||||
<p className="text-[13px] text-[var(--color-text-muted)]">
|
||||
Page not found
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[12px] text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<ErrorDisplay
|
||||
title="Page not found"
|
||||
message="This route does not exist in the dashboard. Return to the main view to pick an active project or session."
|
||||
tone="not-found"
|
||||
primaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const refresh = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh }),
|
||||
}));
|
||||
|
||||
import SessionError from "./error";
|
||||
|
||||
describe("Session error boundary", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("retries with reset and router refresh", () => {
|
||||
const reset = vi.fn();
|
||||
|
||||
render(<SessionError error={new Error("HTTP 500")} reset={reset} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a session-specific message", () => {
|
||||
render(<SessionError error={new Error("Network request failed")} reset={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The session request failed before the dashboard got a response. Check the server connection and try again.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
function getSessionErrorMessage(error: Error): string {
|
||||
const normalized = error.message.toLowerCase();
|
||||
if (normalized.includes("network")) {
|
||||
return "The session request failed before the dashboard got a response. Check the server connection and try again.";
|
||||
}
|
||||
if (normalized.includes("403")) {
|
||||
return "The dashboard could not access this session. Permissions or auth may have changed.";
|
||||
}
|
||||
if (normalized.includes("404")) {
|
||||
return "This session is no longer available. It may have been removed while the page was open.";
|
||||
}
|
||||
if (normalized.includes("500")) {
|
||||
return "The server returned an internal error while loading this session. Try re-fetching the session data.";
|
||||
}
|
||||
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
|
||||
}
|
||||
|
||||
export default function SessionError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Failed to load session"
|
||||
message={getSessionErrorMessage(error)}
|
||||
tone="error"
|
||||
primaryAction={{
|
||||
label: "Try again",
|
||||
onClick: () => {
|
||||
reset();
|
||||
router.refresh();
|
||||
},
|
||||
}}
|
||||
secondaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
error={error}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,14 +22,14 @@ describe("SessionNotFound", () => {
|
|||
render(<SessionNotFound />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The session you\u2019re looking for doesn\u2019t exist or has been deleted.",
|
||||
"The session you’re looking for does not exist anymore, or the link is stale.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a link back to the dashboard", () => {
|
||||
render(<SessionNotFound />);
|
||||
const link = screen.getByText("← Back to dashboard");
|
||||
const link = screen.getByText("Back to dashboard");
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.closest("a")).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,31 +1,14 @@
|
|||
import Link from "next/link";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export default function SessionNotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
|
||||
<svg
|
||||
className="h-8 w-8 text-[var(--color-border-strong)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 9l4 3-4 3M13 15h5" />
|
||||
</svg>
|
||||
<p className="text-[13px] text-[var(--color-text-muted)]">
|
||||
Session not found
|
||||
</p>
|
||||
<p className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
The session you’re looking for doesn’t exist or has been
|
||||
deleted.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[12px] text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<ErrorDisplay
|
||||
title="Session not found"
|
||||
message="The session you’re looking for does not exist anymore, or the link is stale."
|
||||
tone="not-found"
|
||||
primaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { act, render } from "@testing-library/react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DashboardSession } from "@/lib/types";
|
||||
|
||||
const sessionDetailSpy = vi.fn();
|
||||
const notFoundError = new Error("NEXT_NOT_FOUND");
|
||||
const notFoundSpy = vi.fn(() => {
|
||||
throw notFoundError;
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ id: "worker-1" }),
|
||||
notFound: notFoundSpy,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/SessionDetail", () => ({
|
||||
|
|
@ -40,10 +46,32 @@ async function flushAsyncWork(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
class TestErrorBoundary extends React.Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <div data-testid="route-error">{this.state.error.message}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
describe("SessionPage project polling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
sessionDetailSpy.mockClear();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const eventSourceMock = {
|
||||
addEventListener: vi.fn(),
|
||||
|
|
@ -59,6 +87,7 @@ describe("SessionPage project polling", () => {
|
|||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
notFoundSpy.mockReset();
|
||||
});
|
||||
|
||||
it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => {
|
||||
|
|
@ -125,4 +154,70 @@ describe("SessionPage project polling", () => {
|
|||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("routes 404 responses through notFound()", async () => {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ projects: [] }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(<SessionPage />);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(notFoundSpy).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("throws non-404 session fetch failures to the route error boundary", async () => {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/projects") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ projects: [] }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url === "/api/sessions/worker-1") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { default: SessionPage } = await import("./page");
|
||||
|
||||
render(
|
||||
<TestErrorBoundary>
|
||||
<SessionPage />
|
||||
</TestErrorBoundary>,
|
||||
);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { notFound, useParams } from "next/navigation";
|
||||
import { isOrchestratorSession } from "@composio/ao-core/types";
|
||||
import { SessionDetail } from "@/components/SessionDetail";
|
||||
import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types";
|
||||
|
|
@ -63,7 +63,8 @@ export default function SessionPage() {
|
|||
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
|
||||
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<Error | null>(null);
|
||||
const [sessionMissing, setSessionMissing] = useState(false);
|
||||
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
|
||||
const sessionProjectId = session?.projectId ?? null;
|
||||
const allPrefixes = [...prefixByProject.values()];
|
||||
|
|
@ -119,17 +120,18 @@ export default function SessionPage() {
|
|||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
if (res.status === 404) {
|
||||
setError("Session not found");
|
||||
setSessionMissing(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as DashboardSession;
|
||||
setSession(data);
|
||||
setError(null);
|
||||
setRouteError(null);
|
||||
setSessionMissing(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch session:", err);
|
||||
setError("Failed to load session");
|
||||
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -211,17 +213,17 @@ export default function SessionPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
|
||||
<div className="text-[13px] text-[var(--color-status-error)]">
|
||||
{error ?? "Session not found"}
|
||||
</div>
|
||||
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
|
||||
← Back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (sessionMissing) {
|
||||
notFound();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (routeError) {
|
||||
throw routeError;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session data was unavailable after loading completed");
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorTone = "error" | "warning" | "not-found";
|
||||
|
||||
interface ErrorAction {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
title: string;
|
||||
message: string;
|
||||
tone?: ErrorTone;
|
||||
detailsTitle?: string;
|
||||
error?: Error & { digest?: string };
|
||||
primaryAction?: ErrorAction;
|
||||
secondaryAction?: ErrorAction;
|
||||
compact?: boolean;
|
||||
chrome?: "page" | "card";
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const toneMeta: Record<ErrorTone, { accent: string; bg: string; border: string; label: string }> = {
|
||||
error: {
|
||||
accent: "var(--color-status-error)",
|
||||
bg: "var(--color-tint-red)",
|
||||
border: "color-mix(in srgb, var(--color-status-error) 24%, transparent)",
|
||||
label: "error",
|
||||
},
|
||||
warning: {
|
||||
accent: "var(--color-status-attention)",
|
||||
bg: "var(--color-tint-yellow)",
|
||||
border: "color-mix(in srgb, var(--color-status-attention) 24%, transparent)",
|
||||
label: "warning",
|
||||
},
|
||||
"not-found": {
|
||||
accent: "var(--color-accent)",
|
||||
bg: "var(--color-tint-blue)",
|
||||
border: "color-mix(in srgb, var(--color-accent) 24%, transparent)",
|
||||
label: "missing",
|
||||
},
|
||||
};
|
||||
|
||||
function TerminalIcon({ accent }: { accent: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-14 w-14 items-center justify-center rounded-2xl border"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, ${accent} 16%, var(--color-bg-elevated)) 0%, var(--color-bg-surface) 100%)`,
|
||||
borderColor: `color-mix(in srgb, ${accent} 18%, var(--color-border-default))`,
|
||||
boxShadow: "var(--detail-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-7 w-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
style={{ color: accent }}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect x="2.5" y="4.5" width="19" height="15" rx="3" />
|
||||
<path d="M6.5 9.5l3.25 2.5-3.25 2.5M12.75 15h4.75" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ action, primary = false }: { action: ErrorAction; primary?: boolean }) {
|
||||
const className = primary
|
||||
? "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-semibold transition-colors hover:no-underline"
|
||||
: "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-medium transition-colors hover:no-underline";
|
||||
const style = primary
|
||||
? {
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--color-text-inverse)",
|
||||
borderColor: "color-mix(in srgb, var(--color-accent) 72%, transparent)",
|
||||
}
|
||||
: {
|
||||
background: "var(--color-bg-surface)",
|
||||
color: "var(--color-text-secondary)",
|
||||
borderColor: "var(--color-border-default)",
|
||||
};
|
||||
|
||||
if (action.href) {
|
||||
return (
|
||||
<Link href={action.href} className={className} style={style}>
|
||||
{action.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={action.onClick} className={className} style={style}>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorDisplay({
|
||||
title,
|
||||
message,
|
||||
tone = "error",
|
||||
detailsTitle = "Technical details",
|
||||
error,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
compact = false,
|
||||
chrome = "page",
|
||||
children,
|
||||
}: ErrorDisplayProps) {
|
||||
const meta = toneMeta[tone];
|
||||
const hasDetails = Boolean(error?.digest || error?.message || error?.stack);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full items-center justify-center px-6 py-10 ${compact ? "min-h-[calc(100vh-4rem)]" : "min-h-screen"}`}
|
||||
style={{
|
||||
background:
|
||||
chrome === "page"
|
||||
? "radial-gradient(circle at top, var(--color-body-gradient-blue), transparent 35%), var(--color-bg-base)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-[36rem] rounded-[28px] border p-6 sm:p-8"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, color-mix(in srgb, var(--color-bg-elevated) 88%, transparent) 0%, var(--color-bg-surface) 100%)",
|
||||
borderColor: "var(--color-border-default)",
|
||||
boxShadow: "var(--detail-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<TerminalIcon accent={meta.accent} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="mb-3 inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[10px] font-medium uppercase tracking-[0.22em]"
|
||||
style={{
|
||||
color: meta.accent,
|
||||
background: meta.bg,
|
||||
borderColor: meta.border,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</div>
|
||||
<h1 className="text-[24px] font-semibold tracking-[-0.04em] text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-[34rem] text-[14px] leading-6 text-[var(--color-text-secondary)]">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{primaryAction ? <ActionButton action={primaryAction} primary /> : null}
|
||||
{secondaryAction ? <ActionButton action={secondaryAction} /> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{hasDetails ? (
|
||||
<details
|
||||
className="rounded-2xl border"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-bg-elevated) 84%, transparent)",
|
||||
borderColor: "var(--color-border-subtle)",
|
||||
}}
|
||||
>
|
||||
<summary className="cursor-pointer list-none px-4 py-3 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
{detailsTitle}
|
||||
</summary>
|
||||
<div className="border-t px-4 py-4" style={{ borderColor: "var(--color-border-subtle)" }}>
|
||||
{error?.digest ? (
|
||||
<p className="mb-3 font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
|
||||
digest: {error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
{error?.message ? (
|
||||
<p className="mb-3 text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{error?.stack ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-[var(--font-mono)] text-[11px] leading-5 text-[var(--color-text-tertiary)]">
|
||||
{error.stack}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
|
||||
<a {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ErrorDisplay } from "../ErrorDisplay";
|
||||
|
||||
describe("ErrorDisplay", () => {
|
||||
it("renders title, message, and actions", () => {
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(
|
||||
<ErrorDisplay
|
||||
title="Failed to load session"
|
||||
message="Try again."
|
||||
primaryAction={{ label: "Try again", onClick: onRetry }}
|
||||
secondaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Try again.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("renders technical details when provided", () => {
|
||||
render(
|
||||
<ErrorDisplay
|
||||
title="Something went wrong"
|
||||
message="Details are available."
|
||||
error={Object.assign(new Error("HTTP 500"), { digest: "abc123" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Technical details")).toBeInTheDocument();
|
||||
expect(screen.getByText(/digest: abc123/)).toBeInTheDocument();
|
||||
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue