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
114 changes: 7 additions & 107 deletions app/src/components/chart-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,11 @@ import {
Skeleton,
EmptyState,
JsonViewer,
MarkdownWidget,
IframeWidget,
} from "@neoboard/components";

// Chart components use ECharts (browser APIs) — must be loaded client-side only
const BarChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.BarChart })),
{ ssr: false, loading: () => <Skeleton className="w-full h-full" /> },
);
const LineChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.LineChart })),
{ ssr: false, loading: () => <Skeleton className="w-full h-full" /> },
);
const PieChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.PieChart })),
{ ssr: false, loading: () => <Skeleton className="w-full h-full" /> },
);
// Chart components use ECharts (browser APIs) — must be loaded client-side only.
// Bar/Line/Pie are now loaded via their plugins in app/src/plugins/.
const SingleValueChart = dynamic(
() =>
import("@neoboard/components").then((m) => ({
Expand All @@ -36,9 +24,6 @@ const SingleValueChart = dynamic(
{ ssr: false, loading: () => <Skeleton className="w-full h-full" /> },
);
import type {
BarChartDataPoint,
LineChartDataPoint,
PieChartDataPoint,
GraphNode,
GraphEdge,
MapMarker,
Expand Down Expand Up @@ -213,93 +198,13 @@ function ChartRendererInner({

switch (type) {
case "bar":
return (
<BarChart
data={(data as BarChartDataPoint[]) ?? []}
orientation={
settings.orientation as "vertical" | "horizontal" | undefined
}
stacked={settings.stacked as boolean | undefined}
showValues={settings.showValues as boolean | undefined}
showLegend={settings.showLegend as boolean | undefined}
barWidth={settings.barWidth as number | undefined}
barGap={settings.barGap as string | undefined}
xAxisLabel={settings.xAxisLabel as string | undefined}
yAxisLabel={settings.yAxisLabel as string | undefined}
showGridLines={settings.showGridLines as boolean | undefined}
axisLabelRotation={settings.axisLabelRotation as number | undefined}
referenceLines={settings.referenceLines as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
onClick={handleEChartsClick}
enableDataZoom={settings.enableDataZoom as boolean | undefined}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
);

case "line":
return (
<LineChart
data={(data as LineChartDataPoint[]) ?? []}
smooth={settings.smooth as boolean | undefined}
area={settings.area as boolean | undefined}
xAxisLabel={settings.xAxisLabel as string | undefined}
yAxisLabel={settings.yAxisLabel as string | undefined}
showLegend={settings.showLegend as boolean | undefined}
lineWidth={settings.lineWidth as number | undefined}
stepped={settings.stepped as boolean | undefined}
showPoints={settings.showPoints as boolean | undefined}
showGridLines={settings.showGridLines as boolean | undefined}
referenceLines={settings.referenceLines as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
rightAxisSeries={
typeof settings.rightAxisSeries === "string" &&
settings.rightAxisSeries.trim() !== ""
? settings.rightAxisSeries
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: undefined
}
rightYAxisLabel={settings.rightYAxisLabel as string | undefined}
onClick={handleEChartsClick}
enableDataZoom={settings.enableDataZoom as boolean | undefined}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
);

case "pie":
return (
<PieChart
data={(data as PieChartDataPoint[]) ?? []}
donut={settings.donut as boolean | undefined}
showLabel={settings.showLabel as boolean | undefined}
showLegend={settings.showLegend as boolean | undefined}
roseMode={settings.roseMode as boolean | undefined}
labelPosition={
settings.labelPosition as
| "outside"
| "inside"
| "center"
| undefined
}
showPercentage={settings.showPercentage as boolean | undefined}
sortSlices={settings.sortSlices as boolean | undefined}
topN={settings.topN as number | undefined}
donutCenterText={settings.donutCenterText as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
onClick={handleEChartsClick}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
);
case "markdown":
// Handled by plugins (app/src/plugins/). The plugin lookup above
// intercepts these types — this branch is defensive and should
// never execute in practice.
return null;

case "single-value": {
const raw = data ?? 0;
Expand Down Expand Up @@ -476,11 +381,6 @@ function ChartRendererInner({
/>
);

case "markdown":
return (
<MarkdownWidget content={settings.content as string | undefined} />
);

case "iframe":
return (
<IframeWidget
Expand Down
62 changes: 62 additions & 0 deletions app/src/plugins/__tests__/bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { barPlugin } from "../bar";

vi.mock("next/dynamic", () => ({
default: () => {
const Stub = (props: Record<string, unknown>) => (
<div
data-testid="bar-chart"
data-stacked={String(props.stacked ?? false)}
data-orientation={String(props.orientation ?? "vertical")}
/>
);
Stub.displayName = "BarChartStub";
return Stub;
},
}));

vi.mock("@neoboard/components", () => ({
Skeleton: () => null,
}));

describe("barPlugin", () => {
it("declares type = 'bar'", () => {
expect(barPlugin.type).toBe("bar");
});

it("supports click action and styling", () => {
expect(barPlugin.capabilities.supportsClickAction).toBe(true);
expect(barPlugin.capabilities.supportsStyling).toBe(true);
expect(barPlugin.capabilities.isECharts).toBe(true);
expect(barPlugin.capabilities.requiresQuery).toBe(true);
});

it("compatible with neo4j and postgresql", () => {
expect(barPlugin.compatibleWith).toEqual(["neo4j", "postgresql"]);
});

it("has a styling target for bar color", () => {
expect(barPlugin.stylingTargets).toEqual([
{ value: "color", label: "Bar Color" },
]);
});

it("renders BarChart with settings passed through", () => {
const Component = barPlugin.component;
render(
<Component
data={[]}
settings={{ stacked: true, orientation: "horizontal" }}
/>,
);
const chart = screen.getByTestId("bar-chart");
expect(chart).toHaveAttribute("data-stacked", "true");
expect(chart).toHaveAttribute("data-orientation", "horizontal");
});

it("has transform and validate from chart registry", () => {
expect(typeof barPlugin.transform).toBe("function");
expect(typeof barPlugin.validate).toBe("function");
});
});
86 changes: 86 additions & 0 deletions app/src/plugins/bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Bar chart plugin.
*
* Horizontal / vertical / stacked bars for comparing categories.
* Supports click actions, rule-based styling, and axis data zoom.
*/

import dynamic from "next/dynamic";
import { Skeleton } from "@neoboard/components";
import type {
BarChartDataPoint,
EChartsClickEvent,
StylingRule,
} from "@neoboard/components";
import { defineChartPlugin } from "./registry";
import { chartRegistry } from "@/lib/chart-registry";

// Charts use ECharts (browser APIs) — must be loaded client-side only
const BarChart = dynamic(
() => import("@neoboard/components").then((m) => ({ default: m.BarChart })),
{ ssr: false, loading: () => <Skeleton className="w-full h-full" /> },
);

interface PluginComponentProps {
data: unknown;
settings: Record<string, unknown>;
stylingRules?: StylingRule[];
paramValues?: Record<string, unknown>;
onClick?: (e: EChartsClickEvent) => void;
colorThresholds?: string;
}

function BarPluginComponent({
data,
settings,
stylingRules,
paramValues,
onClick,
colorThresholds,
}: PluginComponentProps) {

Check warning on line 40 in app/src/plugins/bar.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ1fyG5aMbZ4NfMLG7rU&open=AZ1fyG5aMbZ4NfMLG7rU&pullRequest=379
return (
<BarChart
data={(data as BarChartDataPoint[]) ?? []}
orientation={
settings.orientation as "vertical" | "horizontal" | undefined
}
stacked={settings.stacked as boolean | undefined}
showValues={settings.showValues as boolean | undefined}
showLegend={settings.showLegend as boolean | undefined}
barWidth={settings.barWidth as number | undefined}
barGap={settings.barGap as string | undefined}
xAxisLabel={settings.xAxisLabel as string | undefined}
yAxisLabel={settings.yAxisLabel as string | undefined}
showGridLines={settings.showGridLines as boolean | undefined}
axisLabelRotation={settings.axisLabelRotation as number | undefined}
referenceLines={settings.referenceLines as string | undefined}
colorThresholds={colorThresholds}
stylingRules={stylingRules}
paramValues={paramValues}
onClick={onClick}
enableDataZoom={settings.enableDataZoom as boolean | undefined}
colorPalette={settings.colorPalette as string | undefined}
colorblindMode={settings.colorblindMode as boolean | undefined}
/>
);
}

export const barPlugin = defineChartPlugin({
type: "bar",
label: "Bar Chart",
component: BarPluginComponent,
transform: chartRegistry.bar.transform,
transformWithMapping: chartRegistry.bar.transformWithMapping,
validate: chartRegistry.bar.validate,
compatibleWith: ["neo4j", "postgresql"],
stylingTargets: [{ value: "color", label: "Bar Color" }],
capabilities: {
supportsClickAction: true,
supportsStyling: true,
isECharts: true,
requiresQuery: true,
},
queryHint:
"Return 2+ columns: first = category label (string), rest = numeric series.\n" +
"Example: RETURN genre, count(*) AS films",
});
11 changes: 9 additions & 2 deletions app/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@

import { pluginRegistry } from "./registry";
import { markdownPlugin } from "./markdown";
import { barPlugin } from "./bar";
import { linePlugin } from "./line";
import { piePlugin } from "./pie";

const BUILT_IN_PLUGINS = [markdownPlugin, barPlugin, linePlugin, piePlugin];

// Idempotent registration — the first import of this module registers
// plugins; subsequent imports are no-ops thanks to Node's module cache.
if (!pluginRegistry.has(markdownPlugin.type)) {
pluginRegistry.register(markdownPlugin);
for (const plugin of BUILT_IN_PLUGINS) {
if (!pluginRegistry.has(plugin.type)) {
pluginRegistry.register(plugin);
}
}

// Re-export for convenience
Expand Down
Loading
Loading