diff --git a/app/src/components/card-container.tsx b/app/src/components/card-container.tsx index 55de5770..5481cc7c 100644 --- a/app/src/components/card-container.tsx +++ b/app/src/components/card-container.tsx @@ -1,24 +1,14 @@ "use client"; import { useWidgetQuery } from "@/hooks/use-widget-query"; +import { useClickAction } from "@/hooks/use-click-action"; import { resolveCacheOptions } from "@/lib/resolve-cache-options"; import { getChartConfig } from "@/lib/chart-registry"; import type { ColumnMapping } from "@/lib/chart-registry"; -import type { - DashboardWidget, - ClickAction, - StylingConfig, -} from "@/lib/db/schema"; +import type { DashboardWidget, StylingConfig } from "@/lib/db/schema"; import type { ParameterSourceMap } from "@/lib/collect-parameter-names"; import type { ColorScaleConfig } from "@neoboard/components"; -import { - useParameterStore, - useParameterValues, -} from "@/stores/parameter-store"; -import { - resolveClickActions, - deriveClickableColumns, -} from "@/lib/resolve-click-action"; +import { useParameterValues } from "@/stores/parameter-store"; import { scrollAndHighlight } from "@/lib/scroll-to-widget"; import { applyTransforms } from "@/lib/data-transforms"; import type { Transform } from "@/lib/data-transforms"; @@ -165,37 +155,11 @@ export function CardContainer({ parameterSourceMap, }: CardContainerProps) { const chartConfig = getChartConfig(widget.chartType); - - const setParameter = useParameterStore((s) => s.setParameter); - const handleChartClick = useCallback( - (point: Record) => { - const result = resolveClickActions(widget, point); - if (!result) return; - - if (result.setParameter) { - const { parameterName, value, label, sourceField } = - result.setParameter; - setParameter( - parameterName, - value, - label, - sourceField, - "text", - "click-action", - widget.id, - ); - } - - if (result.navigateToPageId) { - onNavigateToPage?.(result.navigateToPageId); - } - }, - [widget, setParameter, onNavigateToPage], + const { handleChartClick, hasClickAction, clickableColumns } = useClickAction( + widget, + onNavigateToPage, ); const ws = widget.settings ?? {}; - const clickAction = ws.clickAction as ClickAction | undefined; - const hasClickAction = !!clickAction; - const clickableColumns = deriveClickableColumns(clickAction); // Cache settings from widget config. Default: cache enabled, 5-min TTL. const enableCache = ws.enableCache !== false; diff --git a/app/src/hooks/__tests__/use-click-action.test.ts b/app/src/hooks/__tests__/use-click-action.test.ts new file mode 100644 index 00000000..64564bf8 --- /dev/null +++ b/app/src/hooks/__tests__/use-click-action.test.ts @@ -0,0 +1,282 @@ +// @vitest-environment jsdom +/** + * Tests for useClickAction hook logic. + * + * Tests the underlying functions (resolveClickActions, deriveClickableColumns) + * directly, and also exercises the hook itself via renderHook to cover the + * useCallback / useParameterStore wiring inside use-click-action.ts. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useParameterStore } from "@/stores/parameter-store"; +import type { ClickAction, DashboardWidget } from "@/lib/db/schema"; +import { + resolveClickActions, + deriveClickableColumns, +} from "@/lib/resolve-click-action"; + +import { useClickAction } from "@/hooks/use-click-action"; + +function resetStore() { + useParameterStore.getState().clearAll(); +} + +// --------------------------------------------------------------------------- +// Module exports +// --------------------------------------------------------------------------- +describe("useClickAction — module exports", () => { + it("exports useClickAction as a function", () => { + expect(typeof useClickAction).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// hasClickAction derivation +// --------------------------------------------------------------------------- +describe("useClickAction — hasClickAction derivation", () => { + it("is true when clickAction exists in settings", () => { + const ws = { + clickAction: { + type: "set-parameter", + parameterMapping: { parameterName: "x", sourceField: "y" }, + }, + }; + const clickAction = ws.clickAction as ClickAction | undefined; + expect(!!clickAction).toBe(true); + }); + + it("is false when clickAction is undefined", () => { + const ws: Record = {}; + const clickAction = ws.clickAction as ClickAction | undefined; + expect(!!clickAction).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveClickActions integration (pure function) +// --------------------------------------------------------------------------- +describe("useClickAction — resolveClickActions integration", () => { + beforeEach(resetStore); + + it("resolveClickActions returns setParameter with parameterName and value", () => { + const widget = { + id: "w1", + settings: { + clickAction: { + type: "set-parameter", + parameterMapping: { parameterName: "region", sourceField: "name" }, + }, + }, + } as unknown as DashboardWidget; + + const result = resolveClickActions(widget, { name: "US", value: 100 }); + if (result?.setParameter) { + expect(result.setParameter.parameterName).toBe("region"); + expect(result.setParameter.value).toBe("US"); + } + }); + + it("resolveClickActions returns null when no click action configured", () => { + const widget = { id: "w2", settings: {} } as unknown as DashboardWidget; + const result = resolveClickActions(widget, { name: "US" }); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// deriveClickableColumns (pure function) +// --------------------------------------------------------------------------- +describe("useClickAction — deriveClickableColumns", () => { + it("returns column list from click action config", () => { + const clickAction: ClickAction = { + type: "set-parameter", + parameterMapping: { parameterName: "id", sourceField: "id" }, + clickableColumns: ["id", "name"], + }; + const cols = deriveClickableColumns(clickAction); + expect(cols).toEqual(["id", "name"]); + }); + + it("returns undefined when no click action", () => { + expect(deriveClickableColumns(undefined)).toBeUndefined(); + }); + + it("returns undefined when clickableColumns not specified", () => { + const clickAction: ClickAction = { + type: "set-parameter", + parameterMapping: { parameterName: "id", sourceField: "name" }, + }; + const cols = deriveClickableColumns(clickAction); + expect(cols).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// parameter store wiring (pure function) +// --------------------------------------------------------------------------- +describe("useClickAction — parameter store wiring", () => { + beforeEach(resetStore); + + it("setParameter stores click-action source correctly", () => { + const { setParameter } = useParameterStore.getState(); + setParameter("region", "US", "US", "name", "text", "click-action", "w1"); + + const entry = useParameterStore.getState().parameters["region"]; + expect(entry.value).toBe("US"); + expect(entry.sourceType).toBe("click-action"); + expect(entry.sourceWidgetId).toBe("w1"); + }); +}); + +// --------------------------------------------------------------------------- +// renderHook tests — exercises the actual hook body +// --------------------------------------------------------------------------- +describe("useClickAction — renderHook", () => { + beforeEach(resetStore); + + it("returns handleChartClick, hasClickAction, and clickableColumns", () => { + const widget = { + id: "w1", + chartType: "bar", + settings: {}, + } as unknown as DashboardWidget; + + const { result } = renderHook(() => useClickAction(widget)); + + expect(typeof result.current.handleChartClick).toBe("function"); + expect(result.current.hasClickAction).toBe(false); + expect(result.current.clickableColumns).toBeUndefined(); + }); + + it("hasClickAction is true when widget has clickAction configured", () => { + const widget = { + id: "w2", + chartType: "bar", + settings: { + clickAction: { + type: "set-parameter", + parameterMapping: { parameterName: "region", sourceField: "name" }, + }, + }, + } as unknown as DashboardWidget; + + const { result } = renderHook(() => useClickAction(widget)); + + expect(result.current.hasClickAction).toBe(true); + }); + + it("clickableColumns derives from clickAction config", () => { + const widget = { + id: "w3", + chartType: "table", + settings: { + clickAction: { + type: "set-parameter", + parameterMapping: { parameterName: "id", sourceField: "id" }, + clickableColumns: ["id", "name"], + }, + }, + } as unknown as DashboardWidget; + + const { result } = renderHook(() => useClickAction(widget)); + + expect(result.current.clickableColumns).toEqual(["id", "name"]); + }); + + it("handleChartClick sets parameter in store via click action", () => { + const widget = { + id: "w4", + chartType: "bar", + settings: { + title: "Revenue Chart", + clickAction: { + type: "set-parameter", + parameterMapping: { parameterName: "region", sourceField: "name" }, + }, + }, + } as unknown as DashboardWidget; + + const { result } = renderHook(() => useClickAction(widget)); + + act(() => { + result.current.handleChartClick({ name: "US", value: 100 }); + }); + + const entry = useParameterStore.getState().parameters["region"]; + expect(entry).toBeDefined(); + expect(entry.value).toBe("US"); + expect(entry.sourceType).toBe("click-action"); + expect(entry.sourceWidgetId).toBe("w4"); + }); + + it("handleChartClick does nothing when no click action configured", () => { + const widget = { + id: "w5", + chartType: "bar", + settings: {}, + } as unknown as DashboardWidget; + + const { result } = renderHook(() => useClickAction(widget)); + + act(() => { + result.current.handleChartClick({ name: "US" }); + }); + + // Store should still be empty + const params = useParameterStore.getState().parameters; + expect(Object.keys(params)).toHaveLength(0); + }); + + it("handleChartClick calls onNavigateToPage when navigate action configured", () => { + const widget = { + id: "w6", + chartType: "bar", + settings: { + clickAction: { + type: "navigate-to-page", + targetPageId: "page-42", + }, + }, + } as unknown as DashboardWidget; + + const onNavigateToPage = vi.fn(); + const { result } = renderHook(() => + useClickAction(widget, onNavigateToPage), + ); + + act(() => { + result.current.handleChartClick({ name: "US" }); + }); + + expect(onNavigateToPage).toHaveBeenCalledWith("page-42"); + }); + + it("handleChartClick sets parameter and navigates for combined action", () => { + const widget = { + id: "w7", + chartType: "bar", + settings: { + title: "Sales", + clickAction: { + type: "set-parameter-and-navigate", + parameterMapping: { parameterName: "city", sourceField: "name" }, + targetPageId: "detail-page", + }, + }, + } as unknown as DashboardWidget; + + const onNavigateToPage = vi.fn(); + const { result } = renderHook(() => + useClickAction(widget, onNavigateToPage), + ); + + act(() => { + result.current.handleChartClick({ name: "Berlin", value: 42 }); + }); + + const entry = useParameterStore.getState().parameters["city"]; + expect(entry).toBeDefined(); + expect(entry.value).toBe("Berlin"); + expect(onNavigateToPage).toHaveBeenCalledWith("detail-page"); + }); +}); diff --git a/app/src/hooks/use-click-action.ts b/app/src/hooks/use-click-action.ts new file mode 100644 index 00000000..7e492751 --- /dev/null +++ b/app/src/hooks/use-click-action.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; +import { useParameterStore } from "@/stores/parameter-store"; +import { + resolveClickActions, + deriveClickableColumns, +} from "@/lib/resolve-click-action"; +import type { DashboardWidget, ClickAction } from "@/lib/db/schema"; + +/** + * Extracts click-action handling from a widget configuration. + * + * Returns: + * - `handleChartClick` — callback to pass to ChartRenderer's `onChartClick` + * - `hasClickAction` — whether click actions are configured + * - `clickableColumns` — for tables: which columns are interactive + */ +export function useClickAction( + widget: DashboardWidget, + onNavigateToPage?: (pageId: string, scrollToWidgetId?: string) => void, +) { + const setParameter = useParameterStore((s) => s.setParameter); + + const handleChartClick = useCallback( + (point: Record) => { + const result = resolveClickActions(widget, point); + if (!result) return; + + if (result.setParameter) { + const { parameterName, value, label, sourceField } = + result.setParameter; + setParameter( + parameterName, + value, + label, + sourceField, + "text", + "click-action", + widget.id, + ); + } + + if (result.navigateToPageId) { + onNavigateToPage?.(result.navigateToPageId); + } + }, + [widget, setParameter, onNavigateToPage], + ); + + const ws = widget.settings ?? {}; + const clickAction = ws.clickAction as ClickAction | undefined; + const hasClickAction = !!clickAction; + const clickableColumns = deriveClickableColumns(clickAction); + + return { handleChartClick, hasClickAction, clickableColumns }; +} diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index c461c8ed..f9faf640 100644 --- a/app/src/lib/__tests__/chart-registry.test.ts +++ b/app/src/lib/__tests__/chart-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { getCompatibleChartTypes, getChartConfig, @@ -9,6 +9,40 @@ import { } from "../chart-registry"; import type { ChartType, ConnectorType } from "../chart-registry"; +// Stub component — used by vi.mock to satisfy dynamic import() calls +const Stub = () => null; + +// Mock dynamic imports so component loaders resolve without real dependencies. +// vi.mock calls are hoisted by Vitest and intercept both static & dynamic imports. +vi.mock("@neoboard/components", () => ({ + BarChart: Stub, + LineChart: Stub, + PieChart: Stub, + SingleValueChart: Stub, + GraphChart: Stub, + MapChart: Stub, + JsonViewer: Stub, + MarkdownWidget: Stub, + IframeWidget: Stub, + GaugeChart: Stub, + SankeyChart: Stub, + SunburstChart: Stub, + RadarChart: Stub, + TreemapChart: Stub, +})); + +vi.mock("@/components/table-renderer", () => ({ + TableRenderer: Stub, +})); + +vi.mock("@/components/parameter-widget-renderer", () => ({ + ParameterWidgetRenderer: Stub, +})); + +vi.mock("@/components/form-widget-renderer", () => ({ + FormWidgetRenderer: Stub, +})); + // --------------------------------------------------------------------------- // getCompatibleChartTypes // --------------------------------------------------------------------------- @@ -995,14 +1029,15 @@ describe("transformToGraphData handles native number properties", () => { // chartSupportsStyling // --------------------------------------------------------------------------- describe("chartSupportsStyling", () => { - it.each(["bar", "line", "pie", "single-value", "table"] as const)( - "returns true for %s", - (type) => { - expect(chartSupportsStyling(type)).toBe(true); - }, - ); - - it.each(["graph", "map"] as const)("returns true for %s", (type) => { + it.each([ + "bar", + "line", + "pie", + "single-value", + "table", + "graph", + "map", + ] as const)("returns true for %s", (type) => { expect(chartSupportsStyling(type)).toBe(true); }); @@ -1059,18 +1094,10 @@ describe("getStylingTargets", () => { expect(targets).toContainEqual({ value: "textColor", label: "Text Color" }); }); - it("returns node color target for graph", () => { - expect(getStylingTargets("graph")).toContainEqual({ - value: "color", - label: "Node Color", - }); - }); - - it("returns marker color target for map", () => { - expect(getStylingTargets("map")).toContainEqual({ - value: "color", - label: "Marker Color", - }); + it("returns styling targets for graph", () => { + expect(getStylingTargets("graph")).toEqual([ + { value: "color", label: "Node Color" }, + ]); }); it("returns empty array for unknown type", () => { @@ -1837,3 +1864,29 @@ describe("treemap transform", () => { expect(result).toEqual(transform(data)); }); }); + +// --------------------------------------------------------------------------- +// Registry component field +// --------------------------------------------------------------------------- +describe("registry component field", () => { + const allTypes = Object.keys(chartRegistry) as ChartType[]; + + it.each(allTypes)("%s has a component field", (type) => { + const config = getChartConfig(type); + expect(config).toBeDefined(); + expect(config!.component).toBeDefined(); + expect(typeof config!.component).toBe("function"); + }); + + it.each(allTypes)( + "%s component loader resolves to a module with default export", + async (type) => { + const config = getChartConfig(type); + expect(config).toBeDefined(); + expect(config!.component).toBeDefined(); + const mod = await config!.component!(); + expect(mod).toBeDefined(); + expect(mod.default).toBeDefined(); + }, + ); +}); diff --git a/app/src/lib/chart-registry.ts b/app/src/lib/chart-registry.ts index cdaa7b14..9f0d5cae 100644 --- a/app/src/lib/chart-registry.ts +++ b/app/src/lib/chart-registry.ts @@ -9,6 +9,7 @@ * The toRecords helper is kept as a safety net for backward compatibility. */ +import type React from "react"; import { normalizeValue } from "./normalize-value"; import type { ColumnMapping } from "@neoboard/components"; @@ -40,6 +41,15 @@ export interface ChartConfig { label: string; transform: (data: unknown) => unknown; transformWithMapping: (data: unknown, mapping: ColumnMapping) => unknown; + /** + * Lazy component loader for this chart type. Used by chart-renderer + * to dynamically import the component. Returns a module with a default export. + * + * For charts that don't need lazy loading (e.g., JSON, Markdown), this + * can return the component directly wrapped in `{ default: Component }`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- props differ per chart type; caller provides correct props + component?: () => Promise<{ default: React.ComponentType }>; /** * Validates raw data shape before transform. Returns an error string * when data exists but has the wrong shape for this chart type. @@ -59,8 +69,9 @@ export interface ChartConfig { supportsClickAction?: boolean; /** * Whether this chart type supports rule-based styling. - * Defaults to true if omitted. Set to false for chart types that - * can't apply conditional colors (graph, map, json, parameter-select, form). + * Defaults to true if `stylingTargets` is defined, false otherwise. + * Set to false explicitly for chart types that can't apply conditional + * colors (json, parameter-select, form). */ supportsStyling?: boolean; /** Whether this chart renders via ECharts (used by screenshot capture). */ @@ -675,10 +686,15 @@ function validateMapData(data: unknown): string | null { const COLOR_TARGET = [{ value: "color", label: "Color" }]; +// Note: `component` fields below will replace the parallel lazy-loaders in +// chart-renderer.tsx in a follow-up PR. Until then, chart-renderer.tsx owns +// the active loaders at runtime; these registry entries are for future use. export const chartRegistry: Record = { bar: { type: "bar", label: "Bar Chart", + component: () => + import("@neoboard/components").then((m) => ({ default: m.BarChart })), transform: transformToBarData, transformWithMapping: transformToBarData, validate: validateBarData, @@ -690,6 +706,8 @@ export const chartRegistry: Record = { line: { type: "line", label: "Line Chart", + component: () => + import("@neoboard/components").then((m) => ({ default: m.LineChart })), transform: transformToLineData, transformWithMapping: transformToLineData, validate: validateLineData, @@ -701,6 +719,8 @@ export const chartRegistry: Record = { pie: { type: "pie", label: "Pie Chart", + component: () => + import("@neoboard/components").then((m) => ({ default: m.PieChart })), transform: transformToPieData, transformWithMapping: transformToPieData, validate: validatePieData, @@ -712,6 +732,10 @@ export const chartRegistry: Record = { table: { type: "table", label: "Data Table", + component: () => + import("@/components/table-renderer").then((m) => ({ + default: m.TableRenderer, + })), transform: transformToTableData, transformWithMapping: transformToTableData, compatibleWith: ["neo4j", "postgresql"], @@ -723,6 +747,10 @@ export const chartRegistry: Record = { "single-value": { type: "single-value", label: "Single Value", + component: () => + import("@neoboard/components").then((m) => ({ + default: m.SingleValueChart, + })), transform: transformToValueData, transformWithMapping: transformToValueData, validate: validateValueData, @@ -737,6 +765,8 @@ export const chartRegistry: Record = { graph: { type: "graph", label: "Graph", + component: () => + import("@neoboard/components").then((m) => ({ default: m.GraphChart })), transform: transformToGraphData, transformWithMapping: transformToGraphData, validate: validateGraphData, @@ -746,6 +776,8 @@ export const chartRegistry: Record = { map: { type: "map", label: "Map", + component: () => + import("@neoboard/components").then((m) => ({ default: m.MapChart })), transform: transformToMapData, transformWithMapping: transformToMapData, validate: validateMapData, @@ -755,6 +787,8 @@ export const chartRegistry: Record = { json: { type: "json", label: "JSON Viewer", + component: () => + import("@neoboard/components").then((m) => ({ default: m.JsonViewer })), transform: transformToJsonData, transformWithMapping: transformToJsonData, compatibleWith: ["neo4j", "postgresql"], @@ -764,6 +798,10 @@ export const chartRegistry: Record = { "parameter-select": { type: "parameter-select", label: "Parameter Selector", + component: () => + import("@/components/parameter-widget-renderer").then((m) => ({ + default: m.ParameterWidgetRenderer, + })), transform: transformToSelectData, transformWithMapping: transformToSelectData, compatibleWith: ["neo4j", "postgresql"], @@ -774,6 +812,10 @@ export const chartRegistry: Record = { form: { type: "form", label: "Form", + component: () => + import("@/components/form-widget-renderer").then((m) => ({ + default: m.FormWidgetRenderer, + })), transform: () => [], transformWithMapping: () => [], compatibleWith: ["neo4j", "postgresql"], @@ -783,6 +825,10 @@ export const chartRegistry: Record = { markdown: { type: "markdown", label: "Markdown", + component: () => + import("@neoboard/components").then((m) => ({ + default: m.MarkdownWidget, + })), transform: () => null, transformWithMapping: () => null, compatibleWith: ["neo4j", "postgresql"], @@ -793,6 +839,10 @@ export const chartRegistry: Record = { iframe: { type: "iframe", label: "iFrame", + component: () => + import("@neoboard/components").then((m) => ({ + default: m.IframeWidget, + })), transform: () => null, transformWithMapping: () => null, compatibleWith: ["neo4j", "postgresql"], @@ -803,6 +853,8 @@ export const chartRegistry: Record = { gauge: { type: "gauge", label: "Gauge", + component: () => + import("@neoboard/components").then((m) => ({ default: m.GaugeChart })), transform: transformToGaugeData, transformWithMapping: transformToGaugeData, compatibleWith: ["neo4j", "postgresql"], @@ -813,6 +865,8 @@ export const chartRegistry: Record = { sankey: { type: "sankey", label: "Sankey", + component: () => + import("@neoboard/components").then((m) => ({ default: m.SankeyChart })), transform: transformToSankeyData, transformWithMapping: transformToSankeyData, compatibleWith: ["neo4j", "postgresql"], @@ -822,6 +876,10 @@ export const chartRegistry: Record = { sunburst: { type: "sunburst", label: "Sunburst", + component: () => + import("@neoboard/components").then((m) => ({ + default: m.SunburstChart, + })), transform: transformToHierarchicalData, transformWithMapping: transformToHierarchicalData, compatibleWith: ["neo4j", "postgresql"], @@ -831,6 +889,8 @@ export const chartRegistry: Record = { radar: { type: "radar", label: "Radar", + component: () => + import("@neoboard/components").then((m) => ({ default: m.RadarChart })), transform: transformToRadarData, transformWithMapping: transformToRadarData, compatibleWith: ["neo4j", "postgresql"], @@ -841,6 +901,10 @@ export const chartRegistry: Record = { treemap: { type: "treemap", label: "Treemap", + component: () => + import("@neoboard/components").then((m) => ({ + default: m.TreemapChart, + })), transform: transformToHierarchicalData, transformWithMapping: transformToHierarchicalData, compatibleWith: ["neo4j", "postgresql"],