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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ docs/.next

# CLI build output
cli/dist/
.neoboard.local
.neoboard.local

2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"version": "0.0.1",
"private": true,
"scripts": {
"predev": "node ../scripts/generate-plugin-imports.mjs",
"prebuild": "node ../scripts/generate-plugin-imports.mjs",
"dev": "next dev --turbopack",
"build": "next build --webpack",
"start": "next start",
Expand Down
129 changes: 129 additions & 0 deletions app/src/plugins/__tests__/external-plugins-bootstrap.test.ts
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");
});
});
13 changes: 13 additions & 0 deletions app/src/plugins/external-plugins.generated.ts
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[] = [];
19 changes: 19 additions & 0 deletions app/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { pluginRegistry } from "./registry";
import { CHART_TYPES } from "./chart-types";
import { EXTERNAL_PLUGINS } from "./external-plugins.generated";
import { markdownPlugin } from "./markdown";
import { barPlugin } from "./bar";
import { linePlugin } from "./line";
Expand Down Expand Up @@ -64,6 +65,24 @@ for (const plugin of BUILT_IN_PLUGINS) {
pluginRegistry.register(plugin);
}

// ── External plugins (from neoboard-plugins.json) ───────────────────────
// Registered AFTER built-ins so external plugins can replace a built-in
// chart type — but only when their manifest entry has `overrides: true`.
// Same-type duplicates without overrides throw loudly so operators spot
// the conflict at startup instead of debugging a silent replacement.
for (const { plugin, overrides } of EXTERNAL_PLUGINS) {
if (pluginRegistry.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.`,
);
}
pluginRegistry.unregister(plugin.type);
}
pluginRegistry.register(plugin);
}

// ── Startup validation ──────────────────────────────────────────────────
// Verify that every chart type declared in CHART_TYPES has a registered
// plugin. A mismatch is a dev-time bug, not a runtime error.
Expand Down
150 changes: 150 additions & 0 deletions docs/plugins/authoring.md
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:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to the package-layout fence.

Markdown lint flags this fenced block; use text for the directory tree.

Proposed fix
-```
+```text
 my-neoboard-plugin/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
🧰 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
Verify each finding against the current code and only fix it if needed.

In `@docs/plugins/authoring.md` at line 59, The fenced code block in the
docs/plugins/authoring.md currently lacks a language tag; update the opening
fence for the directory tree (the block containing "my-neoboard-plugin/") to use
the text language (change the triple-backtick fence to ```text) so the Markdown
linter stops flagging it and the tree renders as plain text.

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.
4 changes: 4 additions & 0 deletions neoboard-plugins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "./neoboard-plugins.schema.json",
"plugins": []
}
38 changes: 38 additions & 0 deletions neoboard-plugins.schema.json
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"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,
"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_$]*$",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@neoboard-plugins.schema.json` around lines 17 - 24, The schema lets "package"
and "export" be arbitrary strings, which permits whitespace and invalid JS
identifiers that will break generated TypeScript; update the
neoboard-plugins.schema.json definitions for "package" to include a "pattern"
that forbids whitespace and matches valid import specifiers (e.g. allow scoped
packages, relative paths and characters typically valid in package names like
letters, digits, -, _, ., / and @) and for "export" to include a "pattern" that
enforces valid JS export names (e.g. ^[A-Za-z_$][A-Za-z0-9_$]*$) or the literal
"default"; keep the existing "minLength" checks and add concise regex patterns
on the "package" and "export" properties so editors validate manifests the same
way the generator expects.

"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"]
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"cli"
],
"scripts": {
"generate:plugins": "node scripts/generate-plugin-imports.mjs",
"predev": "node scripts/generate-plugin-imports.mjs",
"prebuild": "node scripts/generate-plugin-imports.mjs",
"dev": "npm -w app run dev",
"build": "npm -w connection run build && npm -w app run build",
"test": "npm -w app run test && npm -w component run test && npm -w cli run test",
Comment thread
alfredo1996 marked this conversation as resolved.
Expand Down
Loading
Loading