diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index b8ec5cb7..103d39da 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -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: () => }, -); -const LineChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.LineChart })), - { ssr: false, loading: () => }, -); -const PieChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.PieChart })), - { ssr: false, loading: () => }, -); +// 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) => ({ @@ -36,9 +24,6 @@ const SingleValueChart = dynamic( { ssr: false, loading: () => }, ); import type { - BarChartDataPoint, - LineChartDataPoint, - PieChartDataPoint, GraphNode, GraphEdge, MapMarker, @@ -213,93 +198,13 @@ function ChartRendererInner({ switch (type) { case "bar": - return ( - - ); - case "line": - return ( - 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 ( - - ); + 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; @@ -476,11 +381,6 @@ function ChartRendererInner({ /> ); - case "markdown": - return ( - - ); - case "iframe": return ( ({ + default: () => { + const Stub = (props: Record) => ( +
+ ); + 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( + , + ); + 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"); + }); +}); diff --git a/app/src/plugins/bar.tsx b/app/src/plugins/bar.tsx new file mode 100644 index 00000000..02068616 --- /dev/null +++ b/app/src/plugins/bar.tsx @@ -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: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; + colorThresholds?: string; +} + +function BarPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, + colorThresholds, +}: PluginComponentProps) { + return ( + + ); +} + +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", +}); diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index efafb56b..d64bfd9e 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -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 diff --git a/app/src/plugins/line.tsx b/app/src/plugins/line.tsx new file mode 100644 index 00000000..568b867c --- /dev/null +++ b/app/src/plugins/line.tsx @@ -0,0 +1,95 @@ +/** + * Line chart plugin. + * + * Line/area/stepped time-series charts with optional dual Y-axis support. + * Supports click actions, rule-based styling, and axis data zoom. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + LineChartDataPoint, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const LineChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.LineChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; + colorThresholds?: string; +} + +function LinePluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, + colorThresholds, +}: PluginComponentProps) { + // Parse comma-separated rightAxisSeries string into string array + const rightAxisSeriesRaw = settings.rightAxisSeries as string | undefined; + const rightAxisSeries = rightAxisSeriesRaw + ? rightAxisSeriesRaw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + : undefined; + + return ( + + ); +} + +export const linePlugin = defineChartPlugin({ + type: "line", + label: "Line Chart", + component: LinePluginComponent, + transform: chartRegistry.line.transform, + transformWithMapping: chartRegistry.line.transformWithMapping, + validate: chartRegistry.line.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Line Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return 2+ columns: first = x-axis label, rest = numeric series.\n" + + "Example: RETURN month, revenue, expenses", +}); diff --git a/app/src/plugins/pie.tsx b/app/src/plugins/pie.tsx new file mode 100644 index 00000000..f0366a3f --- /dev/null +++ b/app/src/plugins/pie.tsx @@ -0,0 +1,82 @@ +/** + * Pie / Doughnut chart plugin. + * + * Proportional slices for part-to-whole comparisons. Supports donut mode, + * rose (nightingale), top-N truncation, and scrollable legend. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + PieChartDataPoint, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const PieChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.PieChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; + colorThresholds?: string; +} + +function PiePluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, + colorThresholds, +}: PluginComponentProps) { + return ( + + ); +} + +export const piePlugin = defineChartPlugin({ + type: "pie", + label: "Pie / Doughnut", + component: PiePluginComponent, + transform: chartRegistry.pie.transform, + transformWithMapping: chartRegistry.pie.transformWithMapping, + validate: chartRegistry.pie.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Slice Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return 2 columns: first = slice label (string), second = numeric value.\n" + + "Example: RETURN category, count(*) AS total", +});