From e5248f15d20930d9af52e92ad551ccb8132e073a Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 01:00:46 +0200 Subject: [PATCH 1/4] feat(app): migrate all 13 remaining charts to plugin system (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth PR in the plugin system epic. Completes the migration — all 17 chart types now use the plugin registry. The switch statement in chart-renderer.tsx is entirely gone. New plugins (13): - single-value.tsx, graph.tsx, map.tsx, table.tsx, json.tsx, parameter-select.tsx, form.tsx, iframe.tsx, gauge.tsx, sankey.tsx, sunburst.tsx, radar.tsx, treemap.tsx chart-renderer.tsx changes: - Removed the switch statement (was 540+ lines, now 130 lines total) - Removed all dynamic imports for individual chart components - Removed unused type imports - Only delegates to pluginRegistry.get(type) now; unknown types return a helpful "Unknown chart type" error state Component wiring: - Plugin adapter receives { data, settings, stylingRules, paramValues, colorScales, onClick, onChartClick, connectionId, widgetId, resultId, query, autoFit, clickableColumns, colorThresholds } from the renderer - Each plugin picks the props it needs and calls the underlying component - handleEChartsClick (ECharts wrapper) vs onChartClick (raw row callback) are both passed — plugins pick the right one (e.g. map uses raw, bar uses ECharts wrapper) Special handling: - Graph: dual path (GraphExplorationWrapper for widgets with connectionId, GraphChart for previews) preserved - Table/ParameterSelect/Form: use app-local components (imported from @/components/*) rather than @neoboard/components All tests pass (1877) + TypeScript clean + ESLint clean. Related: #220, epic #221 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/chart-renderer.tsx | 389 +------------------------- app/src/plugins/form.tsx | 46 +++ app/src/plugins/gauge.tsx | 75 +++++ app/src/plugins/graph.tsx | 102 +++++++ app/src/plugins/iframe.tsx | 39 +++ app/src/plugins/index.ts | 40 ++- app/src/plugins/json.tsx | 43 +++ app/src/plugins/map.tsx | 85 ++++++ app/src/plugins/parameter-select.tsx | 74 +++++ app/src/plugins/radar.tsx | 69 +++++ app/src/plugins/sankey.tsx | 74 +++++ app/src/plugins/single-value.tsx | 90 ++++++ app/src/plugins/sunburst.tsx | 72 +++++ app/src/plugins/table.tsx | 75 +++++ app/src/plugins/treemap.tsx | 75 +++++ 15 files changed, 967 insertions(+), 381 deletions(-) create mode 100644 app/src/plugins/form.tsx create mode 100644 app/src/plugins/gauge.tsx create mode 100644 app/src/plugins/graph.tsx create mode 100644 app/src/plugins/iframe.tsx create mode 100644 app/src/plugins/json.tsx create mode 100644 app/src/plugins/map.tsx create mode 100644 app/src/plugins/parameter-select.tsx create mode 100644 app/src/plugins/radar.tsx create mode 100644 app/src/plugins/sankey.tsx create mode 100644 app/src/plugins/single-value.tsx create mode 100644 app/src/plugins/sunburst.tsx create mode 100644 app/src/plugins/table.tsx create mode 100644 app/src/plugins/treemap.tsx diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index 103d39da..be2c8f60 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -1,94 +1,16 @@ "use client"; import React, { useMemo } from "react"; -import dynamic from "next/dynamic"; import { AlertCircle } from "lucide-react"; -import { normalizeValue } from "@/lib/normalize-value"; import type { ChartType } from "@/lib/chart-registry"; import { pluginRegistry } from "@/plugins"; import { ChartErrorBoundary } from "./chart-error-boundary"; -import { - Skeleton, - EmptyState, - JsonViewer, - IframeWidget, -} from "@neoboard/components"; - -// 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) => ({ - default: m.SingleValueChart, - })), - { ssr: false, loading: () => }, -); +import { EmptyState } from "@neoboard/components"; import type { - GraphNode, - GraphEdge, - MapMarker, EChartsClickEvent, StylingRule, - GaugeDataPoint, - SankeyChartData, - SunburstDataItem, - RadarChartData, - TreemapDataItem, ColorScaleConfig, } from "@neoboard/components"; -import { ParameterWidgetRenderer } from "@/components/parameter-widget-renderer"; -import type { ParameterType } from "@/stores/parameter-store"; -import { GraphExplorationWrapper } from "./graph-exploration-wrapper"; -import { TableRenderer } from "./table-renderer"; - -// Form widget — direct import (client component, no SSR concern since chart-renderer is "use client") -import { FormWidgetRenderer } from "./form-widget-renderer"; - -// Lazy-load GraphChart so NVL (WebGL) is only bundled when a graph widget is rendered -const GraphChart = dynamic( - () => - import("@neoboard/components").then((mod) => ({ default: mod.GraphChart })), - { ssr: false, loading: () => }, -); - -// Dynamically import MapChart to avoid SSR issues with Leaflet -const MapChart = dynamic( - () => - import("@neoboard/components").then((mod) => ({ default: mod.MapChart })), - { - ssr: false, - loading: () => ( -
-
-
- ), - }, -); - -// New ECharts chart types — loaded client-side only -const GaugeChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.GaugeChart })), - { ssr: false, loading: () => }, -); -const SankeyChart = dynamic( - () => - import("@neoboard/components").then((m) => ({ default: m.SankeyChart })), - { ssr: false, loading: () => }, -); -const SunburstChart = dynamic( - () => - import("@neoboard/components").then((m) => ({ default: m.SunburstChart })), - { ssr: false, loading: () => }, -); -const RadarChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.RadarChart })), - { ssr: false, loading: () => }, -); -const TreemapChart = dynamic( - () => - import("@neoboard/components").then((m) => ({ default: m.TreemapChart })), - { ssr: false, loading: () => }, -); /** Styling-related props grouped together. */ export interface ChartStylingProps { @@ -171,9 +93,9 @@ function ChartRendererInner({ }; }, [onChartClick, data]); - // Plugin-driven rendering: check the plugin registry first. Charts - // registered as plugins go through this path; the switch below is the - // fallback for charts still using the legacy hard-coded dispatch. + // Plugin-driven rendering — every chart type is registered as a plugin + // in app/src/plugins/. The plugin's component receives the full set of + // props; it picks the ones it needs. const plugin = pluginRegistry.get(type); if (plugin) { const PluginComponent = plugin.component; @@ -185,6 +107,7 @@ function ChartRendererInner({ paramValues={paramValues} colorScales={colorScales} onClick={handleEChartsClick} + onChartClick={onChartClick} connectionId={connectionId} widgetId={widgetId} resultId={resultId} @@ -196,298 +119,12 @@ function ChartRendererInner({ ); } - switch (type) { - case "bar": - case "line": - case "pie": - 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; - const val = - typeof raw === "number" || typeof raw === "string" - ? raw - : (normalizeValue(raw) ?? String(raw)); - return ( - - ); - } - - case "graph": { - const graphData = (data ?? { nodes: [], edges: [] }) as { - nodes: GraphNode[]; - edges: GraphEdge[]; - }; - if (connectionId) { - return ( - - ); - } - return ( - { - if (ids.length) onChartClick({ nodeId: ids[0] }); - } - : undefined - } - autoFit={autoFit} - stylingRules={stylingRules} - paramValues={paramValues} - /> - ); - } - - case "map": { - const markers = (data ?? []) as MapMarker[]; - return ( - - onChartClick({ - id: m.id, - label: m.label, - lat: m.lat, - lng: m.lng, - }) - : undefined - } - stylingRules={stylingRules} - paramValues={paramValues} - /> - ); - } - - case "table": - return ( - - onChartClick({ - _clickedColumn: info.column, - _clickedValue: info.value, - }) - : undefined - } - clickableColumns={clickableColumns} - stylingRules={stylingRules} - paramValues={paramValues} - colorScales={colorScales} - /> - ); - - case "parameter-select": { - const pName = settings.parameterName as string | undefined; - if (!pName) { - return ( - - ); - } - return ( -
- -
- ); - } - - case "json": - return ( -
- -
- ); - - case "form": - return ( - - ); - - case "iframe": - return ( - - ); - - case "gauge": - return ( - - ); - - case "sankey": { - const sankeyData = (data as SankeyChartData) ?? { nodes: [], links: [] }; - return ( - - ); - } - - case "sunburst": - return ( - - ); - - case "radar": { - const radarData = (data as RadarChartData) ?? { - indicators: [], - series: [], - }; - return ( - - ); - } - - case "treemap": - return ( - - ); - - default: - return ( - } - title="Unknown chart type" - description={`Chart type "${type}" is not supported.`} - className="py-6" - /> - ); - } + return ( + } + title="Unknown chart type" + description={`Chart type "${type}" is not supported.`} + className="py-6" + /> + ); } diff --git a/app/src/plugins/form.tsx b/app/src/plugins/form.tsx new file mode 100644 index 00000000..d84ea3ff --- /dev/null +++ b/app/src/plugins/form.tsx @@ -0,0 +1,46 @@ +/** + * Form widget plugin. + * + * Renders a user-editable form that executes a write query on submit. + * No data transform — the form-widget-renderer handles its own state. + */ + +import { FormWidgetRenderer } from "@/components/form-widget-renderer"; +import { defineChartPlugin } from "./registry"; + +interface PluginComponentProps { + settings: Record; + connectionId?: string; + query?: string; +} + +function FormPluginComponent({ + settings, + connectionId, + query, +}: PluginComponentProps) { + return ( + + ); +} + +export const formPlugin = defineChartPlugin({ + type: "form", + label: "Form", + component: FormPluginComponent, + transform: () => [], + compatibleWith: ["neo4j", "postgresql"], + capabilities: { + supportsClickAction: true, + supportsStyling: false, + isECharts: false, + requiresQuery: false, + }, + queryHint: + "Write query executed on submit. Use $fieldName parameters to reference\n" + + "form fields. Example: CREATE (u:User { name: $name, email: $email })", +}); diff --git a/app/src/plugins/gauge.tsx b/app/src/plugins/gauge.tsx new file mode 100644 index 00000000..3c9d4ba1 --- /dev/null +++ b/app/src/plugins/gauge.tsx @@ -0,0 +1,75 @@ +/** + * Gauge chart plugin. + * + * Radial gauge displaying a single value against a configurable range. + * Supports click actions and rule-based styling. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + GaugeDataPoint, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const GaugeChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.GaugeChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; +} + +function GaugePluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, +}: PluginComponentProps) { + return ( + + ); +} + +export const gaugePlugin = defineChartPlugin({ + type: "gauge", + label: "Gauge", + component: GaugePluginComponent, + transform: chartRegistry.gauge.transform, + transformWithMapping: chartRegistry.gauge.transformWithMapping, + validate: chartRegistry.gauge.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Gauge Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return 1-2 columns: first = numeric value, optional second = name/label.\n" + + "Example: RETURN progress AS value, 'Completion' AS name", +}); diff --git a/app/src/plugins/graph.tsx b/app/src/plugins/graph.tsx new file mode 100644 index 00000000..30af9ad5 --- /dev/null +++ b/app/src/plugins/graph.tsx @@ -0,0 +1,102 @@ +/** + * Graph widget plugin. + * + * Renders Neo4j nodes/relationships as an interactive force-directed graph + * using Neo4j's NVL library. When a connection is available, uses the + * GraphExplorationWrapper for expand-on-click exploration. Otherwise falls + * back to the plain GraphChart component. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { GraphNode, GraphEdge, StylingRule } from "@neoboard/components"; +import { GraphExplorationWrapper } from "@/components/graph-exploration-wrapper"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +// NVL (WebGL) is heavy — lazy load so it's only bundled when a graph widget renders. +const GraphChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.GraphChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onChartClick?: (point: Record) => void; + connectionId?: string; + widgetId?: string; + resultId?: string; + autoFit?: boolean; +} + +function GraphPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onChartClick, + connectionId, + widgetId, + resultId, + autoFit, +}: PluginComponentProps) { + const graphData = (data ?? { nodes: [], edges: [] }) as { + nodes: GraphNode[]; + edges: GraphEdge[]; + }; + if (connectionId) { + return ( + + ); + } + return ( + { + if (ids.length) onChartClick({ nodeId: ids[0] }); + } + : undefined + } + autoFit={autoFit} + stylingRules={stylingRules} + paramValues={paramValues} + /> + ); +} + +export const graphPlugin = defineChartPlugin({ + type: "graph", + label: "Graph", + component: GraphPluginComponent, + transform: chartRegistry.graph.transform, + transformWithMapping: chartRegistry.graph.transformWithMapping, + validate: chartRegistry.graph.validate, + compatibleWith: ["neo4j"], + stylingTargets: [{ value: "color", label: "Node Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: false, + requiresQuery: true, + }, + queryHint: + "Return Neo4j nodes, relationships, or paths.\n" + + "Example: MATCH (n)-[r]->(m) RETURN n, r, m", +}); diff --git a/app/src/plugins/iframe.tsx b/app/src/plugins/iframe.tsx new file mode 100644 index 00000000..d42eb3c5 --- /dev/null +++ b/app/src/plugins/iframe.tsx @@ -0,0 +1,39 @@ +/** + * Iframe widget plugin. + * + * Embeds an external URL in the dashboard. No query, no data transform. + */ + +import { IframeWidget } from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; + +interface PluginComponentProps { + settings: Record; +} + +function IframePluginComponent({ settings }: PluginComponentProps) { + return ( + + ); +} + +export const iframePlugin = defineChartPlugin({ + type: "iframe", + label: "iFrame", + component: IframePluginComponent, + transform: () => null, + compatibleWith: ["neo4j", "postgresql"], + capabilities: { + supportsClickAction: false, + supportsStyling: false, + isECharts: false, + requiresQuery: false, + }, + queryHint: + "Iframe widgets embed an external URL — no query required. " + + "Use the URL field in the widget settings.", +}); diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index d64bfd9e..226f5b3d 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,11 +8,10 @@ * To add a new chart plugin: * 1. Create `app/src/plugins/your-chart.ts` that exports a plugin * via `defineChartPlugin({ ... })` - * 2. Add `registerPluginFromFile("./your-chart")` here + * 2. Add the plugin to the BUILT_IN_PLUGINS array below * - * During the v1.1 plugin migration, charts are moved from the legacy - * switch statement in chart-renderer.tsx into this registry one by one. - * Charts not yet migrated continue to work via the switch fallback. + * As of PR 4, all chart types are registered here — chart-renderer's + * switch statement has been removed and plugin lookup is the only path. */ import { pluginRegistry } from "./registry"; @@ -20,8 +19,39 @@ import { markdownPlugin } from "./markdown"; import { barPlugin } from "./bar"; import { linePlugin } from "./line"; import { piePlugin } from "./pie"; +import { singleValuePlugin } from "./single-value"; +import { graphPlugin } from "./graph"; +import { mapPlugin } from "./map"; +import { tablePlugin } from "./table"; +import { parameterSelectPlugin } from "./parameter-select"; +import { jsonPlugin } from "./json"; +import { formPlugin } from "./form"; +import { iframePlugin } from "./iframe"; +import { gaugePlugin } from "./gauge"; +import { sankeyPlugin } from "./sankey"; +import { sunburstPlugin } from "./sunburst"; +import { radarPlugin } from "./radar"; +import { treemapPlugin } from "./treemap"; -const BUILT_IN_PLUGINS = [markdownPlugin, barPlugin, linePlugin, piePlugin]; +const BUILT_IN_PLUGINS = [ + markdownPlugin, + barPlugin, + linePlugin, + piePlugin, + singleValuePlugin, + graphPlugin, + mapPlugin, + tablePlugin, + parameterSelectPlugin, + jsonPlugin, + formPlugin, + iframePlugin, + gaugePlugin, + sankeyPlugin, + sunburstPlugin, + radarPlugin, + treemapPlugin, +]; // Idempotent registration — the first import of this module registers // plugins; subsequent imports are no-ops thanks to Node's module cache. diff --git a/app/src/plugins/json.tsx b/app/src/plugins/json.tsx new file mode 100644 index 00000000..ff317d41 --- /dev/null +++ b/app/src/plugins/json.tsx @@ -0,0 +1,43 @@ +/** + * JSON viewer widget plugin. + * + * Collapsible JSON tree view for raw query results. No click action, + * no styling — this is a read-only inspector widget. + */ + +import { JsonViewer } from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +interface PluginComponentProps { + data: unknown; + settings: Record; +} + +function JsonPluginComponent({ data, settings }: PluginComponentProps) { + return ( +
+ +
+ ); +} + +export const jsonPlugin = defineChartPlugin({ + type: "json", + label: "JSON Viewer", + component: JsonPluginComponent, + transform: chartRegistry.json.transform, + transformWithMapping: chartRegistry.json.transformWithMapping, + compatibleWith: ["neo4j", "postgresql"], + capabilities: { + supportsClickAction: false, + supportsStyling: false, + isECharts: false, + requiresQuery: true, + }, + queryHint: + "Returns any query result as a collapsible JSON tree. Any shape works.", +}); diff --git a/app/src/plugins/map.tsx b/app/src/plugins/map.tsx new file mode 100644 index 00000000..c12a65c9 --- /dev/null +++ b/app/src/plugins/map.tsx @@ -0,0 +1,85 @@ +/** + * Map chart plugin. + * + * Leaflet-based map with markers. Supports click actions and rule-based + * styling. Heavy dependency — lazy loaded only when a map widget appears. + */ + +import dynamic from "next/dynamic"; +import type { MapMarker, StylingRule } from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +// Leaflet relies on window/document — must be loaded client-side only. +const MapChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.MapChart })), + { + ssr: false, + loading: () => ( +
+
+
+ ), + }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onChartClick?: (point: Record) => void; +} + +function MapPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onChartClick, +}: PluginComponentProps) { + const markers = (data ?? []) as MapMarker[]; + return ( + + onChartClick({ + id: m.id, + label: m.label, + lat: m.lat, + lng: m.lng, + }) + : undefined + } + stylingRules={stylingRules} + paramValues={paramValues} + /> + ); +} + +export const mapPlugin = defineChartPlugin({ + type: "map", + label: "Map", + component: MapPluginComponent, + transform: chartRegistry.map.transform, + transformWithMapping: chartRegistry.map.transformWithMapping, + validate: chartRegistry.map.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Marker Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: false, + requiresQuery: true, + }, + queryHint: + "Return columns with latitude and longitude (names matching lat/lng/lon).\n" + + "Example: RETURN name, latitude, longitude", +}); diff --git a/app/src/plugins/parameter-select.tsx b/app/src/plugins/parameter-select.tsx new file mode 100644 index 00000000..619c7fdd --- /dev/null +++ b/app/src/plugins/parameter-select.tsx @@ -0,0 +1,74 @@ +/** + * Parameter selector widget plugin. + * + * Interactive input widget that exposes a dashboard parameter — dropdown, + * text, date picker, number range, etc. Drives other widgets via the + * parameter store. No data transform — the parameter-widget-renderer + * handles its own option fetching. + */ + +import { EmptyState } from "@neoboard/components"; +import { ParameterWidgetRenderer } from "@/components/parameter-widget-renderer"; +import type { ParameterType } from "@/stores/parameter-store"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +interface PluginComponentProps { + settings: Record; + connectionId?: string; + widgetId?: string; +} + +function ParameterSelectPluginComponent({ + settings, + connectionId, + widgetId, +}: PluginComponentProps) { + const pName = settings.parameterName as string | undefined; + if (!pName) { + return ( + + ); + } + return ( +
+ +
+ ); +} + +export const parameterSelectPlugin = defineChartPlugin({ + type: "parameter-select", + label: "Parameter Selector", + component: ParameterSelectPluginComponent, + transform: chartRegistry["parameter-select"].transform, + transformWithMapping: chartRegistry["parameter-select"].transformWithMapping, + compatibleWith: ["neo4j", "postgresql"], + capabilities: { + supportsClickAction: false, + supportsStyling: false, + isECharts: false, + requiresQuery: false, + }, + queryHint: + "Optional seed query — return a single column of values to populate the\n" + + "selector options. Example: RETURN DISTINCT category FROM items", +}); diff --git a/app/src/plugins/radar.tsx b/app/src/plugins/radar.tsx new file mode 100644 index 00000000..edca96a4 --- /dev/null +++ b/app/src/plugins/radar.tsx @@ -0,0 +1,69 @@ +/** + * Radar chart plugin. + * + * Multi-axis chart comparing several quantitative variables. Supports + * rule-based styling but not click actions. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { RadarChartData, StylingRule } from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const RadarChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.RadarChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; +} + +function RadarPluginComponent({ + data, + settings, + stylingRules, + paramValues, +}: PluginComponentProps) { + const radarData = (data as RadarChartData) ?? { + indicators: [], + series: [], + }; + return ( + + ); +} + +export const radarPlugin = defineChartPlugin({ + type: "radar", + label: "Radar", + component: RadarPluginComponent, + transform: chartRegistry.radar.transform, + transformWithMapping: chartRegistry.radar.transformWithMapping, + validate: chartRegistry.radar.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Area Color" }], + capabilities: { + supportsClickAction: false, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return either long-format (indicator, value, [series], [max]) or\n" + + "wide-format (one column per indicator). Example: RETURN axis, score, series", +}); diff --git a/app/src/plugins/sankey.tsx b/app/src/plugins/sankey.tsx new file mode 100644 index 00000000..ce1f558f --- /dev/null +++ b/app/src/plugins/sankey.tsx @@ -0,0 +1,74 @@ +/** + * Sankey chart plugin. + * + * Flow diagram showing how items move from one category to another. + * Supports click actions and rule-based styling. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + SankeyChartData, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const SankeyChart = dynamic( + () => + import("@neoboard/components").then((m) => ({ default: m.SankeyChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; +} + +function SankeyPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, +}: PluginComponentProps) { + const sankeyData = (data as SankeyChartData) ?? { nodes: [], links: [] }; + return ( + + ); +} + +export const sankeyPlugin = defineChartPlugin({ + type: "sankey", + label: "Sankey", + component: SankeyPluginComponent, + transform: chartRegistry.sankey.transform, + transformWithMapping: chartRegistry.sankey.transformWithMapping, + validate: chartRegistry.sankey.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Link Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return 3 columns: source, target, value.\n" + + "Example: RETURN fromNode AS source, toNode AS target, flow AS value", +}); diff --git a/app/src/plugins/single-value.tsx b/app/src/plugins/single-value.tsx new file mode 100644 index 00000000..b53b77f3 --- /dev/null +++ b/app/src/plugins/single-value.tsx @@ -0,0 +1,90 @@ +/** + * Single-value chart plugin. + * + * Displays a single scalar value (number or string) with optional prefix, + * suffix, title, and number formatting. Supports rule-based styling + * (color / backgroundColor) but no click action. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { StylingRule } from "@neoboard/components"; +import { normalizeValue } from "@/lib/normalize-value"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const SingleValueChart = dynamic( + () => + import("@neoboard/components").then((m) => ({ + default: m.SingleValueChart, + })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + colorThresholds?: string; +} + +function SingleValuePluginComponent({ + data, + settings, + stylingRules, + paramValues, + colorThresholds, +}: PluginComponentProps) { + const raw = data ?? 0; + const val = + typeof raw === "number" || typeof raw === "string" + ? raw + : (normalizeValue(raw) ?? String(raw)); + return ( + + ); +} + +export const singleValuePlugin = defineChartPlugin({ + type: "single-value", + label: "Single Value", + component: SingleValuePluginComponent, + transform: chartRegistry["single-value"].transform, + transformWithMapping: chartRegistry["single-value"].transformWithMapping, + validate: chartRegistry["single-value"].validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [ + { value: "color", label: "Text Color" }, + { value: "backgroundColor", label: "Background Color" }, + ], + capabilities: { + supportsClickAction: false, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return 1 column with a scalar value (number or string).\n" + + "Example: RETURN count(*) AS total", +}); diff --git a/app/src/plugins/sunburst.tsx b/app/src/plugins/sunburst.tsx new file mode 100644 index 00000000..bf0be2fb --- /dev/null +++ b/app/src/plugins/sunburst.tsx @@ -0,0 +1,72 @@ +/** + * Sunburst chart plugin. + * + * Hierarchical radial chart — shows nested categories as concentric rings. + * Supports click actions and rule-based styling. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + SunburstDataItem, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const SunburstChart = dynamic( + () => + import("@neoboard/components").then((m) => ({ default: m.SunburstChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; +} + +function SunburstPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, +}: PluginComponentProps) { + return ( + + ); +} + +export const sunburstPlugin = defineChartPlugin({ + type: "sunburst", + label: "Sunburst", + component: SunburstPluginComponent, + transform: chartRegistry.sunburst.transform, + transformWithMapping: chartRegistry.sunburst.transformWithMapping, + validate: chartRegistry.sunburst.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Segment Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return hierarchical data — either pre-nested with children, or flat rows\n" + + "with name/parent/value columns. Example: RETURN name, parent, value", +}); diff --git a/app/src/plugins/table.tsx b/app/src/plugins/table.tsx new file mode 100644 index 00000000..6fb99360 --- /dev/null +++ b/app/src/plugins/table.tsx @@ -0,0 +1,75 @@ +/** + * Table widget plugin. + * + * Auto-paginated data grid with sorting, filtering, and per-cell styling. + * Supports click actions (per-cell) and rule-based styling (backgroundColor, + * textColor) plus gradient color scales. + */ + +import type { StylingRule, ColorScaleConfig } from "@neoboard/components"; +import { TableRenderer } from "@/components/table-renderer"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + colorScales?: ColorScaleConfig[]; + clickableColumns?: string[]; + onChartClick?: (point: Record) => void; +} + +function TablePluginComponent({ + data, + settings, + stylingRules, + paramValues, + colorScales, + clickableColumns, + onChartClick, +}: PluginComponentProps) { + return ( + + onChartClick({ + _clickedColumn: info.column, + _clickedValue: info.value, + }) + : undefined + } + clickableColumns={clickableColumns} + stylingRules={stylingRules} + paramValues={paramValues} + colorScales={colorScales} + /> + ); +} + +export const tablePlugin = defineChartPlugin({ + type: "table", + label: "Data Table", + component: TablePluginComponent, + transform: chartRegistry.table.transform, + transformWithMapping: chartRegistry.table.transformWithMapping, + validate: chartRegistry.table.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [ + { value: "backgroundColor", label: "Background Color" }, + { value: "textColor", label: "Text Color" }, + ], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: false, + requiresQuery: true, + }, + queryHint: + "Return any tabular data — each column becomes a sortable grid column.\n" + + "Example: RETURN name, created_at, status FROM users", +}); diff --git a/app/src/plugins/treemap.tsx b/app/src/plugins/treemap.tsx new file mode 100644 index 00000000..a274a97d --- /dev/null +++ b/app/src/plugins/treemap.tsx @@ -0,0 +1,75 @@ +/** + * Treemap chart plugin. + * + * Hierarchical rectangles — each block's area is proportional to its value. + * Supports click actions, drilldown, and rule-based styling. + */ + +import dynamic from "next/dynamic"; +import { Skeleton } from "@neoboard/components"; +import type { + TreemapDataItem, + EChartsClickEvent, + StylingRule, +} from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; +import { chartRegistry } from "@/lib/chart-registry"; + +const TreemapChart = dynamic( + () => + import("@neoboard/components").then((m) => ({ default: m.TreemapChart })), + { ssr: false, loading: () => }, +); + +interface PluginComponentProps { + data: unknown; + settings: Record; + stylingRules?: StylingRule[]; + paramValues?: Record; + onClick?: (e: EChartsClickEvent) => void; +} + +function TreemapPluginComponent({ + data, + settings, + stylingRules, + paramValues, + onClick, +}: PluginComponentProps) { + return ( + + ); +} + +export const treemapPlugin = defineChartPlugin({ + type: "treemap", + label: "Treemap", + component: TreemapPluginComponent, + transform: chartRegistry.treemap.transform, + transformWithMapping: chartRegistry.treemap.transformWithMapping, + validate: chartRegistry.treemap.validate, + compatibleWith: ["neo4j", "postgresql"], + stylingTargets: [{ value: "color", label: "Block Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return hierarchical data — either pre-nested with children, or flat rows\n" + + "with name/parent/value columns. Example: RETURN name, parent, value", +}); From d7972f57c48d7ebee719a40d87e0242b10c93a5c Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 01:27:30 +0200 Subject: [PATCH 2/4] fix: switch from Turbopack to webpack for dev/E2E (#220) Turbopack (Next.js 16 default) doesn't correctly handle CJS require() for transpiled monorepo packages, causing createConnectionModule to be undefined at runtime. This breaks all database queries. Changes: - app/package.json: `next dev --webpack` instead of `--turbopack` - app/e2e/global-setup.ts: explicit `--webpack` for local E2E dev mode - app/next.config.ts: add @neoboard/connection to transpilePackages, remove it from serverExternalPackages (it's an internal package) - app/src/lib/connection-adapter.ts: clean require() with docs E2E tests now pass locally (table, single-value, JSON, Neo4j bar chart verified). The --webpack flag can be removed once Turbopack fixes transpilePackages for CJS monorepo packages. See: https://github.com/vercel/next.js/issues/85316 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/e2e/global-setup.ts | 4 +++- app/next.config.ts | 9 ++++++-- app/package.json | 2 +- app/src/lib/connection-adapter.ts | 34 +++++++++++++++---------------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/app/e2e/global-setup.ts b/app/e2e/global-setup.ts index 9f85c812..58d7ecfb 100644 --- a/app/e2e/global-setup.ts +++ b/app/e2e/global-setup.ts @@ -249,7 +249,9 @@ export default async function globalSetup() { `⏳ Starting Next.js ${serverCmd} server on port ${serverPort}...`, ); const args = ["next", serverCmd, "--port", String(serverPort)]; - if (serverCmd === "dev") args.push("--turbopack"); + // Use webpack explicitly — Turbopack (Next.js 16 default) doesn't correctly + // resolve CJS/ESM interop for the @neoboard/connection package at runtime. + if (serverCmd === "dev") args.push("--webpack"); const server = spawn("npx", args, { cwd: appDir, stdio: "pipe", diff --git a/app/next.config.ts b/app/next.config.ts index 5d9aef6b..963a7024 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -24,8 +24,13 @@ const nextConfig: NextConfig = { // Enable source maps in production for E2E coverage collection (nextcov). productionBrowserSourceMaps: process.env.E2E_COVERAGE === "1", outputFileTracingRoot: resolve(import.meta.dirname, ".."), - transpilePackages: ["@neoboard/components"], - serverExternalPackages: ["connection", "postgres", "pg"], + transpilePackages: ["@neoboard/components", "@neoboard/connection"], + serverExternalPackages: [ + "postgres", + "pg", + "neo4j-driver", + "neo4j-driver-core", + ], webpack: (config, { isServer }) => { if (isServer) { // The instrumentation file is compiled in a separate webpack pass that does diff --git a/app/package.json b/app/package.json index 94eb47d1..018ca7d1 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", "lint": "next lint", diff --git a/app/src/lib/connection-adapter.ts b/app/src/lib/connection-adapter.ts index 6ce7a9c1..734da85a 100644 --- a/app/src/lib/connection-adapter.ts +++ b/app/src/lib/connection-adapter.ts @@ -1,27 +1,27 @@ /** - * Thin adapter that re-exports connection package symbols. + * Thin adapter that re-exports connection package symbols using CJS require(). * - * The connection package ships as TypeScript source — its type definitions - * reference internal neo4j-driver-core paths that don't resolve under the - * app's strict tsconfig. We use Node's `createRequire` to load the - * modules at runtime, keeping the type checker opaque to the package - * internals while ensuring Turbopack doesn't try to bundle them. + * The connection package (@neoboard/connection) ships as TypeScript source. + * It's listed in `transpilePackages` in next.config.ts so webpack compiles + * it as part of the app build — no separate build step needed. + * + * We use require() (not ESM import) to keep the type checker opaque to the + * connection package's internal types which reference neo4j-driver-core + * paths that don't resolve under the app's strict tsconfig. + * + * IMPORTANT: This requires `--webpack` flag (not Turbopack) because + * Turbopack doesn't correctly handle CJS require() for transpiled packages. + * See: https://github.com/vercel/next.js/issues/85316 * * Isolating the require() calls here also makes query-executor.ts fully * mockable in Vitest (vi.mock("./connection-adapter", …)). */ -import { createRequire } from "node:module"; - -const require_ = createRequire(import.meta.url); - -/* eslint-disable @typescript-eslint/no-explicit-any */ -const factory: any = require_("@neoboard/connection/src/adapters/factory"); -const interfaces: any = require_( - "@neoboard/connection/src/generalized/interfaces", -); -const config: any = require_("@neoboard/connection/src/ConnectionModuleConfig"); -/* eslint-enable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ +const factory: any = require("@neoboard/connection/src/adapters/factory"); +const interfaces: any = require("@neoboard/connection/src/generalized/interfaces"); +const config: any = require("@neoboard/connection/src/ConnectionModuleConfig"); +/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ export const createConnectionModule: ( type: number, From 262e4a30ca34b34d9a1651e6f5368a051b47fe65 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 11:28:06 +0200 Subject: [PATCH 3/4] ci: re-trigger flaky graph E2E From a87a3dbc116b90b1abe42288a35df60e146fa3b6 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 11:40:22 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20unify=20plugin=20callback=20?= =?UTF-8?q?=E2=80=94=20single=20onChartClick,=20shared=20PluginProps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: plugins/utils.ts — PluginProps type + useEChartsClick() hook - All 16 plugins use PluginProps (no more per-plugin interfaces) - chart-renderer passes only onChartClick (removed handleEChartsClick) - ECharts plugins wrap onChartClick via useEChartsClick() internally - Removed unused useMemo, EChartsClickEvent imports from renderer Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/chart-renderer.tsx | 31 +++------------ app/src/plugins/bar.tsx | 27 ++++--------- app/src/plugins/form.tsx | 13 +------ app/src/plugins/gauge.tsx | 22 +++-------- app/src/plugins/graph.tsx | 17 ++------ app/src/plugins/iframe.tsx | 7 +--- app/src/plugins/json.tsx | 8 +--- app/src/plugins/line.tsx | 23 +++-------- app/src/plugins/map.tsx | 13 ++----- app/src/plugins/markdown.tsx | 7 +--- app/src/plugins/parameter-select.tsx | 9 +---- app/src/plugins/pie.tsx | 23 +++-------- app/src/plugins/radar.tsx | 12 ++---- app/src/plugins/sankey.tsx | 22 +++-------- app/src/plugins/single-value.tsx | 13 ++----- app/src/plugins/sunburst.tsx | 22 +++-------- app/src/plugins/table.tsx | 17 ++------ app/src/plugins/treemap.tsx | 22 +++-------- app/src/plugins/utils.ts | 56 +++++++++++++++++++++++++++ 19 files changed, 131 insertions(+), 233 deletions(-) create mode 100644 app/src/plugins/utils.ts diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index be2c8f60..0cb56e44 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -1,16 +1,12 @@ "use client"; -import React, { useMemo } from "react"; +import React from "react"; import { AlertCircle } from "lucide-react"; import type { ChartType } from "@/lib/chart-registry"; import { pluginRegistry } from "@/plugins"; import { ChartErrorBoundary } from "./chart-error-boundary"; import { EmptyState } from "@neoboard/components"; -import type { - EChartsClickEvent, - StylingRule, - ColorScaleConfig, -} from "@neoboard/components"; +import type { StylingRule, ColorScaleConfig } from "@neoboard/components"; /** Styling-related props grouped together. */ export interface ChartStylingProps { @@ -74,31 +70,15 @@ function ChartRendererInner({ ? settings.colorThresholds : undefined; - const handleEChartsClick = useMemo(() => { - if (!onChartClick) return undefined; - return (e: EChartsClickEvent) => { - // Enrich the click point with the original data row so that - // column-name source fields (e.g. "revenue") resolve correctly - // in click action rules — not just ECharts built-in fields. - const row = Array.isArray(data) - ? (data[e.dataIndex] as Record | undefined) - : undefined; - onChartClick({ - ...(row ?? {}), - name: e.name, - value: e.value, - seriesName: e.seriesName, - dataIndex: e.dataIndex, - }); - }; - }, [onChartClick, data]); - // Plugin-driven rendering — every chart type is registered as a plugin // in app/src/plugins/. The plugin's component receives the full set of // props; it picks the ones it needs. const plugin = pluginRegistry.get(type); if (plugin) { const PluginComponent = plugin.component; + // Pass onChartClick (the raw row-level callback) to all plugins. + // ECharts-based plugins wrap it in their own ECharts event handler + // internally — this keeps the plugin contract to ONE callback. return ( 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, + onChartClick, + colorThresholds, stylingRules, paramValues, - onClick, - colorThresholds, -}: PluginComponentProps) { +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); + return ( ; - connectionId?: string; - query?: string; -} - -function FormPluginComponent({ - settings, - connectionId, - query, -}: PluginComponentProps) { +function FormPluginComponent({ settings, connectionId, query }: PluginProps) { return ( import("@neoboard/components").then((m) => ({ default: m.GaugeChart })), { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onClick?: (e: EChartsClickEvent) => void; -} - function GaugePluginComponent({ data, settings, stylingRules, paramValues, - onClick, -}: PluginComponentProps) { + onChartClick, +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); return ( }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onChartClick?: (point: Record) => void; - connectionId?: string; - widgetId?: string; - resultId?: string; - autoFit?: boolean; -} - function GraphPluginComponent({ data, settings, @@ -42,7 +31,7 @@ function GraphPluginComponent({ widgetId, resultId, autoFit, -}: PluginComponentProps) { +}: PluginProps) { const graphData = (data ?? { nodes: [], edges: [] }) as { nodes: GraphNode[]; edges: GraphEdge[]; @@ -75,7 +64,7 @@ function GraphPluginComponent({ : undefined } autoFit={autoFit} - stylingRules={stylingRules} + stylingRules={stylingRules as StylingRule[] | undefined} paramValues={paramValues} /> ); diff --git a/app/src/plugins/iframe.tsx b/app/src/plugins/iframe.tsx index d42eb3c5..455ccba3 100644 --- a/app/src/plugins/iframe.tsx +++ b/app/src/plugins/iframe.tsx @@ -6,12 +6,9 @@ import { IframeWidget } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; +import { type PluginProps } from "./utils"; -interface PluginComponentProps { - settings: Record; -} - -function IframePluginComponent({ settings }: PluginComponentProps) { +function IframePluginComponent({ settings }: PluginProps) { return ( ; -} - -function JsonPluginComponent({ data, settings }: PluginComponentProps) { +function JsonPluginComponent({ data, settings }: PluginProps) { return (
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, + onChartClick, colorThresholds, -}: PluginComponentProps) { +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); // Parse comma-separated rightAxisSeries string into string array const rightAxisSeriesRaw = settings.rightAxisSeries as string | undefined; const rightAxisSeries = rightAxisSeriesRaw @@ -64,7 +53,7 @@ function LinePluginComponent({ endLabel={settings.endLabel as boolean | undefined} referenceLines={settings.referenceLines as string | undefined} colorThresholds={colorThresholds} - stylingRules={stylingRules} + stylingRules={stylingRules as StylingRule[] | undefined} paramValues={paramValues} onClick={onClick} enableDataZoom={settings.enableDataZoom as boolean | undefined} diff --git a/app/src/plugins/map.tsx b/app/src/plugins/map.tsx index c12a65c9..ed3da9d4 100644 --- a/app/src/plugins/map.tsx +++ b/app/src/plugins/map.tsx @@ -9,6 +9,7 @@ import dynamic from "next/dynamic"; import type { MapMarker, StylingRule } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; +import { type PluginProps } from "./utils"; // Leaflet relies on window/document — must be loaded client-side only. const MapChart = dynamic( @@ -23,21 +24,13 @@ const MapChart = dynamic( }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onChartClick?: (point: Record) => void; -} - function MapPluginComponent({ data, settings, stylingRules, paramValues, onChartClick, -}: PluginComponentProps) { +}: PluginProps) { const markers = (data ?? []) as MapMarker[]; return ( ); diff --git a/app/src/plugins/markdown.tsx b/app/src/plugins/markdown.tsx index d87fbabb..47273694 100644 --- a/app/src/plugins/markdown.tsx +++ b/app/src/plugins/markdown.tsx @@ -8,6 +8,7 @@ import { MarkdownWidget } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; +import { type PluginProps } from "./utils"; interface MarkdownWidgetProps { content?: string; @@ -18,11 +19,7 @@ interface MarkdownWidgetProps { * renders the MarkdownWidget. The plugin contract passes the full * settings object to the component as `settings` prop. */ -function MarkdownPluginComponent({ - settings, -}: { - settings: Record; -}) { +function MarkdownPluginComponent({ settings }: PluginProps) { const props: MarkdownWidgetProps = { content: settings.content as string | undefined, }; diff --git a/app/src/plugins/parameter-select.tsx b/app/src/plugins/parameter-select.tsx index 619c7fdd..bf4d3a76 100644 --- a/app/src/plugins/parameter-select.tsx +++ b/app/src/plugins/parameter-select.tsx @@ -12,18 +12,13 @@ import { ParameterWidgetRenderer } from "@/components/parameter-widget-renderer" import type { ParameterType } from "@/stores/parameter-store"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; - -interface PluginComponentProps { - settings: Record; - connectionId?: string; - widgetId?: string; -} +import { type PluginProps } from "./utils"; function ParameterSelectPluginComponent({ settings, connectionId, widgetId, -}: PluginComponentProps) { +}: PluginProps) { const pName = settings.parameterName as string | undefined; if (!pName) { return ( diff --git a/app/src/plugins/pie.tsx b/app/src/plugins/pie.tsx index f0366a3f..05f009ca 100644 --- a/app/src/plugins/pie.tsx +++ b/app/src/plugins/pie.tsx @@ -7,36 +7,25 @@ import dynamic from "next/dynamic"; import { Skeleton } from "@neoboard/components"; -import type { - PieChartDataPoint, - EChartsClickEvent, - StylingRule, -} from "@neoboard/components"; +import type { PieChartDataPoint, StylingRule } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; +import { useEChartsClick, type PluginProps } from "./utils"; 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, + onChartClick, colorThresholds, -}: PluginComponentProps) { +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); return ( import("@neoboard/components").then((m) => ({ default: m.RadarChart })), { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; -} - function RadarPluginComponent({ data, settings, stylingRules, paramValues, -}: PluginComponentProps) { +}: PluginProps) { const radarData = (data as RadarChartData) ?? { indicators: [], series: [], @@ -41,7 +35,7 @@ function RadarPluginComponent({ showLegend={settings.showLegend as boolean | undefined} showValues={settings.showValues as boolean | undefined} colorPalette={settings.colorPalette as string | undefined} - stylingRules={stylingRules} + stylingRules={stylingRules as StylingRule[] | undefined} paramValues={paramValues} colorblindMode={settings.colorblindMode as boolean | undefined} /> diff --git a/app/src/plugins/sankey.tsx b/app/src/plugins/sankey.tsx index ce1f558f..dff40f85 100644 --- a/app/src/plugins/sankey.tsx +++ b/app/src/plugins/sankey.tsx @@ -7,13 +7,10 @@ import dynamic from "next/dynamic"; import { Skeleton } from "@neoboard/components"; -import type { - SankeyChartData, - EChartsClickEvent, - StylingRule, -} from "@neoboard/components"; +import type { SankeyChartData, StylingRule } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; +import { useEChartsClick, type PluginProps } from "./utils"; const SankeyChart = dynamic( () => @@ -21,21 +18,14 @@ const SankeyChart = dynamic( { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onClick?: (e: EChartsClickEvent) => void; -} - function SankeyPluginComponent({ data, settings, stylingRules, paramValues, - onClick, -}: PluginComponentProps) { + onChartClick, +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); const sankeyData = (data as SankeyChartData) ?? { nodes: [], links: [] }; return ( @@ -21,21 +22,13 @@ const SingleValueChart = dynamic( { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - colorThresholds?: string; -} - function SingleValuePluginComponent({ data, settings, stylingRules, paramValues, colorThresholds, -}: PluginComponentProps) { +}: PluginProps) { const raw = data ?? 0; const val = typeof raw === "number" || typeof raw === "string" @@ -60,7 +53,7 @@ function SingleValuePluginComponent({ } decimalPlaces={settings.decimalPlaces as number | undefined} colorThresholds={colorThresholds} - stylingRules={stylingRules} + stylingRules={stylingRules as StylingRule[] | undefined} paramValues={paramValues} /> ); diff --git a/app/src/plugins/sunburst.tsx b/app/src/plugins/sunburst.tsx index bf0be2fb..276912e3 100644 --- a/app/src/plugins/sunburst.tsx +++ b/app/src/plugins/sunburst.tsx @@ -7,13 +7,10 @@ import dynamic from "next/dynamic"; import { Skeleton } from "@neoboard/components"; -import type { - SunburstDataItem, - EChartsClickEvent, - StylingRule, -} from "@neoboard/components"; +import type { SunburstDataItem, StylingRule } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; +import { useEChartsClick, type PluginProps } from "./utils"; const SunburstChart = dynamic( () => @@ -21,21 +18,14 @@ const SunburstChart = dynamic( { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onClick?: (e: EChartsClickEvent) => void; -} - function SunburstPluginComponent({ data, settings, stylingRules, paramValues, - onClick, -}: PluginComponentProps) { + onChartClick, +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); return ( ; - stylingRules?: StylingRule[]; - paramValues?: Record; - colorScales?: ColorScaleConfig[]; - clickableColumns?: string[]; - onChartClick?: (point: Record) => void; -} +import { type PluginProps } from "./utils"; function TablePluginComponent({ data, @@ -29,7 +20,7 @@ function TablePluginComponent({ colorScales, clickableColumns, onChartClick, -}: PluginComponentProps) { +}: PluginProps) { return ( ); } diff --git a/app/src/plugins/treemap.tsx b/app/src/plugins/treemap.tsx index a274a97d..a262c8d5 100644 --- a/app/src/plugins/treemap.tsx +++ b/app/src/plugins/treemap.tsx @@ -7,13 +7,10 @@ import dynamic from "next/dynamic"; import { Skeleton } from "@neoboard/components"; -import type { - TreemapDataItem, - EChartsClickEvent, - StylingRule, -} from "@neoboard/components"; +import type { TreemapDataItem, StylingRule } from "@neoboard/components"; import { defineChartPlugin } from "./registry"; import { chartRegistry } from "@/lib/chart-registry"; +import { useEChartsClick, type PluginProps } from "./utils"; const TreemapChart = dynamic( () => @@ -21,21 +18,14 @@ const TreemapChart = dynamic( { ssr: false, loading: () => }, ); -interface PluginComponentProps { - data: unknown; - settings: Record; - stylingRules?: StylingRule[]; - paramValues?: Record; - onClick?: (e: EChartsClickEvent) => void; -} - function TreemapPluginComponent({ data, settings, stylingRules, paramValues, - onClick, -}: PluginComponentProps) { + onChartClick, +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); return ( ; + stylingRules?: unknown[]; + paramValues?: Record; + colorScales?: unknown[]; + onChartClick?: (point: Record) => void; + connectionId?: string; + widgetId?: string; + resultId?: string; + query?: string; + autoFit?: boolean; + clickableColumns?: string[]; + colorThresholds?: string; +} + +/** + * Creates an ECharts-compatible onClick handler from the plugin's + * raw `onChartClick` callback. Enriches the ECharts event with the + * original data row so column-name source fields resolve correctly. + * + * Usage inside an ECharts plugin component: + * const onClick = useEChartsClick(onChartClick, data); + * + */ +export function useEChartsClick( + onChartClick: ((point: Record) => void) | undefined, + data: unknown, +): ((e: EChartsClickEvent) => void) | undefined { + return useMemo(() => { + if (!onChartClick) return undefined; + return (e: EChartsClickEvent) => { + const row = Array.isArray(data) + ? (data[e.dataIndex] as Record | undefined) + : undefined; + onChartClick({ + ...(row ?? {}), + name: e.name, + value: e.value, + seriesName: e.seriesName, + dataIndex: e.dataIndex, + }); + }; + }, [onChartClick, data]); +}