diff --git a/app/src/lib/__tests__/dashboard/neodash-converter.test.ts b/app/src/lib/__tests__/dashboard/neodash-converter.test.ts index f73598a1..ac969b89 100644 --- a/app/src/lib/__tests__/dashboard/neodash-converter.test.ts +++ b/app/src/lib/__tests__/dashboard/neodash-converter.test.ts @@ -199,7 +199,9 @@ describe("convertNeoDash", () => { query: "MATCH (n) WHERE n.name = $neodash_userName RETURN n", }), ); - expect(result.layout.pages[0].widgets[0].query).toBe( + // Referencing $param_userName triggers a Filters page being prepended + // (auto-generated parameter-select for the undefined param). Original at index 1. + expect(result.layout.pages[1].widgets[0].query).toBe( "MATCH (n) WHERE n.name = $param_userName RETURN n", ); }); @@ -438,7 +440,8 @@ describe("convertNeoDash", () => { "MATCH (n) WHERE n.name = $neodash_name AND n.age > $neodash_minAge RETURN n", }), ); - expect(result.layout.pages[0].widgets[0].query).toBe( + // Two referenced params → Filters page prepended; original page at index 1. + expect(result.layout.pages[1].widgets[0].query).toBe( "MATCH (n) WHERE n.name = $param_name AND n.age > $param_minAge RETURN n", ); }); diff --git a/app/src/lib/dashboard/__tests__/neodash-converter.test.ts b/app/src/lib/dashboard/__tests__/neodash-converter.test.ts new file mode 100644 index 00000000..b712695f --- /dev/null +++ b/app/src/lib/dashboard/__tests__/neodash-converter.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect } from "vitest"; +import { + isNeoDashFormat, + convertNeoDashWithNotes, + inferParameterType, + extractParamReferences, +} from "@/lib/dashboard/neodash-converter"; + +// --------------------------------------------------------------------------- +// Fixture builders +// --------------------------------------------------------------------------- + +function makeReport( + overrides: Partial<{ + id: string; + title: string; + type: string; + query: string; + settings: Record; + }> = {}, +) { + return { + id: overrides.id ?? "r1", + title: overrides.title ?? "Report", + type: overrides.type ?? "table", + query: overrides.query ?? "MATCH (n) RETURN n", + x: 0, + y: 0, + width: 6, + height: 4, + settings: overrides.settings ?? {}, + parameters: {}, + }; +} + +function makeNeoDash( + reports: ReturnType[], + settings?: { parameters?: Record }, +) { + return { + title: "Test Dashboard", + version: "2.4", + pages: [ + { + title: "Page 1", + reports, + }, + ], + ...(settings ? { settings } : {}), + }; +} + +// --------------------------------------------------------------------------- +// isNeoDashFormat +// --------------------------------------------------------------------------- + +describe("isNeoDashFormat", () => { + it("recognizes a NeoDash v2.x dashboard", () => { + expect(isNeoDashFormat(makeNeoDash([makeReport()]))).toBe(true); + }); + + it("rejects null / undefined / non-objects", () => { + expect(isNeoDashFormat(null)).toBe(false); + expect(isNeoDashFormat(undefined)).toBe(false); + expect(isNeoDashFormat("string")).toBe(false); + expect(isNeoDashFormat(42)).toBe(false); + }); + + it("rejects arrays", () => { + expect(isNeoDashFormat([])).toBe(false); + }); + + it("rejects objects without pages", () => { + expect(isNeoDashFormat({ title: "x" })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// inferParameterType +// --------------------------------------------------------------------------- + +describe("inferParameterType", () => { + it("returns multi-select for arrays", () => { + expect(inferParameterType([])).toBe("multi-select"); + expect(inferParameterType(["a", "b"])).toBe("multi-select"); + }); + + it("returns number-range for finite numbers", () => { + expect(inferParameterType(0)).toBe("number-range"); + expect(inferParameterType(42)).toBe("number-range"); + expect(inferParameterType(3.14)).toBe("number-range"); + }); + + it("does not return number-range for NaN / Infinity", () => { + expect(inferParameterType(Number.NaN)).toBe("select"); + expect(inferParameterType(Number.POSITIVE_INFINITY)).toBe("select"); + }); + + it("returns text for empty string", () => { + expect(inferParameterType("")).toBe("text"); + }); + + it("returns select for non-empty strings (NeoDash's most common case)", () => { + expect(inferParameterType("foo")).toBe("select"); + expect(inferParameterType("Y")).toBe("select"); + expect(inferParameterType("N")).toBe("select"); + }); + + it("returns select for null / undefined / objects", () => { + expect(inferParameterType(null)).toBe("select"); + expect(inferParameterType(undefined)).toBe("select"); + expect(inferParameterType({})).toBe("select"); + }); +}); + +// --------------------------------------------------------------------------- +// extractParamReferences +// --------------------------------------------------------------------------- + +describe("extractParamReferences", () => { + it("extracts $param_xxx names from queries", () => { + const refs = extractParamReferences([ + "MATCH (n) WHERE n.name = $param_userName RETURN n", + "MATCH (m) WHERE m.year > $param_year RETURN m", + ]); + expect([...refs].sort()).toEqual(["userName", "year"]); + }); + + it("returns unique names when referenced multiple times", () => { + const refs = extractParamReferences(["$param_x + $param_x + $param_y"]); + expect([...refs].sort()).toEqual(["x", "y"]); + }); + + it("ignores $paramX without underscore", () => { + const refs = extractParamReferences(["$paramFoo"]); + expect(refs.size).toBe(0); + }); + + it("ignores bare param_xxx without leading $", () => { + const refs = extractParamReferences(["param_foo"]); + expect(refs.size).toBe(0); + }); + + it("skips empty / undefined queries", () => { + const refs = extractParamReferences(["", "$param_x"]); + expect([...refs]).toEqual(["x"]); + }); +}); + +// --------------------------------------------------------------------------- +// convertNeoDashWithNotes — markdown content +// --------------------------------------------------------------------------- + +describe("convertNeoDashWithNotes — markdown widgets", () => { + it("moves text report.query into settings.content and clears widget.query", () => { + const nd = makeNeoDash([ + makeReport({ + type: "text", + title: "Welcome", + query: "## Hello\n\nMarkdown content here.", + }), + ]); + + const { export: exp, notes } = convertNeoDashWithNotes(nd); + const widget = exp.layout.pages[0].widgets[0]; + + expect(widget.chartType).toBe("markdown"); + expect(widget.query).toBe(""); + expect((widget.settings as Record).content).toBe( + "## Hello\n\nMarkdown content here.", + ); + expect(notes).toContain('Imported markdown content for "Welcome"'); + }); + + it("handles 'markdown' type the same as 'text'", () => { + const nd = makeNeoDash([ + makeReport({ + type: "markdown", + title: "Notes", + query: "**bold**", + }), + ]); + + const { export: exp } = convertNeoDashWithNotes(nd); + const widget = exp.layout.pages[0].widgets[0]; + expect(widget.chartType).toBe("markdown"); + expect(widget.query).toBe(""); + expect((widget.settings as Record).content).toBe( + "**bold**", + ); + }); + + it("does not add a markdown note when report.query is empty", () => { + const nd = makeNeoDash([ + makeReport({ type: "text", title: "Empty MD", query: "" }), + ]); + const { notes } = convertNeoDashWithNotes(nd); + expect(notes.some((n) => n.includes("Imported markdown content"))).toBe( + false, + ); + }); + + it("leaves non-markdown widgets' query in place (no settings.content)", () => { + const nd = makeNeoDash([ + makeReport({ + type: "bar", + title: "Bar", + query: "MATCH (n) RETURN n.year, count(*)", + }), + ]); + const widget = + convertNeoDashWithNotes(nd).export.layout.pages[0].widgets[0]; + expect(widget.query).toBe("MATCH (n) RETURN n.year, count(*)"); + expect( + (widget.settings as Record).content, + ).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// convertNeoDashWithNotes — parameter widgets +// --------------------------------------------------------------------------- + +describe("convertNeoDashWithNotes — parameter widgets", () => { + it("creates a parameter-select widget for each referenced + defined param", () => { + const nd = makeNeoDash( + [ + makeReport({ + query: "MATCH (n) WHERE n.year = $neodash_year RETURN n", + }), + ], + { parameters: { neodash_year: 2024 } }, + ); + + const { export: exp, notes } = convertNeoDashWithNotes(nd); + expect(exp.layout.pages).toHaveLength(2); // Filters + original + expect(exp.layout.pages[0].title).toBe("Filters"); + const filterWidget = exp.layout.pages[0].widgets[0]; + expect(filterWidget.chartType).toBe("parameter-select"); + const s = filterWidget.settings as Record; + expect(s.parameterName).toBe("year"); + expect(s.parameterType).toBe("number-range"); + expect(s.defaultValue).toBe(2024); + expect(notes.some((n) => n.includes("$param_year"))).toBe(true); + + // Verify the original widget's query was rewritten from $neodash_year + // → $param_year (CR finding: the test asserted filter creation but not + // the parallel query-syntax conversion). + const originalWidget = exp.layout.pages[1].widgets[0]; + expect(originalWidget.query).toContain("$param_year"); + expect(originalWidget.query).not.toContain("$neodash_year"); + }); + + it("skips parameters that are defined but never referenced", () => { + const nd = makeNeoDash([makeReport({ query: "MATCH (n) RETURN n" })], { + parameters: { neodash_unused: "x", neodash_other: 5 }, + }); + + const { export: exp, notes } = convertNeoDashWithNotes(nd); + expect(exp.layout.pages).toHaveLength(1); // No Filters page + expect(notes.filter((n) => n.includes("never referenced"))).toHaveLength(2); + }); + + it("creates parameter-select for referenced-but-undefined params with a warning note", () => { + // Use realistic NeoDash syntax — convertParamSyntax rewrites it to $param_, + // and the extractor sees the rewritten form (CR finding: tests should + // exercise the conversion path, not bypass it). + const nd = makeNeoDash([ + makeReport({ + query: "MATCH (n) WHERE n.name = $neodash_undeclared RETURN n", + }), + ]); + + const { export: exp, notes } = convertNeoDashWithNotes(nd); + expect(exp.layout.pages).toHaveLength(2); + const filterWidget = exp.layout.pages[0].widgets[0]; + expect( + (filterWidget.settings as Record).parameterName, + ).toBe("undeclared"); + expect( + (filterWidget.settings as Record).defaultValue, + ).toBeUndefined(); + expect(notes.some((n) => n.includes("not defined in NeoDash"))).toBe(true); + }); + + it("infers types correctly per default value", () => { + // Use real NeoDash $neodash_ syntax so the conversion path is exercised + // (CR finding: pre-converted $param_ bypasses convertParamSyntax). + const nd = makeNeoDash( + [ + makeReport({ + query: + "$neodash_str $neodash_emp $neodash_arr $neodash_num $neodash_yn", + }), + ], + { + parameters: { + neodash_str: "value", + neodash_emp: "", + neodash_arr: ["a"], + neodash_num: 10, + neodash_yn: "Y", + }, + }, + ); + + const { export: exp } = convertNeoDashWithNotes(nd); + const byName = Object.fromEntries( + exp.layout.pages[0].widgets.map((w) => [ + (w.settings as Record).parameterName as string, + (w.settings as Record).parameterType as string, + ]), + ); + expect(byName.str).toBe("select"); + expect(byName.emp).toBe("text"); + expect(byName.arr).toBe("multi-select"); + expect(byName.num).toBe("number-range"); + expect(byName.yn).toBe("select"); + }); + + it("strips the 'neodash_' prefix from parameter names", () => { + const nd = makeNeoDash([makeReport({ query: "$neodash_userId" })], { + parameters: { neodash_userId: "alice" }, + }); + + const { export: exp } = convertNeoDashWithNotes(nd); + expect( + (exp.layout.pages[0].widgets[0].settings as Record) + .parameterName, + ).toBe("userId"); + }); + + it("does not create a Filters page when no params are referenced", () => { + const nd = makeNeoDash([makeReport({ query: "MATCH (n) RETURN n" })]); + const { export: exp } = convertNeoDashWithNotes(nd); + expect(exp.layout.pages).toHaveLength(1); + expect(exp.layout.pages[0].title).toBe("Page 1"); + }); + + it("tiles param widgets 4-per-row at w=3 h=2", () => { + const params: Record = {}; + const queryParts: string[] = []; + for (let i = 0; i < 6; i++) { + params[`neodash_p${i}`] = `v${i}`; + queryParts.push(`$neodash_p${i}`); + } + const nd = makeNeoDash([makeReport({ query: queryParts.join(" ") })], { + parameters: params, + }); + + const { export: exp } = convertNeoDashWithNotes(nd); + const filtersGrid = exp.layout.pages[0].gridLayout; + expect(filtersGrid).toHaveLength(6); + // Row 0: 4 widgets at y=0, x=0/3/6/9 + expect(filtersGrid.slice(0, 4).map((g) => g.y)).toEqual([0, 0, 0, 0]); + expect(filtersGrid.slice(0, 4).map((g) => g.x)).toEqual([0, 3, 6, 9]); + // Row 1: 2 widgets at y=2, x=0/3 + expect(filtersGrid.slice(4, 6).map((g) => g.y)).toEqual([2, 2]); + expect(filtersGrid.slice(4, 6).map((g) => g.x)).toEqual([0, 3]); + // Every widget at w=3 h=2 + expect(filtersGrid.every((g) => g.w === 3 && g.h === 2)).toBe(true); + }); + + it("number-range pre-populates rangeMin=min(default, 0) and rangeMax=max(default, 100)", () => { + const nd = makeNeoDash( + [ + makeReport({ + query: "$neodash_small $neodash_big $neodash_neg", + }), + ], + { parameters: { neodash_small: 5, neodash_big: 500, neodash_neg: -10 } }, + ); + + const { export: exp } = convertNeoDashWithNotes(nd); + const byName = Object.fromEntries( + exp.layout.pages[0].widgets.map((w) => [ + (w.settings as Record).parameterName as string, + w.settings as Record, + ]), + ); + expect(byName.small.rangeMin).toBe(0); + expect(byName.small.rangeMax).toBe(100); // max(5, 100) + expect(byName.big.rangeMin).toBe(0); + expect(byName.big.rangeMax).toBe(500); + // CR caught: negative defaults need rangeMin to widen below 0 + expect(byName.neg.rangeMin).toBe(-10); + expect(byName.neg.rangeMax).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// convertNeoDashWithNotes — connectionId default +// --------------------------------------------------------------------------- + +describe("convertNeoDashWithNotes — defaultConnectionId", () => { + it("stamps the provided id on every widget", () => { + const nd = makeNeoDash([makeReport({ id: "a" }), makeReport({ id: "b" })]); + const { export: exp } = convertNeoDashWithNotes(nd, "conn-123"); + for (const w of exp.layout.pages[0].widgets) { + expect(w.connectionId).toBe("conn-123"); + } + }); + + it("falls back to empty string when omitted", () => { + const nd = makeNeoDash([makeReport()]); + const { export: exp } = convertNeoDashWithNotes(nd); + expect(exp.layout.pages[0].widgets[0].connectionId).toBe(""); + }); + + it("filter widgets always have connectionId='' (no connection needed)", () => { + const nd = makeNeoDash([makeReport({ query: "$param_x" })], { + parameters: { neodash_x: "v" }, + }); + const { export: exp } = convertNeoDashWithNotes(nd, "conn-123"); + // Original page widgets get the stamped connection + expect(exp.layout.pages[1].widgets[0].connectionId).toBe("conn-123"); + // Filter widgets are parameter-select, no query, no connection + expect(exp.layout.pages[0].widgets[0].connectionId).toBe(""); + }); +}); diff --git a/app/src/lib/dashboard/neodash-converter.ts b/app/src/lib/dashboard/neodash-converter.ts index 4269f06f..9e621703 100644 --- a/app/src/lib/dashboard/neodash-converter.ts +++ b/app/src/lib/dashboard/neodash-converter.ts @@ -204,6 +204,51 @@ interface NeoDashJson { description?: string; version?: string; pages: NeoDashPage[]; + /** + * NeoDash stores dashboard-wide parameters here. NeoBoard models + * parameters as outputs of explicit parameter-select widgets, so the + * converter auto-generates one widget per *referenced* parameter + * (unreferenced ones are dropped with a note). + */ + settings?: { + parameters?: Record; + [key: string]: unknown; + }; +} + +/** + * Inferred parameter-select `parameterType` from a NeoDash default value. + * + * NeoDash didn't track the parameter type — the value shape is the only + * hint we have. The user can change the type in the widget editor. + */ +type ParameterSelectType = "select" | "text" | "multi-select" | "number-range"; + +export function inferParameterType(value: unknown): ParameterSelectType { + if (Array.isArray(value)) return "multi-select"; + if (typeof value === "number" && Number.isFinite(value)) + return "number-range"; + if (typeof value === "string" && value === "") return "text"; + // "Y" / "N" / arbitrary string / null / undefined / object — default to select + return "select"; +} + +/** + * Extract every `$param_` reference from a list of widget queries. + * Returns the set of unique parameter names (without the `$param_` prefix). + * + * Run AFTER convertParamSyntax has rewritten `$neodash_*` → `$param_*`. + */ +export function extractParamReferences(queries: string[]): Set { + const names = new Set(); + const re = /\$param_(\w+)/g; + for (const q of queries) { + if (!q) continue; + for (const match of q.matchAll(re)) { + names.add(match[1]); + } + } + return names; } export interface ConversionResult { @@ -290,11 +335,19 @@ export function convertNeoDashWithNotes( const refreshSettings = convertRefreshRate(reportSettings); const paramDefaults = convertParameterDefaults(reportSettings); + // Markdown widget content lives in `settings.content`, not `query`. + // NeoDash stored markdown body in `report.query`; route it correctly + // and leave the widget's query empty (markdown is content-only). + const isMarkdown = chartType === "markdown"; + if (isMarkdown && report.query) { + notes.push('Imported markdown content for "' + report.title + '"'); + } + widgets.push({ id: widgetId, chartType, connectionId: defaultConnectionId, - query: convertParamSyntax(report.query ?? ""), + query: isMarkdown ? "" : convertParamSyntax(report.query ?? ""), params: report.parameters ?? {}, settings: { ...reportSettings, @@ -302,6 +355,8 @@ export function convertNeoDashWithNotes( ...(report.title ? { title: report.title } : {}), // Set area mode for NeoDash "area" chart type ...(originalType === "area" ? { chartOptions: { area: true } } : {}), + // Markdown: content moved out of report.query + ...(isMarkdown ? { content: report.query ?? "" } : {}), // Mapped settings ...(clickAction ? { clickAction } : {}), ...(stylingConfig ? { stylingConfig } : {}), @@ -327,6 +382,16 @@ export function convertNeoDashWithNotes( }; }); + // Auto-generate parameter-select widgets for every $param_* referenced + // in widget queries. NeoDash's dashboard-wide params don't map to a + // NeoBoard concept directly; the closest is a parameter-select widget + // that produces the value when rendered. Prepend them as a "Filters" + // page so they're visible before the data pages. + const filtersPage = buildFiltersPage(nd.settings?.parameters, pages, notes); + if (filtersPage) { + pages.unshift(filtersPage); + } + const layout: DashboardLayoutV2 = { version: 2, pages, @@ -346,3 +411,130 @@ export function convertNeoDashWithNotes( notes, }; } + +/** + * Build the auto-generated "Filters" page from NeoDash's dashboard-wide + * parameters. Returns null when no widgets would be created (no params + * referenced in any query, or no params at all). + * + * Walks two sets: + * 1. Defined in `nd.settings.parameters` → create widget if referenced; + * skip with note otherwise + * 2. Referenced in queries but not defined → create with no default + warn + */ +function buildFiltersPage( + ndParams: Record | undefined, + pages: { widgets: DashboardWidget[] }[], + notes: string[], +): { + id: string; + title: string; + widgets: DashboardWidget[]; + gridLayout: GridLayoutItem[]; +} | null { + const params = ndParams ?? {}; + const referenced = extractParamReferences( + pages.flatMap((p) => p.widgets.map((w) => w.query ?? "")), + ); + const definedNames = new Set(Object.keys(params)); + const widgets: DashboardWidget[] = []; + const gridLayout: GridLayoutItem[] = []; + + // 1. Iterate defined params: create if referenced, drop if not. + for (const rawName of Object.keys(params)) { + const value = params[rawName]; + const paramName = rawName.startsWith("neodash_") + ? rawName.slice("neodash_".length) + : rawName; + + if (!referenced.has(paramName)) { + notes.push( + "Parameter $param_" + + paramName + + " was defined in NeoDash but never referenced in any query — skipped", + ); + continue; + } + + const paramType = inferParameterType(value); + addFilterWidget(widgets, gridLayout, paramName, paramType, value); + notes.push( + "Created parameter-select widget for $param_" + + paramName + + " (type: " + + paramType + + ")", + ); + } + + // 2. Referenced but never defined: create with no default + warn. + for (const paramName of referenced) { + if ( + definedNames.has(paramName) || + definedNames.has("neodash_" + paramName) + ) { + continue; + } + addFilterWidget(widgets, gridLayout, paramName, "select", undefined); + notes.push( + "Created parameter-select widget for $param_" + + paramName + + " with no default (referenced in query but not defined in NeoDash settings)", + ); + } + + if (widgets.length === 0) return null; + return { + id: crypto.randomUUID(), + title: "Filters", + widgets, + gridLayout, + }; +} + +/** + * Tile a new parameter-select widget into the Filters page grid. + * Layout: 4 widgets per row at w=3, h=2 (Filters page is 12 cols wide). + */ +function addFilterWidget( + widgets: DashboardWidget[], + gridLayout: GridLayoutItem[], + parameterName: string, + parameterType: ParameterSelectType, + defaultValue: unknown, +): void { + const id = crypto.randomUUID(); + const index = widgets.length; + const x = (index % 4) * 3; + const y = Math.floor(index / 4) * 2; + + const settings: Record = { + title: parameterName, + parameterName, + parameterType, + }; + // Pre-populate the default when we know it. We don't try to reverse-engineer + // the seed query for select-typed params from a hard-coded default; the user + // can wire the seed query in the editor. + if (defaultValue !== undefined) { + settings.defaultValue = defaultValue; + } + if (parameterType === "number-range" && typeof defaultValue === "number") { + // rangeMin/rangeMax must include defaultValue. CodeRabbit caught the + // negative-default bug: a default of -5 with rangeMin=0 would be outside + // the range. min(default, 0) keeps the floor at 0 for non-negative + // defaults (common case) while widening for negatives. + settings.rangeMin = Math.min(defaultValue, 0); + settings.rangeMax = Math.max(defaultValue, 100); + } + + widgets.push({ + id, + chartType: "parameter-select", + connectionId: "", + query: "", + params: {}, + settings, + }); + gridLayout.push({ i: id, x, y, w: 3, h: 2 }); +}