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
17 changes: 8 additions & 9 deletions app/src/app/api/widget-templates/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ import { nextResponseMockFactory } from "@/__tests__/helpers/next-mocks";
// Mocks
// ---------------------------------------------------------------------------

const mockRequireSession =
vi.fn<
() => Promise<{
userId: string;
role: string;
canWrite: boolean;
tenantId: string;
}>
>();
const mockRequireSession = vi.fn<
() => Promise<{
userId: string;
role: string;
canWrite: boolean;
tenantId: string;
}>
>();

const mockDb = {
select: vi.fn(),
Expand Down
50 changes: 50 additions & 0 deletions app/src/components/__tests__/chart-error-boundary-unit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { ChartErrorBoundary } from "../chart-error-boundary";

const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

function ThrowingChild(): React.JSX.Element {
throw new Error("test explosion");
}

function GoodChild() {
return <div data-testid="good-child">OK</div>;
}

describe("ChartErrorBoundary", () => {
afterAll(() => consoleError.mockRestore());

it("renders children when no error", () => {
render(
<ChartErrorBoundary chartType="bar">
<GoodChild />
</ChartErrorBoundary>,
);
expect(screen.getByTestId("good-child")).toBeDefined();
});

it("renders fallback UI when child throws", () => {
render(
<ChartErrorBoundary chartType="pie">
<ThrowingChild />
</ChartErrorBoundary>,
);
expect(screen.getByText("Chart failed to render")).toBeDefined();
expect(screen.getByText("test explosion")).toBeDefined();
});

it("logs error with chart type", () => {
render(
<ChartErrorBoundary chartType="sankey">
<ThrowingChild />
</ChartErrorBoundary>,
);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("[ChartErrorBoundary] sankey crashed:"),
expect.any(Error),
expect.anything(),
);
});
});
113 changes: 113 additions & 0 deletions app/src/components/__tests__/chart-error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";

// Mock @neoboard/components to avoid pulling in ECharts
vi.mock("@neoboard/components", () => ({
Skeleton: ({ className }: { className?: string }) => (
<div data-testid="skeleton" className={className} />
),
EmptyState: ({
title,
description,
}: {
title: string;
description?: string;
}) => (
<div data-testid="empty-state">
<span>{title}</span>
{description && <span>{description}</span>}
</div>
),
JsonViewer: () => <div data-testid="json-viewer" />,
MarkdownWidget: () => <div data-testid="markdown-widget" />,
IframeWidget: () => <div data-testid="iframe-widget" />,
}));

// Mock next/dynamic to just render children synchronously
vi.mock("next/dynamic", () => ({
default: () => {
return function DynamicStub() {
return <div data-testid="dynamic-stub" />;
};
},
}));

vi.mock("@/lib/normalize-value", () => ({
normalizeValue: (v: unknown) => v,
}));
vi.mock("@/components/parameter-widget-renderer", () => ({
ParameterWidgetRenderer: () => <div data-testid="param-renderer" />,
}));
vi.mock("@/components/graph-exploration-wrapper", () => ({
GraphExplorationWrapper: () => <div data-testid="graph-wrapper" />,
}));
vi.mock("@/components/form-widget-renderer", () => ({
FormWidgetRenderer: () => <div data-testid="form-renderer" />,
}));

// Suppress console.error from the error boundary during tests
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

import { ChartRenderer } from "../chart-renderer";

describe("ChartRenderer error boundary", () => {
afterAll(() => {
consoleError.mockRestore();
});

it("renders fallback when a chart throws during render", () => {
// Force a render error by passing data that will cause JSON.stringify to throw
const circular: Record<string, unknown> = {};
circular.self = circular;

// The table renderer will try to process this — but we need something
// that actually throws. Let's use a getter that throws.
const badData = [
new Proxy(
{},
{
get() {
throw new Error("Boom!");
},
ownKeys() {
throw new Error("Boom!");
},
},
),
];

render(
<ChartRenderer
type={"table" as Parameters<typeof ChartRenderer>[0]["type"]}
data={badData}
/>,
);

expect(screen.getByText("Chart failed to render")).toBeDefined();
expect(screen.getByText("Boom!")).toBeDefined();
});

it("renders chart normally when no error occurs", () => {
render(
<ChartRenderer
type={"json" as Parameters<typeof ChartRenderer>[0]["type"]}
data={{ hello: "world" }}
/>,
);

// JSON viewer should render (mocked)
expect(screen.getByTestId("json-viewer")).toBeDefined();
});

it("renders unknown chart type as empty state (not error boundary)", () => {
render(
<ChartRenderer
type={"nonexistent" as Parameters<typeof ChartRenderer>[0]["type"]}
data={null}
/>,
);

expect(screen.getByText("Unknown chart type")).toBeDefined();
});
});
1 change: 1 addition & 0 deletions app/src/components/card-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ export function CardContainer({
type={chartConfig.type}
data={null}
settings={resolvedContentOptions}
meta={{ widgetId: widget.id }}
/>
</div>
</div>
Expand Down
46 changes: 46 additions & 0 deletions app/src/components/chart-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import React from "react";
import { AlertCircle } from "lucide-react";

interface ChartErrorBoundaryState {
error: Error | null;
}

/**
* Catches render errors from any chart component so one broken widget
* doesn't crash the entire dashboard.
*/
export class ChartErrorBoundary extends React.Component<
{ chartType: string; children: React.ReactNode },
ChartErrorBoundaryState
> {
state: ChartErrorBoundaryState = { error: null };

static getDerivedStateFromError(error: Error) {
return { error };
}

componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error(
`[ChartErrorBoundary] ${this.props.chartType} crashed:`,
error,
info.componentStack,
);
}

render() {
if (this.state.error) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm font-medium">Chart failed to render</p>
<p className="text-xs text-muted-foreground max-w-[300px] truncate">
{this.state.error.message}
</p>
</div>
);
}
return this.props.children;
}
}
20 changes: 18 additions & 2 deletions app/src/components/chart-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { AlertCircle } from "lucide-react";
import { normalizeValue } from "@/lib/normalize-value";
import type { ChartType } from "@/lib/chart-registry";
import { ChartErrorBoundary } from "./chart-error-boundary";
import {
Skeleton,
EmptyState,
Expand Down Expand Up @@ -136,9 +137,20 @@ export interface ChartRendererProps {

/**
* Renders the appropriate chart component based on widget type and data.
* Forwards chart-specific settings as props to the underlying chart component.
* Wrapped in an error boundary so one broken widget doesn't crash the dashboard.
*/
export function ChartRenderer({
export function ChartRenderer(props: ChartRendererProps) {
return (
<ChartErrorBoundary
chartType={props.type}
key={`${props.type}-${props.meta?.widgetId ?? ""}`}
>
<ChartRendererInner {...props} />
</ChartErrorBoundary>
);
}

function ChartRendererInner({
type,
data,
settings = {},
Expand Down Expand Up @@ -319,6 +331,8 @@ export function ChartRenderer({
: undefined
}
autoFit={autoFit}
stylingRules={stylingRules}
paramValues={paramValues}
/>
);
}
Expand All @@ -344,6 +358,8 @@ export function ChartRenderer({
})
: undefined
}
stylingRules={stylingRules}
paramValues={paramValues}
/>
);
}
Expand Down
Loading
Loading