diff --git a/app/src/components/card-container.tsx b/app/src/components/card-container.tsx index 2c06b359..59ab45de 100644 --- a/app/src/components/card-container.tsx +++ b/app/src/components/card-container.tsx @@ -302,18 +302,33 @@ export function CardContainer({ /> ); } - const mappedData = ( - chartConfig.transformWithMapping ?? chartConfig.transform - )(previewData, columnMapping); + let mappedData: unknown; + try { + mappedData = (chartConfig.transformWithMapping ?? chartConfig.transform)( + previewData, + columnMapping, + ); + } catch (err) { + console.error( + "Chart transform failed for " + widget.chartType + ":", + err, + ); + mappedData = previewData; + } // Skip transforms for graph charts — their data shape is incompatible with tabular transforms - const transformedData = - dataTransforms.length && widget.chartType !== "graph" - ? applyTransforms( - mappedData as Record[], - dataTransforms, - allParamValues, - ) - : mappedData; + let transformedData: unknown = mappedData; + if (dataTransforms.length && widget.chartType !== "graph") { + try { + transformedData = applyTransforms( + mappedData as Record[], + dataTransforms, + allParamValues, + ); + } catch (err) { + console.error("Data transform failed:", err); + transformedData = mappedData; + } + } const availableColumns = extractColumnNames(previewData); return (
@@ -586,16 +601,29 @@ export function CardContainer({ ); } - const mappedData = ( - chartConfig.transformWithMapping ?? chartConfig.transform - )(rawData, columnMapping); - const transformedData = dataTransforms.length - ? applyTransforms( + let mappedData: unknown; + try { + mappedData = (chartConfig.transformWithMapping ?? chartConfig.transform)( + rawData, + columnMapping, + ); + } catch (err) { + console.error("Chart transform failed for " + widget.chartType + ":", err); + mappedData = rawData; + } + let transformedData: unknown = mappedData; + if (dataTransforms.length) { + try { + transformedData = applyTransforms( mappedData as Record[], dataTransforms, allParamValues, - ) - : mappedData; + ); + } catch (err) { + console.error("Data transform failed:", err); + transformedData = mappedData; + } + } const availableColumns = extractColumnNames(rawData); return ( diff --git a/app/src/lib/__tests__/plugin/chart-helpers.test.ts b/app/src/lib/__tests__/plugin/chart-helpers.test.ts index 9818a856..ae0770c1 100644 --- a/app/src/lib/__tests__/plugin/chart-helpers.test.ts +++ b/app/src/lib/__tests__/plugin/chart-helpers.test.ts @@ -182,8 +182,14 @@ describe("chartRequiresQuery", () => { // getChartDefaults // --------------------------------------------------------------------------- describe("getChartDefaults", () => { - it("returns empty object", () => { - expect(getChartDefaults("bar")).toEqual({}); + it("returns defaults from settings schema when available", () => { + const defaults = getChartDefaults("bar"); + // Bar plugin has a Zod schema with defaults — should extract them + expect(typeof defaults).toBe("object"); + }); + + it("returns empty object for unknown type", () => { + expect(getChartDefaults("unknown_type_xyz")).toEqual({}); }); }); @@ -191,15 +197,17 @@ describe("getChartDefaults", () => { // supportsColumnMapping // --------------------------------------------------------------------------- describe("supportsColumnMapping", () => { - it("returns true for bar, line, pie", () => { + it("returns true for types with transformWithMapping", () => { + // bar, line, pie all define transformWithMapping expect(supportsColumnMapping("bar")).toBe(true); expect(supportsColumnMapping("line")).toBe(true); expect(supportsColumnMapping("pie")).toBe(true); }); - it("returns false for table, json", () => { - expect(supportsColumnMapping("table")).toBe(false); - expect(supportsColumnMapping("json")).toBe(false); + it("returns false for types without transformWithMapping", () => { + // markdown has no transformWithMapping (content-only widget) + expect(supportsColumnMapping("markdown")).toBe(false); + expect(supportsColumnMapping("iframe")).toBe(false); }); }); diff --git a/app/src/lib/plugin/chart-helpers.ts b/app/src/lib/plugin/chart-helpers.ts index 5140a40c..43036d50 100644 --- a/app/src/lib/plugin/chart-helpers.ts +++ b/app/src/lib/plugin/chart-helpers.ts @@ -283,15 +283,30 @@ export function chartRequiresQuery(type: string): boolean { * Get default chart settings for a chart type. * Returns an empty object — defaults are managed by Zod schemas in plugins. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function getChartDefaults(_type: string): Record { +export function getChartDefaults(type: string): Record { + const plugin = pluginRegistry.get(type); + if (plugin?.settingsSchema) { + try { + // Parse empty object through the Zod schema to extract defaults + return plugin.settingsSchema.parse({}) as Record; + } catch { + // Schema requires fields — can't extract defaults + } + } return {}; } /** * Check whether a chart type supports column mapping. + * Uses the plugin's transformWithMapping function as the signal — + * if a plugin provides it, column mapping is supported. + * Falls back to the hardcoded set for plugins that haven't adopted yet. */ export function supportsColumnMapping(type: string): boolean { + const plugin = pluginRegistry.get(type); + if (plugin) { + return typeof plugin.transformWithMapping === "function"; + } return COLUMN_MAPPING_TYPES.has(type); } diff --git a/app/src/lib/plugin/chart-plugin-registry.ts b/app/src/lib/plugin/chart-plugin-registry.ts index feaa4fbc..c16b1fa1 100644 --- a/app/src/lib/plugin/chart-plugin-registry.ts +++ b/app/src/lib/plugin/chart-plugin-registry.ts @@ -132,7 +132,7 @@ const DEFAULT_CAPABILITIES: ChartCapabilities = { }; export function defineChartPlugin(config: ChartPluginConfig): ChartPlugin { - // Validation + // ── Validation ────────────────────────────────────────────────────── if (!config.type || config.type.trim() === "") { throw new Error("Chart plugin: type is required and cannot be empty"); } @@ -143,6 +143,30 @@ export function defineChartPlugin(config: ChartPluginConfig): ChartPlugin { throw new Error("Chart plugin: transform must be a function"); } + // Validate options shape if provided + if (config.options) { + for (const opt of config.options) { + if (!opt.key || !opt.label || !opt.type) { + console.warn( + 'Chart plugin "' + config.type + '": option missing key/label/type:', + opt, + ); + } + } + } + + // Validate compatibleWith entries + if (config.compatibleWith) { + for (const ct of config.compatibleWith) { + if (typeof ct !== "string" || ct.trim() === "") { + console.warn( + 'Chart plugin "' + config.type + '": invalid compatibleWith entry:', + ct, + ); + } + } + } + // supportsStyling defaults to true if stylingTargets is provided, false otherwise const stylingFromTargets = config.stylingTargets && config.stylingTargets.length > 0; diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 600533c5..34d7466d 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -73,21 +73,40 @@ for (const plugin of BUILT_IN_PLUGINS) { // 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.`, + try { + if (!plugin || typeof plugin !== "object" || !plugin.type) { + console.error( + "External plugin skipped: invalid plugin object (missing type)", ); + continue; } - pluginRegistry.unregister(plugin.type); + if (pluginRegistry.has(plugin.type)) { + if (!overrides) { + console.error( + 'External plugin "' + + plugin.type + + '" conflicts with an existing plugin. ' + + 'Set "overrides": true in neoboard-plugins.json to replace it. Skipping.', + ); + continue; + } + pluginRegistry.unregister(plugin.type); + } + pluginRegistry.register(plugin); + } catch (err) { + console.error( + "External plugin registration failed for type " + + JSON.stringify(plugin?.type) + + ":", + err, + ); + // Continue loading remaining plugins — one broken plugin shouldn't crash the app } - 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. + +// 1. Every CHART_TYPES entry must have a registered plugin. const registeredTypes = new Set(pluginRegistry.getTypes()); for (const t of CHART_TYPES) { if (!registeredTypes.has(t)) { @@ -97,6 +116,25 @@ for (const t of CHART_TYPES) { } } +// 2. Validate compatibleWith references actual connector types. +const KNOWN_CONNECTORS = new Set(["neo4j", "postgresql"]); +for (const type of pluginRegistry.getTypes()) { + const plugin = pluginRegistry.get(type); + if (plugin?.compatibleWith) { + for (const ct of plugin.compatibleWith) { + if (!KNOWN_CONNECTORS.has(ct)) { + console.warn( + 'Plugin "' + + type + + '" declares compatibleWith "' + + ct + + '" but no such connector is registered', + ); + } + } + } +} + // Re-export for convenience export { pluginRegistry } from "./registry"; export { CHART_TYPES, type ChartType } from "./chart-types"; diff --git a/cli/src/lib/manifest.ts b/cli/src/lib/manifest.ts index 4ee17b81..2cf1b924 100644 --- a/cli/src/lib/manifest.ts +++ b/cli/src/lib/manifest.ts @@ -1,8 +1,18 @@ /** * Read/write helpers for neoboard-plugins.json and neoboard-connectors.json. + * + * Writes use atomic temp-file + rename to prevent corruption from + * concurrent processes or crashes mid-write. */ -import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { + readFileSync, + writeFileSync, + renameSync, + existsSync, + unlinkSync, +} from "node:fs"; +import { dirname, join } from "node:path"; export interface ManifestEntry { package: string; @@ -13,7 +23,31 @@ export interface ManifestEntry { type ManifestKey = "plugins" | "connectors"; /** - * Read entries from a manifest file. Returns empty array if file missing. + * Atomically write JSON to a file: write to temp file, then rename. + * Rename is atomic on POSIX and near-atomic on Windows. + */ +function atomicWriteJson(filePath: string, data: unknown): void { + const tmpPath = join( + dirname(filePath), + ".tmp-" + Date.now() + "-" + Math.random().toString(36).slice(2), + ); + try { + writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n"); + renameSync(tmpPath, filePath); + } catch (err) { + // Clean up temp file on failure + try { + unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + throw err; + } +} + +/** + * Read entries from a manifest file. Returns empty array if file missing + * or corrupted (with a warning for corruption). */ export function readManifest( filePath: string, @@ -23,7 +57,8 @@ export function readManifest( try { const raw = JSON.parse(readFileSync(filePath, "utf-8")); return Array.isArray(raw[key]) ? raw[key] : []; - } catch { + } catch (err) { + console.warn("Failed to parse manifest " + filePath + ":", err); return []; } } @@ -42,7 +77,7 @@ export function addToManifest( return false; // already exists } entries.push(entry); - writeFileSync(filePath, JSON.stringify({ [key]: entries }, null, 2) + "\n"); + atomicWriteJson(filePath, { [key]: entries }); return true; } @@ -59,6 +94,6 @@ export function removeFromManifest( const entries = readManifest(filePath, key); const filtered = entries.filter((e) => e.package !== packageName); if (filtered.length === entries.length) return false; // not found - writeFileSync(filePath, JSON.stringify({ [key]: filtered }, null, 2) + "\n"); + atomicWriteJson(filePath, { [key]: filtered }); return true; } diff --git a/connection/src/generalized/connector-plugin.ts b/connection/src/generalized/connector-plugin.ts index 5c671b15..4e732999 100644 --- a/connection/src/generalized/connector-plugin.ts +++ b/connection/src/generalized/connector-plugin.ts @@ -124,6 +124,57 @@ export function createConnectorRegistry(): ConnectorRegistry { `Call unregister first if you want to replace it.`, ); } + + // Validate formFields if provided + if (plugin.formFields) { + const keys = new Set(); + for (const field of plugin.formFields) { + if (!field.key || !field.label || !field.type) { + console.warn( + 'Connector "' + + plugin.type + + '": formField missing key/label/type:', + field, + ); + } + if (keys.has(field.key)) { + console.warn( + 'Connector "' + + plugin.type + + '": duplicate formField key "' + + field.key + + '"', + ); + } + keys.add(field.key); + if ( + field.type === "select" && + (!field.options || field.options.length === 0) + ) { + console.warn( + 'Connector "' + + plugin.type + + '": select field "' + + field.key + + '" has no options', + ); + } + } + } + + // Validate category if provided + const validCategories = ["database", "graph", "api", "file"]; + if (plugin.category && !validCategories.includes(plugin.category)) { + console.warn( + 'Connector "' + + plugin.type + + '": invalid category "' + + plugin.category + + '". Expected: ' + + validCategories.join(", "), + ); + } + plugins.set(plugin.type, plugin); }, unregister(type) { diff --git a/docs/src/content/docs/charts/choropleth.mdx b/docs/src/content/docs/charts/choropleth.mdx new file mode 100644 index 00000000..364e3f2a --- /dev/null +++ b/docs/src/content/docs/charts/choropleth.mdx @@ -0,0 +1,58 @@ +--- +title: Choropleth Map +description: World map with countries colored by data value. +--- + +## Overview + +The Choropleth Map fills countries with colors based on data values, creating a heat map of the world. Use it for population, GDP, sales by country, or any country-level metric. + +## Query Format + +Return 2 columns: country name and numeric value. Country names must match standard English names (e.g., "United States", "United Kingdom", "South Korea"). + +### Cypher + +```cypher +MATCH (o:Order)-[:SHIPPED_TO]->(c:Country) +RETURN c.name AS name, count(o) AS value +``` + +### SQL + +```sql +SELECT country AS name, SUM(revenue) AS value +FROM sales +GROUP BY country +ORDER BY value DESC +``` + +## Country Name Mapping + +Common aliases are automatically resolved: + +| You Write | Maps To | +|-----------|---------| +| `USA` | United States | +| `UK` | United Kingdom | +| `South Korea` | Korea | +| `North Korea` | Dem. Rep. Korea | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| Show Labels | `false` | Show country name labels on the map | +| Show Legend | `true` | Show the piecewise color range legend | +| Enable Zoom & Pan | `true` | Allow zooming (up to 20x) and panning | +| Min Color | `#e8f4f8` | Color for the lowest value | +| Max Color | `#08306b` | Color for the highest value | + +## Features + +- **Zoom & pan** — scroll to zoom up to 20x, drag to pan +- **Piecewise legend** — auto-calculated ranges with color squares (top-right) +- **Hover emphasis** — gold highlight with shadow and country name +- **Dark tooltip** — formatted number with country name +- **5-stop gradient** — smooth color transition across data range +- **Click actions** — click a country to navigate or set a parameter diff --git a/docs/src/content/docs/charts/circle-packing.mdx b/docs/src/content/docs/charts/circle-packing.mdx new file mode 100644 index 00000000..d8159334 --- /dev/null +++ b/docs/src/content/docs/charts/circle-packing.mdx @@ -0,0 +1,43 @@ +--- +title: Circle Packing +description: Hierarchical data visualized as nested circles. +--- + +## Overview + +Circle Packing displays hierarchical data as nested circles where circle size encodes the value. It shares the same data format as Sunburst and Treemap charts — you can switch between them freely. + +## Query Format + +Return hierarchical data with name and value columns. Optional: parent column for flat-to-tree conversion. + +### Cypher + +```cypher +MATCH (p:Category)-[:HAS_CHILD]->(c:Category) +RETURN p.name AS parent, c.name AS name, c.count AS value +``` + +### SQL + +```sql +SELECT parent_name AS parent, name, value +FROM categories +ORDER BY parent_name, value DESC +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| Show Labels | `true` | Show text labels inside circles (auto-hide when too small) | +| Padding | `3` | Spacing between sibling circles (px) | + +## Features + +- **Nested circles** — size proportional to value +- **Color by depth** — automatic color palette by hierarchy level +- **Labels** — auto-hidden when circle radius is too small +- **Tooltip** — shows name and value on hover +- **Click actions** — click a circle to navigate or set a parameter +- **Rule-based styling** — color circles by condition diff --git a/docs/src/content/docs/charts/gantt.mdx b/docs/src/content/docs/charts/gantt.mdx new file mode 100644 index 00000000..486da83a --- /dev/null +++ b/docs/src/content/docs/charts/gantt.mdx @@ -0,0 +1,46 @@ +--- +title: Gantt Chart +description: Timeline visualization showing tasks as horizontal bars on a time axis. +--- + +## Overview + +The Gantt Chart displays tasks as horizontal bars on a time axis. Use it for project plans, ETL pipelines, incident timelines, or any data with start and end dates. + +## Query Format + +Return 3+ columns: task name, start date, end date. Optional: category/status for color grouping, progress (0-1) for completion overlay. + +### Cypher + +```cypher +MATCH (t:Task) +RETURN t.name AS task, t.start AS start, t.end AS end, t.status AS category +ORDER BY t.start +``` + +### SQL + +```sql +SELECT task_name AS task, start_date AS start, end_date AS end, status AS category +FROM projects +ORDER BY start_date +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| Show Today Line | `true` | Red dashed vertical line marking today's date | +| Show Progress | `false` | Overlay showing completion percentage (requires progress column) | +| Show Grid Lines | `true` | Vertical grid lines on the time axis | +| Bar Corner Radius | `2` | Corner roundness for task bars | + +## Features + +- **Zoom & pan** — scroll to zoom the time axis, drag to pan +- **Vertical scroll** — automatic scrollbar when more than 15 tasks +- **Color by category** — use rule-based styling to color bars by status +- **Progress overlay** — semi-transparent fill showing completion percentage +- **Today marker** — red dashed line with "Today" label +- **Click actions** — click a bar to navigate or set a parameter diff --git a/docs/src/content/docs/guides/keyboard-shortcuts.mdx b/docs/src/content/docs/guides/keyboard-shortcuts.mdx new file mode 100644 index 00000000..99b2addc --- /dev/null +++ b/docs/src/content/docs/guides/keyboard-shortcuts.mdx @@ -0,0 +1,28 @@ +--- +title: Keyboard Shortcuts +description: Speed up your workflow with keyboard shortcuts. +--- + +## Dashboard Shortcuts + +### Edit Mode + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl + S` | Save dashboard | +| `Cmd/Ctrl + E` | Switch to view mode | +| `Cmd/Ctrl + N` | Open "Add Widget" dialog | +| `Escape` | Close the current modal or dialog | + +### View Mode + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl + E` | Switch to edit mode (if you have edit permission) | + +## Notes + +- Shortcuts are **suppressed when typing** in text inputs, textareas, or the query editor — they won't interfere with writing queries. +- **Escape always fires**, even when an input is focused, to close modals. +- Shortcut hints appear in button tooltips (hover over Save, Add Widget, or Edit buttons to see them). +- `Cmd` on Mac, `Ctrl` on Windows/Linux — both are supported. diff --git a/scripts/generate-connector-imports.mjs b/scripts/generate-connector-imports.mjs index 53e2bb02..0bc94660 100644 --- a/scripts/generate-connector-imports.mjs +++ b/scripts/generate-connector-imports.mjs @@ -18,6 +18,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); @@ -217,6 +218,21 @@ export function runGenerator(opts = {}) { return { ok: false, errors, wrote: false }; } + // Verify that all referenced packages are actually installed + const req = createRequire(import.meta.url); + for (const entry of entries) { + try { + req.resolve(entry.package); + } catch { + errors.push( + `Package "${entry.package}" is not installed. Run: npm install ${entry.package}`, + ); + } + } + if (errors.length > 0) { + return { ok: false, errors, wrote: false }; + } + const source = renderSource(entries); const existing = existsSync(outputPath) diff --git a/scripts/generate-plugin-imports.mjs b/scripts/generate-plugin-imports.mjs index 614fdea0..15c142b6 100644 --- a/scripts/generate-plugin-imports.mjs +++ b/scripts/generate-plugin-imports.mjs @@ -20,6 +20,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); @@ -217,6 +218,21 @@ export function runGenerator(opts = {}) { return { ok: false, errors, wrote: false }; } + // Verify that all referenced packages are actually installed + const req = createRequire(import.meta.url); + for (const entry of entries) { + try { + req.resolve(entry.package); + } catch { + errors.push( + `Package "${entry.package}" is not installed. Run: npm install ${entry.package}`, + ); + } + } + if (errors.length > 0) { + return { ok: false, errors, wrote: false }; + } + const source = renderSource(entries); // Idempotent write — skip if content matches (preserves mtime for