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";