-
Notifications
You must be signed in to change notification settings - Fork 0
fix(import): preserve NeoDash markdown + auto-generate parameter widgets #936
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
Merged
alfredo1996
merged 2 commits into
release/1.1
from
fix/issue-915-neodash-converter-fidelity
Jun 4, 2026
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
399 changes: 399 additions & 0 deletions
399
app/src/lib/dashboard/__tests__/neodash-converter.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,399 @@ | ||
| 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<string, unknown>; | ||
| }> = {}, | ||
| ) { | ||
| 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<typeof makeReport>[], | ||
| settings?: { parameters?: Record<string, unknown> }, | ||
| ) { | ||
| 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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>; | ||
| 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); | ||
| }); | ||
|
|
||
| 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", () => { | ||
| const nd = makeNeoDash([ | ||
| makeReport({ | ||
| query: "MATCH (n) WHERE n.name = $param_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<string, unknown>).parameterName, | ||
| ).toBe("undeclared"); | ||
| expect( | ||
| (filterWidget.settings as Record<string, unknown>).defaultValue, | ||
| ).toBeUndefined(); | ||
| expect(notes.some((n) => n.includes("not defined in NeoDash"))).toBe(true); | ||
| }); | ||
|
|
||
| it("infers types correctly per default value", () => { | ||
| const nd = makeNeoDash( | ||
| [ | ||
| makeReport({ | ||
| query: "$param_str $param_emp $param_arr $param_num $param_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<string, unknown>).parameterName as string, | ||
| (w.settings as Record<string, unknown>).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"); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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<string, unknown>) | ||
| .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<string, unknown> = {}; | ||
| const queryParts: string[] = []; | ||
| for (let i = 0; i < 6; i++) { | ||
| params[`neodash_p${i}`] = `v${i}`; | ||
| queryParts.push(`$param_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); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| it("number-range pre-populates rangeMin=0 and rangeMax=max(default, 100)", () => { | ||
| const nd = makeNeoDash([makeReport({ query: "$param_small $param_big" })], { | ||
| parameters: { neodash_small: 5, neodash_big: 500 }, | ||
| }); | ||
|
|
||
| const { export: exp } = convertNeoDashWithNotes(nd); | ||
| const byName = Object.fromEntries( | ||
| exp.layout.pages[0].widgets.map((w) => [ | ||
| (w.settings as Record<string, unknown>).parameterName as string, | ||
| w.settings as Record<string, unknown>, | ||
| ]), | ||
| ); | ||
| 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); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // 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(""); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.