-
Notifications
You must be signed in to change notification settings - Fork 0
feat(plugins): external chart plugin loading via manifest (#423) #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
01f67fb
57c1577
46f0f91
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,4 +75,5 @@ docs/.next | |
|
|
||
| # CLI build output | ||
| cli/dist/ | ||
| .neoboard.local | ||
| .neoboard.local | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { describe, it, expect, beforeEach, vi } from "vitest"; | ||
| import type { ChartPlugin } from "@/lib/plugin/chart-plugin-registry"; | ||
| import { createPluginRegistry } from "@/lib/plugin/chart-plugin-registry"; | ||
|
|
||
| // Lightweight plugin factory for tests — ONLY fields the bootstrap cares | ||
| // about. Full plugin coverage lives in bar.test.tsx and friends. | ||
| function makePlugin(type: string): ChartPlugin { | ||
| return { | ||
| type, | ||
| label: type, | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| component: (() => null) as any, | ||
| transform: (d) => d, | ||
| options: [], | ||
| capabilities: { | ||
| supportsClickAction: false, | ||
| supportsStyling: false, | ||
| isECharts: false, | ||
| requiresQuery: true, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Mirrors the external-plugin registration loop in plugins/index.ts. | ||
| * Keeping the decision here lets us unit-test it without executing the | ||
| * full bootstrap side effect. | ||
| */ | ||
| function registerExternalPlugins( | ||
| registry: ReturnType<typeof createPluginRegistry>, | ||
| entries: Array<{ plugin: ChartPlugin; overrides: boolean }>, | ||
| ) { | ||
| for (const { plugin, overrides } of entries) { | ||
| if (registry.has(plugin.type)) { | ||
| if (!overrides) { | ||
| throw new Error( | ||
| `External plugin "${plugin.type}" conflicts with an existing plugin. ` + | ||
| `Set "overrides": true in neoboard-plugins.json to replace the built-in.`, | ||
| ); | ||
| } | ||
| registry.unregister(plugin.type); | ||
| } | ||
| registry.register(plugin); | ||
| } | ||
| } | ||
|
|
||
| describe("external plugin bootstrap — overrides logic", () => { | ||
| let registry: ReturnType<typeof createPluginRegistry>; | ||
|
|
||
| beforeEach(() => { | ||
| registry = createPluginRegistry(); | ||
| }); | ||
|
|
||
| it("registers an external plugin with a unique type", () => { | ||
| registerExternalPlugins(registry, [ | ||
| { plugin: makePlugin("heatmap"), overrides: false }, | ||
| ]); | ||
| expect(registry.has("heatmap")).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects an external plugin that conflicts with a built-in when overrides=false", () => { | ||
| registry.register(makePlugin("bar")); | ||
| expect(() => | ||
| registerExternalPlugins(registry, [ | ||
| { plugin: makePlugin("bar"), overrides: false }, | ||
| ]), | ||
| ).toThrow(/conflicts with an existing plugin/); | ||
| expect(() => | ||
| registerExternalPlugins(registry, [ | ||
| { plugin: makePlugin("bar"), overrides: false }, | ||
| ]), | ||
| ).toThrow(/overrides.*true/); | ||
| }); | ||
|
|
||
| it("replaces a built-in when overrides=true", () => { | ||
| const builtin = makePlugin("bar"); | ||
| builtin.label = "Built-in Bar"; | ||
| registry.register(builtin); | ||
|
|
||
| const external = makePlugin("bar"); | ||
| external.label = "External Bar"; | ||
| registerExternalPlugins(registry, [{ plugin: external, overrides: true }]); | ||
|
|
||
| expect(registry.get("bar")?.label).toBe("External Bar"); | ||
| }); | ||
|
|
||
| it("registers multiple external plugins in order", () => { | ||
| registerExternalPlugins(registry, [ | ||
| { plugin: makePlugin("heatmap"), overrides: false }, | ||
| { plugin: makePlugin("funnel"), overrides: false }, | ||
| ]); | ||
| expect(registry.getTypes()).toEqual( | ||
| expect.arrayContaining(["heatmap", "funnel"]), | ||
| ); | ||
| }); | ||
|
|
||
| it("first conflict aborts the batch — subsequent entries are not registered", () => { | ||
| registry.register(makePlugin("bar")); | ||
| expect(() => | ||
| registerExternalPlugins(registry, [ | ||
| { plugin: makePlugin("bar"), overrides: false }, | ||
| { plugin: makePlugin("safe"), overrides: false }, | ||
| ]), | ||
| ).toThrow(); | ||
| // "safe" never got registered because the loop threw on "bar" | ||
| expect(registry.has("safe")).toBe(false); | ||
| }); | ||
|
|
||
| it("empty entries array is a no-op", () => { | ||
| const before = registry.getTypes().length; | ||
| registerExternalPlugins(registry, []); | ||
| expect(registry.getTypes().length).toBe(before); | ||
| }); | ||
| }); | ||
|
|
||
| describe("plugin bootstrap — end-to-end (real registry)", () => { | ||
| it("exports pluginRegistry with built-ins registered", async () => { | ||
| // Mock the heavy component-library dep so the bootstrap doesn't | ||
| // load the full chart suite in the test env. | ||
| vi.doMock("@neoboard/components", () => ({ | ||
| MarkdownWidget: () => null, | ||
| getChartOptions: () => [], | ||
| })); | ||
| const { pluginRegistry } = await import("../index"); | ||
| expect(pluginRegistry.has("bar")).toBe(true); | ||
| expect(pluginRegistry.has("markdown")).toBe(true); | ||
| vi.doUnmock("@neoboard/components"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /** | ||
| * AUTO-GENERATED — do not edit by hand. | ||
| * Source: neoboard-plugins.json | ||
| * Regenerate: node scripts/generate-plugin-imports.mjs | ||
| */ | ||
| import type { ChartPlugin } from "@/lib/plugin/chart-plugin-registry"; | ||
|
|
||
| export interface ExternalPluginEntry { | ||
| plugin: ChartPlugin; | ||
| overrides: boolean; | ||
| } | ||
|
|
||
| export const EXTERNAL_PLUGINS: ExternalPluginEntry[] = []; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # Authoring external chart plugins for NeoBoard | ||
|
|
||
| NeoBoard loads external chart plugins at **build time** via a manifest | ||
| at the repository root: `neoboard-plugins.json`. This doc walks through | ||
| writing a plugin, wiring it into the manifest, and the trust model you | ||
| sign up for as an operator. | ||
|
|
||
| ## Trust model (read this first) | ||
|
|
||
| - Plugins run **in-process** inside the NeoBoard Next.js app. They have | ||
| the same access as any first-party code: React context, the plugin | ||
| registry, the network, environment variables in the browser bundle. | ||
| - Declaring a plugin in `neoboard-plugins.json` requires filesystem + | ||
| commit access to the repo and an `npm install`. This is **not** a | ||
| runtime-pluggable surface — there is no UI or API to add a plugin. | ||
| - **There is no sandbox.** A malicious plugin can exfiltrate the user's | ||
| session. Treat external plugins the same as any other npm dependency | ||
| you bundle into your production build. | ||
| - Review plugin source before adding it to the manifest. Prefer plugins | ||
| you've authored, or packages from a vendor you trust. | ||
|
|
||
| ## Plugin shape | ||
|
|
||
| A plugin is a `ChartPlugin` object (see | ||
| `app/src/lib/plugin/chart-plugin-registry.ts` for the full type). The | ||
| minimum viable plugin: | ||
|
|
||
| ```ts | ||
| import { defineChartPlugin } from "@neoboard/app/plugin-sdk"; // see note below | ||
| import { MyChart } from "./component"; | ||
|
|
||
| export default defineChartPlugin({ | ||
| type: "heatmap", | ||
| label: "Heatmap", | ||
| component: MyChart, | ||
| transform: (rows) => rows, // whatever shape your component consumes | ||
| }); | ||
| ``` | ||
|
|
||
| Fields at a glance: | ||
|
|
||
| | Field | Required | What it does | | ||
| | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `type` | yes | Unique chart identifier (e.g. `"heatmap"`). Must not collide with a built-in unless you also set `"overrides": true` in the manifest. | | ||
| | `label` | yes | Human-readable name in the chart picker. | | ||
| | `component` | yes | React component rendered inside the widget card. | | ||
| | `transform` | yes | `(rawRows) → yourShape` — normalizes the query result for your component. | | ||
| | `options` | no | Chart-option panel config. See `app/src/plugins/bar/options.ts` for a full example. | | ||
| | `queryHint` | no | Short example query + expected columns shown in the editor. | | ||
| | `compatibleWith` | no | Array of connector types (`"neo4j"`, `"postgresql"`). Omit = all. | | ||
| | `stylingTargets` | no | Conditional-styling targets (e.g. `[{ value: "color", label: "Color" }]`). | | ||
| | `capabilities` | no | Overrides for `supportsClickAction`, `supportsStyling`, `isECharts`, `requiresQuery`. | | ||
| | `settingsSchema` | no | Zod schema for the settings object. Recommended — lets you `.parse()` raw settings in your component. | | ||
|
|
||
| ## Package layout | ||
|
|
||
| Your plugin should be an npm package. Minimum structure: | ||
|
|
||
| ``` | ||
| my-neoboard-plugin/ | ||
| ├── package.json | ||
| ├── src/ | ||
| │ ├── index.ts # exports default plugin | ||
| │ ├── component.tsx # React component | ||
| │ └── settings.ts # Zod schema (optional) | ||
| └── tsconfig.json | ||
| ``` | ||
|
|
||
| `package.json` must declare `"main"` / `"module"` / `"types"` that point | ||
| at a published bundle — NeoBoard imports your package like any other npm | ||
| dep. Peer-depend on `react` and (if you use them) `zod` and | ||
| `@neoboard/components` so they don't get bundled twice. | ||
|
|
||
| ```json | ||
| { | ||
| "name": "@myorg/neoboard-heatmap", | ||
| "main": "dist/index.js", | ||
| "module": "dist/index.mjs", | ||
| "types": "dist/index.d.ts", | ||
| "peerDependencies": { | ||
| "react": ">=19" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Wiring it in | ||
|
|
||
| 1. `npm install @myorg/neoboard-heatmap` in the NeoBoard repo root. | ||
| 2. Add an entry to `neoboard-plugins.json`: | ||
|
|
||
| ```json | ||
| { | ||
| "$schema": "./neoboard-plugins.schema.json", | ||
| "plugins": [{ "package": "@myorg/neoboard-heatmap" }] | ||
| } | ||
| ``` | ||
|
|
||
| 3. Run `npm run generate:plugins` (or just start dev/build — it's wired | ||
| into `predev` and `prebuild`). | ||
| 4. A new chart type `"heatmap"` appears in the widget editor. | ||
|
|
||
| ### Named exports | ||
|
|
||
| By default the generator imports the package's `default` export. To use | ||
| a named export: | ||
|
|
||
| ```json | ||
| { "package": "@myorg/neoboard-charts", "export": "heatmap" } | ||
| ``` | ||
|
|
||
| ### Overriding a built-in | ||
|
|
||
| External plugins that reuse a built-in chart type (`"bar"`, `"pie"`, | ||
| etc.) are **rejected at startup** unless the manifest entry opts in: | ||
|
|
||
| ```json | ||
| { "package": "@myorg/neoboard-bar-plus", "overrides": true } | ||
| ``` | ||
|
|
||
| This is deliberate — silent overrides are a nightmare to debug. If | ||
| you're replacing a built-in, say so out loud. | ||
|
|
||
| ## Development loop | ||
|
|
||
| The manifest is resolved at build time, so you'll want a local | ||
| workflow: | ||
|
|
||
| 1. `npm link` your plugin package during development so changes in | ||
| your plugin are immediately visible in NeoBoard's `node_modules`. | ||
| 2. Restart the dev server after editing `neoboard-plugins.json` (the | ||
| import statements are regenerated by the `predev` hook). | ||
|
|
||
| ## Failure modes | ||
|
|
||
| | Symptom | Likely cause | | ||
| | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `Cannot find module '@myorg/foo'` at build time | Package not installed. Run `npm install` in the repo root. | | ||
| | `External plugin "bar" conflicts with an existing plugin` at startup | Your plugin's `type` matches a built-in and `overrides` is `false`. Either rename or set `"overrides": true`. | | ||
| | Widget renders blank / throws | Your plugin's component threw. `ChartErrorBoundary` (each widget has one) catches it and shows an inline error. Check the browser console for the stack. | | ||
| | Chart options panel is empty | `options` is undefined. Declare option keys in your plugin. | | ||
|
|
||
| ## Testing your plugin | ||
|
|
||
| Treat your plugin as a normal React library: unit-test the `transform` | ||
| function, snapshot-test the component with sample data, ship it as an | ||
| npm package with its own test suite. NeoBoard does not run your tests. | ||
|
|
||
| Integration testing against a real NeoBoard install is the most | ||
| reliable signal. A stripped-down example lives in | ||
| `examples/plugin-sparkline/` (coming soon) — clone it, rename, ship. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "$schema": "./neoboard-plugins.schema.json", | ||
| "plugins": [] | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,38 @@ | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||||||||||||||||||||||||||||||||||||||
| "$id": "https://github.com/alfredo1996/neoboard/blob/main/neoboard-plugins.schema.json", | ||||||||||||||||||||||||||||||||||||||
| "title": "NeoBoard external plugin manifest", | ||||||||||||||||||||||||||||||||||||||
| "description": "Declares external chart plugins to import at build time. Each entry must resolve to an npm package that is installed (declared in app/package.json or a workspace) and exports a ChartPlugin.", | ||||||||||||||||||||||||||||||||||||||
| "type": "object", | ||||||||||||||||||||||||||||||||||||||
| "additionalProperties": false, | ||||||||||||||||||||||||||||||||||||||
| "properties": { | ||||||||||||||||||||||||||||||||||||||
| "$schema": { "type": "string" }, | ||||||||||||||||||||||||||||||||||||||
| "plugins": { | ||||||||||||||||||||||||||||||||||||||
| "type": "array", | ||||||||||||||||||||||||||||||||||||||
| "items": { | ||||||||||||||||||||||||||||||||||||||
| "type": "object", | ||||||||||||||||||||||||||||||||||||||
| "additionalProperties": false, | ||||||||||||||||||||||||||||||||||||||
| "required": ["package"], | ||||||||||||||||||||||||||||||||||||||
| "properties": { | ||||||||||||||||||||||||||||||||||||||
| "package": { | ||||||||||||||||||||||||||||||||||||||
| "type": "string", | ||||||||||||||||||||||||||||||||||||||
| "minLength": 1, | ||||||||||||||||||||||||||||||||||||||
| "description": "The npm package name to import (e.g. '@myorg/neoboard-heatmap' or './plugins/my-local')." | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| "export": { | ||||||||||||||||||||||||||||||||||||||
| "type": "string", | ||||||||||||||||||||||||||||||||||||||
| "minLength": 1, | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Constrain manifest strings to values codegen can import. The schema currently accepts whitespace and invalid named exports that will later generate broken TypeScript. Mirror the generator’s stricter rules here so editors catch bad manifests early. Proposed schema tightening "package": {
"type": "string",
"minLength": 1,
+ "pattern": "^\\S+$",
"description": "The npm package name to import (e.g. '@myorg/neoboard-heatmap' or './plugins/my-local')."
},
"export": {
"type": "string",
"minLength": 1,
+ "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$",
"default": "default",
"description": "Named export to import. Defaults to 'default' (the package's default export)."📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| "default": "default", | ||||||||||||||||||||||||||||||||||||||
| "description": "Named export to import. Defaults to 'default' (the package's default export)." | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| "overrides": { | ||||||||||||||||||||||||||||||||||||||
| "type": "boolean", | ||||||||||||||||||||||||||||||||||||||
| "default": false, | ||||||||||||||||||||||||||||||||||||||
| "description": "When true, this plugin is allowed to replace a built-in plugin with the same chart type. Defaults to false — duplicates are rejected at registration." | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| "required": ["plugins"] | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language to the package-layout fence.
Markdown lint flags this fenced block; use
textfor the directory tree.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 59-59: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents