Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/components/chart-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<PluginComponent
data={data}
settings={settings}
stylingRules={stylingRules}
paramValues={paramValues}
colorScales={colorScales}
onClick={handleEChartsClick}
connectionId={connectionId}
widgetId={widgetId}
resultId={resultId}
query={query}
autoFit={autoFit}
clickableColumns={clickableColumns}
colorThresholds={colorThresholds}
/>
);
}

switch (type) {
case "bar":
return (
Expand Down
49 changes: 49 additions & 0 deletions app/src/plugins/__tests__/markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="markdown-widget">{content ?? ""}</div>
),
}));

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(<Component settings={{ content: "Hello **world**" }} />);
expect(screen.getByTestId("markdown-widget")).toHaveTextContent(
"Hello **world**",
);
});

it("renders MarkdownWidget with empty content when none provided", () => {
const Component = markdownPlugin.component;
render(<Component settings={{}} />);
expect(screen.getByTestId("markdown-widget")).toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions app/src/plugins/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions app/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -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";
57 changes: 57 additions & 0 deletions app/src/plugins/markdown.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}) {

Check warning on line 25 in app/src/plugins/markdown.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ1fu-V97Pzve-iu7hq6&open=AZ1fu-V97Pzve-iu7hq6&pullRequest=378
const props: MarkdownWidgetProps = {
content: settings.content as string | undefined,
};
return <MarkdownWidget {...props} />;
}

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.",
},
],
});
21 changes: 21 additions & 0 deletions app/src/plugins/registry.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading