From 43e483917d39cb0f68d9c54dda56e65bae84ac66 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 00:01:50 +0200 Subject: [PATCH 1/2] feat(app): plugin-driven chart renderer + markdown plugin migration (#220) Second PR in the plugin system epic. Wires the plugin registry into chart-renderer.tsx and migrates the markdown widget as a proof of concept. Architecture: - New `app/src/plugins/` directory holds the global registry singleton and individual chart plugins - `chart-renderer.tsx` now checks `pluginRegistry.get(type)` FIRST; the existing switch statement remains as a safety fallback for charts not yet migrated - Plugin component receives a uniform props shape: `{ data, settings, stylingRules, paramValues, colorScales, onClick, connectionId, widgetId, resultId, query, autoFit, clickableColumns, colorThresholds }` Markdown migration (first plugin): - New `app/src/plugins/markdown.tsx` defines the markdown plugin - Content-only widget with no query, no click action, no styling - Component adapter reads `settings.content` and renders MarkdownWidget - The legacy switch case is kept as fallback (dead code after registration) Tests added: - 6 markdown plugin tests (capabilities, transform, component render) - 3 global registry tests (registration on import, idempotency, unknown types) All 1852 app tests pass (+9 new). Related: #220, epic #221 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/chart-renderer.tsx | 26 ++++++++++ app/src/plugins/__tests__/markdown.test.tsx | 49 ++++++++++++++++++ app/src/plugins/__tests__/registry.test.ts | 29 +++++++++++ app/src/plugins/index.ts | 28 ++++++++++ app/src/plugins/markdown.tsx | 57 +++++++++++++++++++++ app/src/plugins/registry.ts | 21 ++++++++ 6 files changed, 210 insertions(+) create mode 100644 app/src/plugins/__tests__/markdown.test.tsx create mode 100644 app/src/plugins/__tests__/registry.test.ts create mode 100644 app/src/plugins/index.ts create mode 100644 app/src/plugins/markdown.tsx create mode 100644 app/src/plugins/registry.ts diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index 1514fc57..09f0e9d3 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { AlertCircle } from "lucide-react"; import { normalizeValue } from "@/lib/normalize-value"; import type { ChartType } from "@/lib/chart-registry"; +import { pluginRegistry } from "@/plugins"; import { ChartErrorBoundary } from "./chart-error-boundary"; import { Skeleton, @@ -185,6 +186,31 @@ 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. + const plugin = pluginRegistry.get(type); + if (plugin) { + const PluginComponent = plugin.component; + return ( + + ); + } + switch (type) { case "bar": return ( diff --git a/app/src/plugins/__tests__/markdown.test.tsx b/app/src/plugins/__tests__/markdown.test.tsx new file mode 100644 index 00000000..51def98b --- /dev/null +++ b/app/src/plugins/__tests__/markdown.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { markdownPlugin } from "../markdown"; + +vi.mock("@neoboard/components", () => ({ + MarkdownWidget: ({ content }: { content?: string }) => ( +
{content ?? ""}
+ ), +})); + +describe("markdownPlugin", () => { + it("declares type = 'markdown'", () => { + expect(markdownPlugin.type).toBe("markdown"); + }); + + it("declares content-only capabilities", () => { + expect(markdownPlugin.capabilities.supportsClickAction).toBe(false); + expect(markdownPlugin.capabilities.supportsStyling).toBe(false); + expect(markdownPlugin.capabilities.requiresQuery).toBe(false); + expect(markdownPlugin.capabilities.isECharts).toBe(false); + }); + + it("exposes a content option in the Content category", () => { + expect(markdownPlugin.options).toHaveLength(1); + expect(markdownPlugin.options?.[0]).toMatchObject({ + key: "content", + type: "text", + category: "Content", + }); + }); + + it("transform returns null (content-only widget)", () => { + expect(markdownPlugin.transform({ anything: "x" })).toBeNull(); + }); + + it("renders MarkdownWidget with settings.content", () => { + const Component = markdownPlugin.component; + render(); + expect(screen.getByTestId("markdown-widget")).toHaveTextContent( + "Hello **world**", + ); + }); + + it("renders MarkdownWidget with empty content when none provided", () => { + const Component = markdownPlugin.component; + render(); + expect(screen.getByTestId("markdown-widget")).toBeInTheDocument(); + }); +}); diff --git a/app/src/plugins/__tests__/registry.test.ts b/app/src/plugins/__tests__/registry.test.ts new file mode 100644 index 00000000..b6d93b9f --- /dev/null +++ b/app/src/plugins/__tests__/registry.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@neoboard/components", () => ({ + MarkdownWidget: () => null, +})); + +// Import the global registry via the plugin index (this also triggers +// registration side effects for all built-in plugins). +import { pluginRegistry } from "../index"; + +describe("global plugin registry (bootstrap)", () => { + it("has markdown plugin registered on import", () => { + expect(pluginRegistry.has("markdown")).toBe(true); + const plugin = pluginRegistry.get("markdown"); + expect(plugin?.type).toBe("markdown"); + expect(plugin?.label).toBe("Markdown"); + }); + + it("re-importing plugins/index.ts is idempotent", async () => { + // Re-importing should NOT throw about duplicate registration. + const mod = await import("../index"); + expect(mod.pluginRegistry.has("markdown")).toBe(true); + }); + + it("unknown chart types return undefined", () => { + expect(pluginRegistry.get("nonexistent-chart-type")).toBeUndefined(); + expect(pluginRegistry.has("nonexistent-chart-type")).toBe(false); + }); +}); diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts new file mode 100644 index 00000000..efafb56b --- /dev/null +++ b/app/src/plugins/index.ts @@ -0,0 +1,28 @@ +/** + * Plugin registrations — imports and registers all chart plugins. + * + * Importing this module has the side effect of registering each built-in + * plugin with the global registry. Called once at app startup via an + * import in chart-renderer.tsx (or wherever the registry is first used). + * + * 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 + * + * 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. + */ + +import { pluginRegistry } from "./registry"; +import { markdownPlugin } from "./markdown"; + +// 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); +} + +// Re-export for convenience +export { pluginRegistry } from "./registry"; diff --git a/app/src/plugins/markdown.tsx b/app/src/plugins/markdown.tsx new file mode 100644 index 00000000..d87fbabb --- /dev/null +++ b/app/src/plugins/markdown.tsx @@ -0,0 +1,57 @@ +/** + * Markdown widget plugin. + * + * Renders static markdown content — no query, no data transform. + * This is the simplest possible plugin and serves as a reference + * implementation during the plugin migration. + */ + +import { MarkdownWidget } from "@neoboard/components"; +import { defineChartPlugin } from "./registry"; + +interface MarkdownWidgetProps { + content?: string; +} + +/** + * Component adapter — extracts the `content` field from settings and + * renders the MarkdownWidget. The plugin contract passes the full + * settings object to the component as `settings` prop. + */ +function MarkdownPluginComponent({ + settings, +}: { + settings: Record; +}) { + const props: MarkdownWidgetProps = { + content: settings.content as string | undefined, + }; + return ; +} + +export const markdownPlugin = defineChartPlugin({ + type: "markdown", + label: "Markdown", + component: MarkdownPluginComponent, + // Content-only widget — no data transform needed + transform: () => null, + capabilities: { + supportsClickAction: false, + supportsStyling: false, + isECharts: false, + requiresQuery: false, + }, + queryHint: + "Markdown widgets render static content — no query required. " + + "Use the content field to write your text.", + options: [ + { + key: "content", + label: "Content", + type: "text", + default: "", + category: "Content", + description: "Markdown source for the widget body.", + }, + ], +}); diff --git a/app/src/plugins/registry.ts b/app/src/plugins/registry.ts new file mode 100644 index 00000000..d844d30c --- /dev/null +++ b/app/src/plugins/registry.ts @@ -0,0 +1,21 @@ +/** + * Global chart plugin registry. + * + * Singleton instance used by chart-renderer.tsx to look up plugins at + * render time. Plugins are registered via the `plugins/index.ts` module + * which is imported once when the app starts. + * + * This file is intentionally minimal — the registry implementation lives + * in `app/src/lib/chart-plugin-registry.ts`. This module just holds the + * singleton and re-exports the types for convenience. + */ + +import { createPluginRegistry } from "@/lib/chart-plugin-registry"; + +export const pluginRegistry = createPluginRegistry(); + +export type { + ChartPlugin, + ChartPluginConfig, +} from "@/lib/chart-plugin-registry"; +export { defineChartPlugin } from "@/lib/chart-plugin-registry"; From bc9a47fc5feacfba46764258d26e846774bbd735 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Mon, 6 Apr 2026 00:14:00 +0200 Subject: [PATCH 2/2] ci: trigger workflow after base change