diff --git a/app/e2e/code-completion.spec.ts b/app/e2e/code-completion.spec.ts index 9ea1d647..1cf0fec5 100644 --- a/app/e2e/code-completion.spec.ts +++ b/app/e2e/code-completion.spec.ts @@ -99,21 +99,38 @@ async function triggerCypherAutocomplete( } /** - * Click inside the CM editor content area and type text via keyboard events. - * After typing, re-clicks the editor to ensure focus is fully settled — the - * neo4j-cypher editor's completion keymap requires a settled focus state that - * keyboard.type() alone doesn't guarantee. + * Insert exact text into the CM editor via CM6 dispatch (bypasses closeBrackets + * auto-insertion that corrupts partial Cypher like "MATCH (n:" → "MATCH (n:)"). + * After dispatch, clicks the editor to ensure focus for keyboard shortcuts. */ async function typeInCmEditor( dialog: Locator, page: Page, text: string, ): Promise { - const cm = dialog.locator("[data-testid='codemirror-container'] .cm-content"); - await cm.click(); - await page.keyboard.type(text, { delay: 30 }); + const cmContainer = dialog.locator("[data-testid='codemirror-container']"); + const cm = cmContainer.locator(".cm-content"); + + // Use CM6 dispatch to set exact text without closeBrackets interference + await cmContainer.evaluate((el: HTMLElement, t: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } + const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor")); + if (!view) throw new Error("CM6 view not found for typeInCmEditor"); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: t }, + selection: { anchor: t.length }, + }); + }, text); + // Re-focus: the Cypher editor's CM6 completion keymap needs a settled focus - // state after programmatic typing. Without this, Ctrl+Space may not trigger. + // state after programmatic dispatch. Without this, Ctrl+Space may not trigger. + // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(100); await cm.click(); } diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 68f330f5..6bbbc2b1 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -106,16 +106,19 @@ export async function typeInEditor( } // Strategy 1: Use CM6's internal dispatch API (most reliable). - // In CM6 v6.x, each DOM node managed by the editor has a `cmTile` - // property (Tile instance). The `.cm-content` element's cmTile is a - // DocTile whose `.root.view` yields the EditorView. This mirrors the - // logic of the static `EditorView.findFromDOM()` method. + // CM6 decorates managed DOM nodes with a `cmTile` property (Tile instance). + // We mirror EditorView.findFromDOM(): try .cm-content first, then .cm-editor. const dispatched = await cmContainer.evaluate((el: HTMLElement, text: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } const cmContent = el.querySelector(".cm-content"); if (!cmContent) return "no-editor"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tile = (cmContent as any).cmTile; - const view = tile?.root?.view ?? tile?.view; + const view = findView(cmContent) ?? findView(el.querySelector(".cm-editor")); if (!view) return "no-view"; if (view.state.readOnly) return "readonly"; @@ -134,11 +137,14 @@ export async function typeInEditor( // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(300); const stillPresent = await cmContainer.evaluate((el: HTMLElement, text: string) => { - const c = el.querySelector(".cm-content"); - if (!c) return false; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tile = (c as any).cmTile; - const view = tile?.root?.view ?? tile?.view; + function findView(node: Element | null): any { + if (!node) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tile = (node as any).cmTile; + return tile?.root?.view ?? tile?.view ?? null; + } + const view = findView(el.querySelector(".cm-content")) ?? findView(el.querySelector(".cm-editor")); if (!view) return false; return view.state.doc.toString().includes(text.substring(0, 20)); }, query); @@ -148,8 +154,9 @@ export async function typeInEditor( return; } - // Strategy 2: Keyboard fallback (for environments where cmView is not accessible) - if (dispatched === "no-view") { + // Strategy 2: Keyboard fallback (for environments where cmView is not accessible + // or when the view is temporarily readonly during initialization) + if (dispatched === "no-view" || dispatched === "readonly") { await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 }); await cm.click(); await page.keyboard.press("ControlOrMeta+a"); @@ -162,7 +169,7 @@ export async function typeInEditor( return; } - // Retry-worthy states: no-editor, readonly, dispatch-failed + // Retry-worthy states: no-editor, dispatch-failed throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); }).toPass({ timeout: 20_000 }); } diff --git a/app/e2e/form-widget.spec.ts b/app/e2e/form-widget.spec.ts index ef0ed2e8..a684416f 100644 --- a/app/e2e/form-widget.spec.ts +++ b/app/e2e/form-widget.spec.ts @@ -1,4 +1,10 @@ -import { test, expect, ALICE, createTestDashboard, typeInEditor } from "./fixtures"; +import { + test, + expect, + ALICE, + createTestDashboard, + typeInEditor, +} from "./fixtures"; test.describe("Form widget", () => { let dashboardCleanup: (() => Promise) | undefined; @@ -33,7 +39,9 @@ test.describe("Form widget", () => { await page.getByRole("option").first().click(); // Write a write query - await typeInEditor(dialog, page, + await typeInEditor( + dialog, + page, "CREATE (n:FormTestNode {name: $param_name, email: $param_email})", ); @@ -56,11 +64,13 @@ test.describe("Form widget", () => { await paramInputs.nth(1).fill("email"); // Preview should show two labeled placeholders + Submit button - await expect(dialog.getByText("Author", { exact: true })).toBeVisible({ timeout: 5_000 }); - await expect(dialog.getByText("Message", { exact: true })).toBeVisible({ timeout: 5_000 }); - await expect( - dialog.getByRole("button", { name: "Submit" }), - ).toBeVisible(); + await expect(dialog.getByText("Author", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + await expect(dialog.getByText("Message", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + await expect(dialog.getByRole("button", { name: "Submit" })).toBeVisible(); // Add the widget (connection + query required) await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -79,7 +89,9 @@ test.describe("Form widget", () => { await dialog.getByRole("combobox").nth(0).click(); await page.getByRole("option").first().click(); - await typeInEditor(dialog, page, + await typeInEditor( + dialog, + page, "CREATE (n:FormTestNode {firstName: $param_firstName, age: $param_age_min})", ); @@ -116,13 +128,11 @@ test.describe("Form widget", () => { // The form widget should render with the configured fields // Fields default to required=true, so the label includes an asterisk "*". // Use regex anchored at start to avoid matching "Page 1" tab or param hints. - await expect( - page.getByText(/^First Name/), - ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/^First Name/)).toBeVisible({ + timeout: 15_000, + }); await expect(page.getByText(/^Age/)).toBeVisible(); - await expect( - page.getByRole("button", { name: "Submit" }), - ).toBeVisible(); + await expect(page.getByRole("button", { name: "Submit" })).toBeVisible(); }); test("should submit form widget and see success message", async ({ @@ -137,7 +147,9 @@ test.describe("Form widget", () => { await dialog.getByRole("combobox").nth(0).click(); await page.getByRole("option").first().click(); - await typeInEditor(dialog, page, + await typeInEditor( + dialog, + page, "CREATE (n:FormE2ETest {name: $param_name}) RETURN n.name AS name", ); @@ -218,7 +230,9 @@ test.describe("Form widget", () => { // No fields added — preview shows placeholder message await expect( - dialog.getByText("Add fields in the Fields section below to see the form preview"), + dialog.getByText( + "Add fields in the Fields section below to see the form preview", + ), ).toBeVisible({ timeout: 5_000 }); // Save button is enabled (query + connection are set) @@ -244,9 +258,9 @@ test.describe("Form widget", () => { await expect(page).not.toHaveURL(/\/edit$/, { timeout: 10_000 }); // The form widget should show the empty-state message - await expect( - page.getByText("No fields configured"), - ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("No fields configured")).toBeVisible({ + timeout: 15_000, + }); }); test("required field blocks submit and shows error when empty", async ({ @@ -260,7 +274,9 @@ test.describe("Form widget", () => { await dialog.getByRole("combobox").nth(0).click(); await page.getByRole("option").first().click(); - await typeInEditor(dialog, page, + await typeInEditor( + dialog, + page, "CREATE (n:FormReqTest {name: $param_name})", ); @@ -275,7 +291,9 @@ test.describe("Form widget", () => { dialog.getByRole("checkbox", { name: "Required" }), ).toBeChecked(); - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ timeout: 10_000 }); + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000 }); await dialog.getByRole("button", { name: "Add Widget" }).click(); await expect(dialog).not.toBeVisible(); @@ -305,9 +323,7 @@ test.describe("Form widget", () => { await page.getByRole("button", { name: "Submit" }).click(); // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(1_000); - await expect( - page.getByText("This field is required"), - ).toBeVisible(); + await expect(page.getByText("This field is required")).toBeVisible(); }).toPass({ timeout: 10_000 }); // Fill the required field — error should clear @@ -341,13 +357,17 @@ test.describe("Form widget", () => { await tableDialog.getByRole("combobox").nth(0).click(); await page.getByRole("option").first().click(); - await typeInEditor(tableDialog, page, + await typeInEditor( + tableDialog, + page, "MATCH (n:FormRefreshNode) RETURN n.name AS name LIMIT 10", ); // Set title for easy identification await tableDialog.getByLabel("Widget Title").fill("Refresh Target"); - await expect(tableDialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)")).toBeEnabled({ timeout: 10_000 }); + await expect( + tableDialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)"), + ).toBeEnabled({ timeout: 10_000 }); await tableDialog.getByTitle("Run query (Ctrl+Enter / ⌘+Enter)").click(); await expect( tableDialog.locator("[data-testid='base-chart'], table").first(), @@ -365,7 +385,9 @@ test.describe("Form widget", () => { await formDialog.getByRole("combobox").nth(0).click(); await page.getByRole("option").first().click(); - await typeInEditor(formDialog, page, + await typeInEditor( + formDialog, + page, "CREATE (n:FormRefreshNode {name: $param_name})", ); @@ -376,12 +398,10 @@ test.describe("Form widget", () => { // Go to Advanced tab and enable refresh for the table widget await formDialog.getByRole("tab", { name: "Advanced" }).click(); - await expect( - formDialog.getByText("Refresh Target"), - ).toBeVisible({ timeout: 5_000 }); - await formDialog - .getByRole("checkbox", { name: "Refresh Target" }) - .click(); + await expect(formDialog.getByText("Refresh Target")).toBeVisible({ + timeout: 5_000, + }); + await formDialog.getByRole("checkbox", { name: "Refresh Target" }).click(); await formDialog.getByRole("button", { name: "Add Widget" }).click(); await expect(formDialog).not.toBeVisible(); @@ -423,8 +443,7 @@ test.describe("Form widget", () => { // Listen for the write query API response (tighten matcher to this form's POST) const writeResponsePromise = page.waitForResponse( (r) => - r.url().includes("/api/query/write") && - r.request().method() === "POST", + r.url().includes("/api/query/write") && r.request().method() === "POST", { timeout: 15_000 }, ); @@ -440,6 +459,78 @@ test.describe("Form widget", () => { }); }); + test("form field should be pre-populated from a parameter-select widget", async ({ + page, + }) => { + test.setTimeout(90_000); + + // --- Widget 1: Parameter-select that sets $param_movie_title --- + await page.getByRole("button", { name: "Add Widget" }).first().click(); + let dialog = page.getByRole("dialog", { name: "Add Widget" }); + + await dialog.getByRole("combobox").nth(1).click(); + await page.getByRole("option", { name: "Parameter Selector" }).click(); + await dialog.getByRole("combobox").nth(0).click(); + await page.getByRole("option").first().click(); + + await dialog + .locator("#seed-query") + .fill("MATCH (m:Movie) RETURN DISTINCT m.title ORDER BY m.title LIMIT 5"); + await dialog.getByLabel("Parameter Name").fill("movie_title"); + + await dialog.getByRole("button", { name: "Add Widget" }).click(); + await expect(dialog).not.toBeVisible(); + + // --- Widget 2: Form with a text field named "movie_title" (same as param) --- + await page.getByRole("button", { name: "Add Widget" }).first().click(); + dialog = page.getByRole("dialog", { name: "Add Widget" }); + + await dialog.getByRole("combobox").nth(1).click(); + await page.getByRole("option", { name: "Form" }).click(); + await dialog.getByRole("combobox").nth(0).click(); + await page.getByRole("option").first().click(); + + await typeInEditor( + dialog, + page, + "CREATE (n:TestNode {title: $param_movie_title})", + ); + + // Add a text field whose parameterName matches the selector's parameter + await dialog.getByRole("button", { name: "Add Field" }).click(); + await dialog.getByPlaceholder("e.g. Movie Title").fill("Movie Title"); + await dialog.getByPlaceholder("e.g. title").fill("movie_title"); + + await dialog.getByRole("button", { name: "Add Widget" }).click(); + await expect(dialog).not.toBeVisible(); + + // Save and go to view mode + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("button", { name: "Save" })).toBeEnabled({ + timeout: 15_000, + }); + await page.getByRole("button", { name: "Back" }).click(); + const leaveBtn = page.getByRole("button", { name: "Leave" }); + if (await leaveBtn.isVisible({ timeout: 1_000 }).catch(() => false)) { + await leaveBtn.click(); + } + await expect(page).not.toHaveURL(/\/edit$/, { timeout: 10_000 }); + + // Wait for the parameter-select dropdown to render + const paramTrigger = page.getByText("Select a value…"); + await expect(paramTrigger).toBeVisible({ timeout: 15_000 }); + + // Select a movie from the dropdown + await paramTrigger.click(); + await expect(async () => { + await page.getByRole("option").first().click({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + // The form's "Movie Title" text field should be pre-populated with the selected value + const formInput = page.getByRole("textbox", { name: "movie_title" }); + await expect(formInput).not.toHaveValue("", { timeout: 10_000 }); + }); + test("form widget requires connection and query to save", async ({ page, }) => { @@ -527,27 +618,33 @@ test.describe("Write permission enforcement", () => { isPublic: true, layoutJson: { version: 2, - pages: [{ - id: "page-1", - title: "Page 1", - widgets: [{ - id: "w-form", - chartType: "form", - connectionId: "conn-neo4j-001", - query: "CREATE (n:PermTest {v: $param_v}) RETURN n.v AS v", - settings: { - title: "Form", - formFields: [{ - id: "f1", - label: "Value", - parameterName: "v", - parameterType: "text", - required: true, - }], - }, - }], - gridLayout: [{ i: "w-form", x: 0, y: 0, w: 12, h: 4 }], - }], + pages: [ + { + id: "page-1", + title: "Page 1", + widgets: [ + { + id: "w-form", + chartType: "form", + connectionId: "conn-neo4j-001", + query: "CREATE (n:PermTest {v: $param_v}) RETURN n.v AS v", + settings: { + title: "Form", + formFields: [ + { + id: "f1", + label: "Value", + parameterName: "v", + parameterType: "text", + required: true, + }, + ], + }, + }, + ], + gridLayout: [{ i: "w-form", x: 0, y: 0, w: 12, h: 4 }], + }, + ], }, }, }); @@ -618,9 +715,9 @@ test.describe("Write permission enforcement", () => { await page.getByRole("button", { name: "Submit" }).click(); // The form widget renders the API error inline - await expect( - page.getByText("Write permission required"), - ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Write permission required")).toBeVisible({ + timeout: 10_000, + }); // Screenshot: 403 error displayed inside the form widget await page.screenshot({ diff --git a/app/e2e/styling-rules.spec.ts b/app/e2e/styling-rules.spec.ts index 0dba7aae..f669ea3d 100644 --- a/app/e2e/styling-rules.spec.ts +++ b/app/e2e/styling-rules.spec.ts @@ -121,7 +121,7 @@ test.describe("Styling rules — table widget", () => { await page.getByRole("button", { name: "Done" }).click(); }); - test("should show target column selector for table type", async ({ page }) => { + test("should show per-rule column selector for table type", async ({ page }) => { test.setTimeout(60_000); const dialog = await addTableAndGoToAdvanced(page); @@ -129,9 +129,10 @@ test.describe("Styling rules — table widget", () => { await dialog.getByLabel("Enable rule-based styling").click(); await dialog.getByRole("button", { name: "Manage Styling Rules" }).click(); - // Should show "Target Column" label (tables only) - await expect(page.getByText("Target Column")).toBeVisible(); - // The FieldSelectorInput placeholder + // Add a rule so we can see the per-rule column selector + await page.getByRole("button", { name: "Add Rule" }).click(); + // Each rule should have a "Column" field with per-rule column picker + await expect(page.getByText("Column")).toBeVisible(); await expect(page.getByText("Auto (first numeric)")).toBeVisible(); await page.getByRole("button", { name: "Done" }).click(); @@ -263,8 +264,8 @@ test.describe("Styling rules — bar chart", () => { await page.getByRole("button", { name: "Add Rule" }).click(); await expect(page.getByText("Rule 1")).toBeVisible(); - // "Target Column" should NOT be visible for bar charts (only tables) - await expect(page.getByText("Target Column")).not.toBeVisible(); + // Per-rule "Column" selector should NOT be visible for bar charts (only tables) + await expect(page.locator("text=Column").first()).not.toBeVisible(); // Click Done await page.getByRole("button", { name: "Done" }).click(); diff --git a/app/package-lock.json b/app/package-lock.json index bd252a27..6edcab01 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -58,12 +58,12 @@ "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.2", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.15", "@hookform/resolvers": "^5.2.2", - "@neo4j-cypher/codemirror": "^1.0.3", - "@neo4j-cypher/editor-support": "^1.0.2", + "@neo4j-cypher/language-support": "^2.0.0-next.30", "@neo4j-nvl/react": "^1.1.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/app/src/app/(dashboard)/[id]/edit/page.tsx b/app/src/app/(dashboard)/[id]/edit/page.tsx index e65a9abd..d6fdb188 100644 --- a/app/src/app/(dashboard)/[id]/edit/page.tsx +++ b/app/src/app/(dashboard)/[id]/edit/page.tsx @@ -304,7 +304,17 @@ export default function DashboardEditorPage({ setEditorOpen(true); } + const [cachedPreviewData, setCachedPreviewData] = useState< + { data: unknown; resultId: string } | undefined + >(); + function openEditWidget(widget: DashboardWidget) { + // Grab cached query data so the editor preview shows instantly + const cached = queryClient.getQueryData<{ + data: unknown; + resultId: string; + }>(["widget-query", widget.connectionId, widget.query, undefined]); + setCachedPreviewData(cached ?? undefined); setEditorMode("edit"); setEditingWidget(widget); setEditorOpen(true); @@ -475,6 +485,7 @@ export default function DashboardEditorPage({ initialTemplate={ pendingTemplateId ? templateMap[pendingTemplateId] : undefined } + initialPreviewData={editorMode === "edit" ? cachedPreviewData : undefined} /> {templateWidget && diff --git a/app/src/components/card-container.tsx b/app/src/components/card-container.tsx index 9261043c..1b2077b2 100644 --- a/app/src/components/card-container.tsx +++ b/app/src/components/card-container.tsx @@ -5,6 +5,7 @@ import { resolveCacheOptions } from "@/lib/resolve-cache-options"; import { getChartConfig } from "@/lib/chart-registry"; import type { ChartType, ColumnMapping } from "@/lib/chart-registry"; import type { DashboardWidget, ClickAction, StylingConfig } from "@/lib/db/schema"; +import type { ColorScaleConfig } from "@neoboard/components"; import { useParameterStore, useParameterValues } from "@/stores/parameter-store"; import { resolveClickActions, deriveClickableColumns } from "@/lib/resolve-click-action"; import React, { useMemo, useCallback, useState } from "react"; @@ -175,15 +176,17 @@ export function CardContainer({ // Try migrating from legacy colorThresholds const legacyThresholds = chartOptions.colorThresholds; if (typeof legacyThresholds === "string" && legacyThresholds.trim()) { - const legacyColumn = chartOptions.colorThresholdsColumn; - return migrateColorThresholds( - legacyThresholds, - typeof legacyColumn === "string" ? legacyColumn : undefined, - ); + return migrateColorThresholds(legacyThresholds); } return undefined; }, [widget.settings?.stylingConfig, chartOptions]); + // Resolve color scales config + const conditionalFormatting = widget.settings?.conditionalFormatting as + | { colorScales?: ColorScaleConfig[] } + | undefined; + const colorScales = conditionalFormatting?.colorScales; + if (!chartConfig) { return ( {showOverlay && ( @@ -423,6 +428,7 @@ export function CardContainer({ stylingRules={resolvedStylingConfig?.rules} paramValues={allParamValues} autoFit={autoFit} + colorScales={colorScales} /> {showOverlay && ( diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index 02c287f9..d0c52a79 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -44,6 +44,7 @@ import type { SunburstDataItem, RadarChartData, TreemapDataItem, + ColorScaleConfig, } from "@neoboard/components"; import { ParameterWidgetRenderer } from "@/components/parameter-widget-renderer"; import type { ParameterType } from "@/stores/parameter-store"; @@ -111,21 +112,34 @@ export interface ChartRendererProps { paramValues?: Record; /** When true, graph widgets trigger a fit-to-viewport after mount. */ autoFit?: boolean; + /** Color scale configs for gradient cell backgrounds (table only) */ + colorScales?: ColorScaleConfig[]; } /** * Renders the appropriate chart component based on widget type and data. * Forwards chart-specific settings as props to the underlying chart component. */ -export function ChartRenderer({ type, data, settings = {}, onChartClick, clickableColumns, connectionId, widgetId, resultId, query, stylingRules, paramValues, autoFit }: ChartRendererProps) { +export function ChartRenderer({ type, data, settings = {}, onChartClick, clickableColumns, connectionId, widgetId, resultId, query, stylingRules, paramValues, autoFit, colorScales }: ChartRendererProps) { const colorThresholds = typeof settings.colorThresholds === "string" ? settings.colorThresholds : undefined; const handleEChartsClick = useMemo(() => { if (!onChartClick) return undefined; - return (e: EChartsClickEvent) => - onChartClick({ name: e.name, value: e.value, seriesName: e.seriesName, dataIndex: e.dataIndex }); - }, [onChartClick]); + return (e: EChartsClickEvent) => { + // Enrich the click point with the original data row so that + // column-name source fields (e.g. "revenue") resolve correctly + // in click action rules — not just ECharts built-in fields. + const row = Array.isArray(data) ? (data[e.dataIndex] as Record | undefined) : undefined; + onChartClick({ + ...(row ?? {}), + name: e.name, + value: e.value, + seriesName: e.seriesName, + dataIndex: e.dataIndex, + }); + }; + }, [onChartClick, data]); switch (type) { case "bar": @@ -141,10 +155,13 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab xAxisLabel={settings.xAxisLabel as string | undefined} yAxisLabel={settings.yAxisLabel as string | undefined} showGridLines={settings.showGridLines as boolean | undefined} + axisLabelRotation={settings.axisLabelRotation as number | undefined} + referenceLines={settings.referenceLines as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} onClick={handleEChartsClick} + enableDataZoom={settings.enableDataZoom as boolean | undefined} colorPalette={settings.colorPalette as string | undefined} colorblindMode={settings.colorblindMode as boolean | undefined} /> @@ -163,10 +180,12 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab stepped={settings.stepped as boolean | undefined} showPoints={settings.showPoints as boolean | undefined} showGridLines={settings.showGridLines as boolean | undefined} + referenceLines={settings.referenceLines as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} onClick={handleEChartsClick} + enableDataZoom={settings.enableDataZoom as boolean | undefined} colorPalette={settings.colorPalette as string | undefined} colorblindMode={settings.colorblindMode as boolean | undefined} /> @@ -183,6 +202,8 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab labelPosition={settings.labelPosition as "outside" | "inside" | "center" | undefined} showPercentage={settings.showPercentage as boolean | undefined} sortSlices={settings.sortSlices as boolean | undefined} + topN={settings.topN as number | undefined} + donutCenterText={settings.donutCenterText as string | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} @@ -203,6 +224,7 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab suffix={settings.suffix as string | undefined} fontSize={settings.fontSize as "sm" | "md" | "lg" | "xl" | undefined} numberFormat={settings.numberFormat as "plain" | "comma" | "compact" | "percent" | undefined} + decimalPlaces={settings.decimalPlaces as number | undefined} colorThresholds={colorThresholds} stylingRules={stylingRules} paramValues={paramValues} @@ -265,6 +287,7 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab clickableColumns={clickableColumns} stylingRules={stylingRules} paramValues={paramValues} + colorScales={colorScales} /> ); diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx index 7730dd06..f312827c 100644 --- a/app/src/components/form-widget-renderer.tsx +++ b/app/src/components/form-widget-renderer.tsx @@ -1,6 +1,12 @@ "use client"; -import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import React, { + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from "react"; import { useSession } from "next-auth/react"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -15,6 +21,7 @@ import { Label, type RelativeDatePreset, } from "@neoboard/components"; +import { useParameterValues } from "@/stores/parameter-store"; import { useWriteQueryExecution } from "@/hooks/use-write-query-execution"; import { useSeedQuery } from "@/hooks/use-seed-query"; import { buildFormParams } from "@/lib/form-field-def"; @@ -78,8 +85,7 @@ function FieldInput({ (field.parentParameterName !== undefined ? !!parentValue : true); const seedExtraParams = useMemo(() => { - const base = - field.parameterType === "cascading-select" ? parentParams : {}; + const base = field.parameterType === "cascading-select" ? parentParams : {}; if (field.searchable && debouncedSearch) { return { ...base, param_search: debouncedSearch }; } @@ -105,11 +111,18 @@ function FieldInput({ prevParentValue.current = parentValue; onChange(field.parameterName, undefined); } - }, [field.parameterType, field.parentParameterName, parentValue, field.parameterName, onChange]); + }, [ + field.parameterType, + field.parentParameterName, + parentValue, + field.parameterName, + onChange, + ]); switch (field.parameterType) { case "text": { - const textValue = value !== undefined && value !== null ? String(value) : ""; + const textValue = + value !== undefined && value !== null ? String(value) : ""; return ( { - if (!v) { onChange(field.parameterName, undefined); return; } + if (!v) { + onChange(field.parameterName, undefined); + return; + } const opt = options.find((o) => o.value === v); - onChange(field.parameterName, opt?.rawValue !== undefined ? opt.rawValue : v); + onChange( + field.parameterName, + opt?.rawValue !== undefined ? opt.rawValue : v, + ); }} placeholder={field.placeholder} loading={loading} @@ -153,7 +173,10 @@ function FieldInput({ options={options} values={multiValues} onChange={(vals) => { - if (vals.length === 0) { onChange(field.parameterName, undefined); return; } + if (vals.length === 0) { + onChange(field.parameterName, undefined); + return; + } const rawVals = vals.map((v) => { const opt = options.find((o) => o.value === v); return opt?.rawValue !== undefined ? opt.rawValue : v; @@ -169,7 +192,8 @@ function FieldInput({ } case "date": { - const dateValue = value !== undefined && value !== null ? String(value) : ""; + const dateValue = + value !== undefined && value !== null ? String(value) : ""; return ( { - if (!from && !to) { onChange(field.parameterName, undefined); return; } + if (!from && !to) { + onChange(field.parameterName, undefined); + return; + } onChange(field.parameterName, { from, to }); }} /> @@ -237,9 +264,15 @@ function FieldInput({ options={options} value={cascadeValue} onChange={(v) => { - if (!v) { onChange(field.parameterName, undefined); return; } + if (!v) { + onChange(field.parameterName, undefined); + return; + } const opt = options.find((o) => o.value === v); - onChange(field.parameterName, opt?.rawValue !== undefined ? opt.rawValue : v); + onChange( + field.parameterName, + opt?.rawValue !== undefined ? opt.rawValue : v, + ); }} parentValue={parentValue} parentParameterName={field.parentParameterName} @@ -263,12 +296,12 @@ export function FormWidgetRenderer({ }: FormWidgetRendererProps) { const fields = useMemo( () => (settings?.formFields as FormFieldDef[] | undefined) ?? [], - + [settings?.formFields], ); const chartOptions = useMemo( () => (settings.chartOptions ?? {}) as Record, - + [settings.chartOptions], ); @@ -289,7 +322,17 @@ export function FormWidgetRenderer({ const { data: session } = useSession(); const tenantId = session?.user?.tenantId; - // Sync local values when fields change (field added/removed/renamed in editor). + // Seed form fields from external parameters (click-actions, selectors, etc.) + // Fields the user has manually changed are NOT overwritten by external params. + const allParams = useParameterValues(); + const touchedFields = useRef(new Set()); + + // Stable key of external param values for fields in this form + const paramSeedKey = fields + .map((f) => `${f.parameterName}=${allParams[f.parameterName] ?? ""}`) + .join("|"); + + // Sync local values when fields change OR when matching external params change. // number-range fields default to [rangeMin, rangeMax] so buildFormParams always // includes param_X_min / param_X_max even when the user hasn't moved the slider. const fieldKey = fields.map((f) => f.parameterName).join(","); @@ -297,7 +340,15 @@ export function FormWidgetRenderer({ setLocalValues((prev) => { const next: Record = {}; for (const f of fields) { - if (prev[f.parameterName] !== undefined) { + if ( + touchedFields.current.has(f.parameterName) && + prev[f.parameterName] !== undefined + ) { + // User manually changed this field — preserve their value + next[f.parameterName] = prev[f.parameterName]; + } else if (allParams[f.parameterName] !== undefined) { + next[f.parameterName] = allParams[f.parameterName]; + } else if (prev[f.parameterName] !== undefined) { next[f.parameterName] = prev[f.parameterName]; } else if (f.parameterType === "number-range") { next[f.parameterName] = [f.rangeMin ?? 0, f.rangeMax ?? 100]; @@ -308,9 +359,10 @@ export function FormWidgetRenderer({ return next; }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fieldKey]); + }, [fieldKey, paramSeedKey]); const handleFieldChange = useCallback((name: string, value: unknown) => { + touchedFields.current.add(name); setLocalValues((prev) => ({ ...prev, [name]: value })); setSuccessMessage(null); setErrorMessage(null); @@ -371,7 +423,16 @@ export function FormWidgetRenderer({ }, }, ); - }, [fields, localValues, connectionId, query, chartOptions, writeQuery, refreshWidgetIds, queryClient]); + }, [ + fields, + localValues, + connectionId, + query, + chartOptions, + writeQuery, + refreshWidgetIds, + queryClient, + ]); if (fields.length === 0) { return ( @@ -431,7 +492,7 @@ export function FormWidgetRenderer({ > {writeQuery.isPending ? "Submitting…" - : ((chartOptions.submitButtonText as string) || "Submit")} + : (chartOptions.submitButtonText as string) || "Submit"} diff --git a/app/src/components/table-renderer.tsx b/app/src/components/table-renderer.tsx index a5d4e316..be44c03f 100644 --- a/app/src/components/table-renderer.tsx +++ b/app/src/components/table-renderer.tsx @@ -10,9 +10,13 @@ import { parseColorThresholds, resolveThresholdColor, resolveStylingRuleColor, + interpolateColor, } from "@neoboard/components"; -import type { StylingRule } from "@neoboard/components"; +import type { StylingRule, ColorScaleConfig } from "@neoboard/components"; import type { ColumnDef } from "@tanstack/react-table"; +import { parseGroupByColumns } from "@/lib/table-utils"; + +const AGG_SYMBOLS: Record = { sum: "Σ", mean: "μ", median: "M̃", count: "#", min: "min", max: "max" }; export interface TableRendererProps { data: unknown; @@ -24,6 +28,8 @@ export interface TableRendererProps { stylingRules?: StylingRule[]; /** Resolved parameter values for parameterRef comparisons */ paramValues?: Record; + /** Color scale configs for gradient cell backgrounds */ + colorScales?: ColorScaleConfig[]; } /** @@ -31,7 +37,7 @@ export interface TableRendererProps { * Uses a ResizeObserver on the wrapper div to pass a live containerHeight so * DataGrid can calculate the dynamic page size automatically. */ -export function TableRenderer({ data, settings = {}, onCellClick, clickableColumns, stylingRules, paramValues }: TableRendererProps) { +export function TableRenderer({ data, settings = {}, onCellClick, clickableColumns, stylingRules, paramValues, colorScales }: TableRendererProps) { const records = useMemo(() => (Array.isArray(data) ? data : []), [data]); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(undefined); @@ -55,29 +61,57 @@ export function TableRenderer({ data, settings = {}, onCellClick, clickableColum }, []); const enableSorting = settings.enableSorting !== false; + const enableColumnResizing = settings.enableColumnResizing === true; + const enableGrouping = settings.enableGrouping === true; + const initialGrouping = useMemo( + () => parseGroupByColumns(enableGrouping, settings.groupBy as string), + [enableGrouping, settings.groupBy], + ); + + const aggregationFn = ((settings.aggregationFn as string) || "sum") as "sum" | "mean" | "median" | "count" | "min" | "max"; const columns = useMemo((): ColumnDef, unknown>[] => { if (!records.length) return []; - return Object.keys(records[0]).map((key) => ({ - id: key, - accessorFn: (row: Record) => row[key], - header: ({ column }) => ( - - ), - cell: ({ getValue }) => { - const v = getValue(); - if (v === null || v === undefined) - return null; - const display = - typeof v === "object" ? JSON.stringify(v) : String(v); - return ( - - {display} - - ); - }, - })); - }, [records]); + const aggSymbol = AGG_SYMBOLS[aggregationFn] ?? "Σ"; + return Object.keys(records[0]).map((key) => { + // Detect numeric columns for automatic aggregation in grouped mode + const isNumeric = records.some( + (r) => typeof (r as Record)[key] === "number", + ); + return { + id: key, + accessorFn: (row: Record) => row[key], + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const v = getValue(); + if (v === null || v === undefined) + return null; + const display = + typeof v === "object" ? JSON.stringify(v) : String(v); + return ( + + {display} + + ); + }, + ...(enableGrouping && isNumeric + ? { + aggregationFn, + aggregatedCell: ({ getValue }: { getValue: () => unknown }) => { + const v = getValue(); + return v != null ? ( + + {aggSymbol} {typeof v === "number" ? v.toLocaleString() : String(v)} + + ) : null; + }, + } + : {}), + }; + }); + }, [records, enableGrouping, aggregationFn]); const thresholds = useMemo(() => { const raw = @@ -103,22 +137,41 @@ export function TableRenderer({ data, settings = {}, onCellClick, clickableColum const getRowStyle = useMemo(() => { if (stylingRules?.length) { + // Fallback column for rules without an explicit column + const defaultCol = + thresholdColumn || fallbackThresholdColumn; return (row: Record): React.CSSProperties | undefined => { - const col = - thresholdColumn && thresholdColumn in row - ? thresholdColumn - : fallbackThresholdColumn; - if (!col) return undefined; - const val = row[col]; - const bgRules = stylingRules.filter((r) => !r.target || r.target === "backgroundColor"); - const textRules = stylingRules.filter((r) => r.target === "textColor"); - const bgColor = bgRules.length ? resolveStylingRuleColor(val, bgRules, paramValues) : undefined; - const textColor = textRules.length ? resolveStylingRuleColor(val, textRules, paramValues) : undefined; - if (!bgColor && !textColor) return undefined; - return { - ...(bgColor ? { backgroundColor: bgColor } : {}), - ...(textColor ? { color: textColor } : {}), - }; + const style: React.CSSProperties = {}; + let hasStyle = false; + let bgSet = false; + let textSet = false; + let boldSet = false; + + for (const rule of stylingRules) { + if (bgSet && textSet && boldSet) break; + const ruleCol = rule.column || defaultCol; + if (!ruleCol || !(ruleCol in row)) continue; + const val = row[ruleCol]; + const color = resolveStylingRuleColor(val, [rule], paramValues); + if (!color) continue; + const target = rule.target || "backgroundColor"; + if (target === "backgroundColor" && !bgSet) { + style.backgroundColor = color; + bgSet = true; + hasStyle = true; + } + if (target === "textColor" && !textSet) { + style.color = color; + textSet = true; + hasStyle = true; + } + if (rule.bold && !boldSet) { + style.fontWeight = "bold"; + boldSet = true; + hasStyle = true; + } + } + return hasStyle ? style : undefined; }; } if (thresholds.length > 0) { @@ -137,6 +190,40 @@ export function TableRenderer({ data, settings = {}, onCellClick, clickableColum return undefined; }, [stylingRules, paramValues, thresholds, thresholdColumn, fallbackThresholdColumn]); + // Compute per-column min/max for color scales + const columnMinMax = useMemo(() => { + if (!colorScales?.length || !records.length) return new Map(); + const result = new Map(); + for (const scale of colorScales) { + let min = Infinity; + let max = -Infinity; + for (const row of records) { + const raw = (row as Record)[scale.column]; + if (raw === null || raw === undefined || raw === "" || (typeof raw === "string" && !raw.trim())) continue; + const val = Number(raw); + if (!Number.isNaN(val)) { + if (val < min) min = val; + if (val > max) max = val; + } + } + if (min !== Infinity) result.set(scale.column, { min, max }); + } + return result; + }, [colorScales, records]); + + const getCellStyle = useMemo(() => { + if (!colorScales?.length) return undefined; + return (row: Record, columnId: string): React.CSSProperties | undefined => { + const scale = colorScales.find((s) => s.column === columnId); + if (!scale) return undefined; + const bounds = columnMinMax.get(columnId); + if (!bounds) return undefined; + const val = Number(row[columnId]); + if (Number.isNaN(val)) return undefined; + return { backgroundColor: interpolateColor(val, bounds.min, bounds.max, scale.minColor, scale.maxColor) }; + }; + }, [colorScales, columnMinMax]); + const emptyMessage = (settings.emptyMessage as string | undefined) ?? "No results"; if (!records.length) { return ; @@ -145,9 +232,11 @@ export function TableRenderer({ data, settings = {}, onCellClick, clickableColum return (
[]} enableSorting={enableSorting} + enableColumnResizing={enableColumnResizing} enableSelection={settings.enableSelection as boolean | undefined} enableGlobalFilter={settings.enableGlobalFilter !== false} enableColumnFilters={settings.enableColumnFilters !== false} @@ -157,6 +246,9 @@ export function TableRenderer({ data, settings = {}, onCellClick, clickableColum onCellClick={onCellClick} clickableColumns={clickableColumns} getRowStyle={getRowStyle} + getCellStyle={getCellStyle} + enableGrouping={enableGrouping} + initialGrouping={initialGrouping} pagination={(table) => (
diff --git a/app/src/components/widget-editor-modal.tsx b/app/src/components/widget-editor-modal.tsx index 813e2fc8..05f881bb 100644 --- a/app/src/components/widget-editor-modal.tsx +++ b/app/src/components/widget-editor-modal.tsx @@ -13,6 +13,7 @@ import { ChartOptionsPanel, ChartSettingsPanel, getDefaultChartSettings, + ColorScalePanel, Badge, Button, LoadingButton, @@ -31,6 +32,7 @@ import { MarkdownWidget, IframeWidget, } from "@neoboard/components"; +import type { ColorScaleConfig } from "@neoboard/components"; import { getCompatibleChartTypes, getChartConfig, @@ -78,6 +80,8 @@ export interface WidgetEditorModalProps { layout?: DashboardLayoutV2; /** Template to auto-apply when opening in add mode (from Widget Lab "Use in Dashboard") */ initialTemplate?: WidgetTemplate; + /** Cached query data from the dashboard — shown as preview immediately without re-running */ + initialPreviewData?: { data: unknown; resultId: string }; } export function WidgetEditorModal({ @@ -91,6 +95,7 @@ export function WidgetEditorModal({ onLabSaved, layout, initialTemplate, + initialPreviewData, }: WidgetEditorModalProps) { const isLabMode = mode === "lab-edit" || mode === "lab-create"; const [chartType, setChartType] = useState(widget?.chartType ?? "bar"); @@ -138,8 +143,12 @@ export function WidgetEditorModal({ const [stylingRules, setStylingRules] = useState( existingStylingConfig?.rules ?? [] ); - const [stylingTargetColumn, setStylingTargetColumn] = useState( - existingStylingConfig?.targetColumn ?? "" + // Color scales state (gradient cell backgrounds for tables) + const existingConditionalFormatting = widget?.settings?.conditionalFormatting as + | { colorScales?: ColorScaleConfig[] } + | undefined; + const [colorScales, setColorScales] = useState( + existingConditionalFormatting?.colorScales ?? [] ); const [dialogStep, setDialogStep] = useState<"main" | "rules" | "styling-rules" | "templates">("main"); @@ -369,7 +378,7 @@ export function WidgetEditorModal({ setActionRules([]); setStylingEnabled(false); setStylingRules([]); - setStylingTargetColumn(""); + setColorScales([]); setDialogStep("main"); seedQueryExecution.reset(); previewQuery.reset(); @@ -396,32 +405,30 @@ export function WidgetEditorModal({ if (sc) { setStylingEnabled(sc.enabled); setStylingRules(sc.rules ?? []); - setStylingTargetColumn(sc.targetColumn ?? ""); } else { // Try migrating from legacy colorThresholds const legacyThresholds = (widget.settings?.chartOptions as Record | undefined)?.colorThresholds; - const legacyColumn = (widget.settings?.chartOptions as Record | undefined)?.colorThresholdsColumn; if (typeof legacyThresholds === "string" && legacyThresholds.trim()) { - const migrated = migrateColorThresholds( - legacyThresholds, - typeof legacyColumn === "string" ? legacyColumn : undefined, - ); + const migrated = migrateColorThresholds(legacyThresholds); if (migrated) { setStylingEnabled(migrated.enabled); setStylingRules(migrated.rules); - setStylingTargetColumn(migrated.targetColumn ?? ""); } else { setStylingEnabled(false); setStylingRules([]); - setStylingTargetColumn(""); - } + } } else { setStylingEnabled(false); setStylingRules([]); - setStylingTargetColumn(""); - } + } } + // Initialize color scales from existing widget + const cf = widget.settings?.conditionalFormatting as + | { colorScales?: ColorScaleConfig[] } + | undefined; + setColorScales(cf?.colorScales ?? []); + setDialogStep("main"); setEnableCache(widget.settings?.enableCache !== false); setCacheTtlMinutes((widget.settings?.cacheTtlMinutes as number | undefined) ?? 5); @@ -476,7 +483,7 @@ export function WidgetEditorModal({ setActionRules([]); setStylingEnabled(false); setStylingRules([]); - setStylingTargetColumn(""); + setColorScales([]); setLabName(""); setLabDescription(""); setLabTagsInput(""); @@ -499,7 +506,7 @@ export function WidgetEditorModal({ setClickActionEnabled(false); setStylingEnabled(false); setStylingRules([]); - setStylingTargetColumn(""); + setColorScales([]); setActionRules([]); setDialogStep("main"); seedQueryExecution.reset(); @@ -573,9 +580,8 @@ export function WidgetEditorModal({ return { enabled: true, rules: stylingRules, - targetColumn: stylingTargetColumn || undefined, }; - }, [stylingEnabled, chartType, stylingRules, stylingTargetColumn]); + }, [stylingEnabled, chartType, stylingRules]); const handlePreview = useCallback(() => { if (connectionId && query.trim()) { @@ -587,6 +593,29 @@ export function WidgetEditorModal({ } }, [connectionId, query, previewQuery, allParamValues, selectedConnection]); + // Auto-run preview when editing an existing widget so column selectors are populated. + // Skip if initialPreviewData was provided (we already have data to show). + const autoPreviewTriggered = useRef(false); + useEffect(() => { + if (!open || (mode !== "edit" && mode !== "lab-edit")) { + autoPreviewTriggered.current = false; + return; + } + if (autoPreviewTriggered.current) return; + if (!connectionId || !query.trim()) return; + if (initialPreviewData) { + autoPreviewTriggered.current = true; + return; + } + autoPreviewTriggered.current = true; + // setTimeout ensures the reset effect's setState calls have flushed + const timer = setTimeout(() => { + handlePreview(); + }, 0); + return () => clearTimeout(timer); + + }, [open, mode, connectionId, query, handlePreview, initialPreviewData]); + // Handles CMD+Shift+Enter (Mac) / Ctrl+Shift+Enter (Win/Linux): run query, then save on success. const handleRunAndSave = useCallback(() => { // Content-only widgets (markdown, iframe) don't have a query — skip the run+save shortcut. @@ -619,6 +648,9 @@ export function WidgetEditorModal({ formFields: chartType === "form" ? formFields : undefined, clickAction: buildClickAction(), stylingConfig: buildStylingConfig(), + conditionalFormatting: colorScales.length + ? { colorScales } + : undefined, enableCache, cacheTtlMinutes, }, @@ -645,6 +677,7 @@ export function WidgetEditorModal({ formFields, enableCache, cacheTtlMinutes, + colorScales, previewQuery, onSave, onOpenChange, @@ -678,13 +711,13 @@ export function WidgetEditorModal({ // Derive available fields from preview query results const availableFields = useMemo(() => { - if (!previewQuery.data?.data) return []; - const data = previewQuery.data.data; - if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object" && data[0] !== null) { - return Object.keys(data[0] as Record); + const src = previewQuery.data?.data ?? initialPreviewData?.data; + if (!src) return []; + if (Array.isArray(src) && src.length > 0 && typeof src[0] === "object" && src[0] !== null) { + return Object.keys(src[0] as Record); } return []; - }, [previewQuery.data]); + }, [previewQuery.data, initialPreviewData]); const isParamSelect = chartType === "parameter-select"; const isForm = chartType === "form"; @@ -724,6 +757,10 @@ export function WidgetEditorModal({ formFields: isForm ? formFields : undefined, clickAction: (isParamSelect || isForm || isContentOnly) ? undefined : clickAction, stylingConfig: (isParamSelect || isForm || isContentOnly) ? undefined : stylingConfig, + conditionalFormatting: (isParamSelect || isForm || isContentOnly) ? undefined + : colorScales.length + ? { colorScales } + : undefined, enableCache: (isParamSelect || isForm || isContentOnly) ? undefined : enableCache, cacheTtlMinutes: (isParamSelect || isForm || isContentOnly) ? undefined : cacheTtlMinutes, }, @@ -760,6 +797,9 @@ export function WidgetEditorModal({ chartOptions, stylingConfig: buildStylingConfig(), clickAction: buildClickAction(), + conditionalFormatting: colorScales.length + ? { colorScales } + : undefined, }, }; @@ -787,8 +827,6 @@ export function WidgetEditorModal({ onRulesChange={setStylingRules} onBack={() => setDialogStep("main")} chartType={chartType} - targetColumn={stylingTargetColumn} - onTargetColumnChange={setStylingTargetColumn} availableFields={availableFields} parameterSuggestions={parameterSuggestions} stylingTargets={getStylingTargets(chartType)} @@ -1030,7 +1068,26 @@ export function WidgetEditorModal({ chartType={chartType} settings={chartOptions} onSettingsChange={setChartOptions} + columns={availableFields} /> + {chartType === "table" && ( +
+

+ Color Scales +

+ {availableFields.length === 0 ? ( +

+ Run a preview query to configure color scales. +

+ ) : ( + + )} +
+ )} {chartOptions.cacheMode === "forever" && (
)} - {/* Rule-based styling — hidden for unsupported chart types */} + {/* Styling — row-level rules + cell-level formatting */} {chartSupportsStyling(chartType) && (

- Rule-Based Styling + Styling

)} - {previewQuery.data ? ( + {(previewQuery.data || initialPreviewData) ? ( + ) : (mode === "edit" || mode === "lab-edit") && connectionId && query.trim() ? ( +
+
+
) : (
Run a query to see the preview diff --git a/app/src/components/widget-editor/styling-rules-editor.tsx b/app/src/components/widget-editor/styling-rules-editor.tsx index effd3ca8..f2537268 100644 --- a/app/src/components/widget-editor/styling-rules-editor.tsx +++ b/app/src/components/widget-editor/styling-rules-editor.tsx @@ -2,7 +2,7 @@ import React from "react"; import type { StylingRule, StylingOperator } from "@/lib/db/schema"; -import { ArrowLeft, GripVertical, Plus, Trash2 } from "lucide-react"; +import { ArrowLeft, GripVertical, Plus, Trash2, Bold } from "lucide-react"; import { Accordion, AccordionContent, @@ -80,16 +80,15 @@ interface StylingRulesEditorProps { onRulesChange: (rules: StylingRule[]) => void; onBack: () => void; chartType: string; - targetColumn: string; - onTargetColumnChange: (col: string) => void; availableFields: string[]; parameterSuggestions: string[]; stylingTargets: { value: string; label: string }[]; } function ruleSummary(rule: StylingRule): string { + const col = rule.column ? `${rule.column} ` : ""; const op = rule.operator ?? "<="; - if (NULL_OPS.has(op)) return op.replace("_", " "); + if (NULL_OPS.has(op)) return `${col}${op.replace("_", " ")}`; const val = rule.parameterRef ? `$param_${rule.parameterRef}` : String(rule.value); @@ -97,9 +96,9 @@ function ruleSummary(rule: StylingRule): string { const valTo = rule.parameterRefTo ? `$param_${rule.parameterRefTo}` : String(rule.valueTo ?? "?"); - return `between ${val} and ${valTo}`; + return `${col}between ${val} and ${valTo}`; } - return `${op.replace("_", " ")} ${val}`; + return `${col}${op.replace("_", " ")} ${val}`; } interface SortableRuleItemProps { @@ -109,6 +108,8 @@ interface SortableRuleItemProps { onUpdate: (id: string, updates: Partial) => void; parameterSuggestions: string[]; stylingTargets: { value: string; label: string }[]; + isTable: boolean; + availableFields: string[]; } function SortableRuleItem({ @@ -118,6 +119,8 @@ function SortableRuleItem({ onUpdate, parameterSuggestions, stylingTargets, + isTable, + availableFields, }: SortableRuleItemProps) { const { attributes, @@ -159,6 +162,7 @@ function SortableRuleItem({ className="inline-block w-3 h-3 rounded-sm border align-middle" style={{ backgroundColor: rule.color }} /> + {rule.bold && B}
+ {/* Column — per-rule column selector for tables */} + {isTable && ( +
+ + onUpdate(rule.id, { column: v || undefined })} + fields={availableFields} + label="Column" + placeholder="Auto (first numeric)" + /> +
+ )} + {/* Operator */}
@@ -265,6 +283,22 @@ function SortableRuleItem({
+ {/* Bold */} + + {/* Target — only when multiple targets available */} {stylingTargets.length > 1 && (
@@ -300,8 +334,6 @@ export function StylingRulesEditor({ onRulesChange, onBack, chartType, - targetColumn, - onTargetColumnChange, availableFields, parameterSuggestions, stylingTargets, @@ -350,23 +382,6 @@ export function StylingRulesEditor({
- {/* Target column selector — for tables only */} - {isTable && ( -
- -

- Column to evaluate rules against. Leave blank to use the first numeric column. -

- -
- )} - {rules.length === 0 && (

No styling rules yet. Add one to get started. @@ -389,6 +404,8 @@ export function StylingRulesEditor({ onUpdate={updateItem} parameterSuggestions={parameterSuggestions} stylingTargets={stylingTargets} + isTable={isTable} + availableFields={availableFields} /> ))} diff --git a/app/src/components/widget-editor/value-or-param-input.tsx b/app/src/components/widget-editor/value-or-param-input.tsx index 2130e530..f38a69f8 100644 --- a/app/src/components/widget-editor/value-or-param-input.tsx +++ b/app/src/components/widget-editor/value-or-param-input.tsx @@ -1,7 +1,6 @@ "use client"; import { - Button, CreatableCombobox, Input, } from "@neoboard/components"; @@ -17,9 +16,10 @@ interface ValueOrParamInputProps { } /** - * Paired Value / Parameter toggle with either a CreatableCombobox (parameter - * mode) or a plain Input (value mode). Used inside styling rules wherever the - * user can choose between a literal value and a dashboard parameter reference. + * Smart input that auto-detects whether the user is entering a literal value + * or a dashboard parameter reference. Shows parameter suggestions via a + * combobox; selecting a suggestion sets parameterRef. Typing a literal value + * clears parameterRef and sets value directly. */ export function ValueOrParamInput({ parameterRef, @@ -30,45 +30,41 @@ export function ValueOrParamInput({ inputType = "number", placeholder = "0", }: ValueOrParamInputProps) { - return ( - <> -

- - -
- {parameterRef !== undefined ? ( - - ) : ( - - onValueChange( - inputType === "number" ? Number(e.target.value) : e.target.value, - ) + // When there are parameter suggestions, show a combobox that allows + // both selecting a parameter and typing a literal value. + if (parameterSuggestions.length > 0) { + const displayValue = parameterRef !== undefined ? parameterRef : String(value); + return ( + { + if (parameterSuggestions.includes(v)) { + // User selected a known parameter + onParamRefChange(v); + } else { + // User typed a literal value + onParamRefChange(undefined); + onValueChange(inputType === "number" && v !== "" ? Number(v) : v); } - placeholder={placeholder} - /> - )} - + }} + placeholder={placeholder} + /> + ); + } + + // No parameter suggestions — plain input + return ( + { + onParamRefChange(undefined); + onValueChange( + inputType === "number" ? Number(e.target.value) : e.target.value, + ); + }} + placeholder={placeholder} + /> ); } diff --git a/app/src/lib/__tests__/chart-registry.test.ts b/app/src/lib/__tests__/chart-registry.test.ts index 84da75d1..2e2a5c1b 100644 --- a/app/src/lib/__tests__/chart-registry.test.ts +++ b/app/src/lib/__tests__/chart-registry.test.ts @@ -1491,21 +1491,137 @@ describe("radar transform", () => { expect(result.indicators[0].name).toBe("X"); }); - it("auto-scales max from data when max column is missing", () => { + it("auto-scales max from data when max column is missing (single indicator)", () => { const data = [{ indicator: "Speed", value: 80 }]; const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: unknown[] }; // 80 * 1.1 = 88, ceil → 88 expect(result.indicators[0].max).toBe(88); }); - it("handles flat tabular data without indicator column (uses column names as indicators)", () => { + it("uses global max across all indicators for relative comparison", () => { + const data = [ + { indicator: "ACTED_IN", value: 172 }, + { indicator: "PRODUCED", value: 15 }, + { indicator: "DIRECTED", value: 44 }, + { indicator: "WROTE", value: 10 }, + { indicator: "REVIEWED", value: 9 }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> }; + // Global max: ceil(172 * 1.1) = 190 + const globalMax = Math.ceil(172 * 1.1); + expect(result.indicators).toHaveLength(5); + // All indicators should share the same max + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + // The shape should NOT be uniform — values differ significantly + const values = result.series[0].values; + expect(values[0]).toBe(172); // ACTED_IN + expect(values[4]).toBe(9); // REVIEWED + }); + + it("preserves explicit max column values when provided", () => { + const data = [ + { indicator: "Speed", value: 80, max: 200 }, + { indicator: "Strength", value: 40, max: 150 }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + expect(result.indicators[0].max).toBe(200); + expect(result.indicators[1].max).toBe(150); + }); + + it("uses global max for wide-format tabular data", () => { const data = [{ Speed: 80, Strength: 60, Agility: 90 }]; - const result = transform(data) as { indicators: Array<{ name: string }>; series: Array<{ values: number[] }> }; + const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> }; + // Global max: ceil(90 * 1.1) = 99 + const globalMax = Math.ceil(90 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } expect(result.indicators.map((i) => i.name)).toContain("Speed"); expect(result.indicators.map((i) => i.name)).toContain("Strength"); expect(result.series[0].values).toHaveLength(3); }); + it("falls back to globalMax when max column contains null/undefined/NaN", () => { + // When the max column exists but values are invalid (null/NaN/0), + // indicators should use globalMax instead of treating 0 or NaN as explicit. + const data = [ + { indicator: "Speed", value: 80, max: null }, + { indicator: "Strength", value: 60, max: undefined }, + { indicator: "Agility", value: 90, max: NaN }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + // globalMax: ceil(90 * 1.1) = 99 + const globalMax = Math.ceil(90 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column value is 0", () => { + const data = [ + { indicator: "Speed", value: 50, max: 0 }, + { indicator: "Strength", value: 30, max: 0 }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + // 0 is not a valid explicit max (not > 0), so globalMax is used + const globalMax = Math.ceil(50 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column value is negative", () => { + const data = [ + { indicator: "Speed", value: 50, max: -100 }, + { indicator: "Strength", value: 30, max: -50 }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + const globalMax = Math.ceil(50 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + + it("mixes explicit and fallback max when some indicators have valid max", () => { + const data = [ + { indicator: "Speed", value: 80, max: 200 }, + { indicator: "Strength", value: 60, max: null }, + { indicator: "Agility", value: 90, max: 150 }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + const globalMax = Math.ceil(90 * 1.1); + // Speed and Agility have valid explicit max; Strength falls back to globalMax + expect(result.indicators[0].max).toBe(200); // Speed — explicit + expect(result.indicators[1].max).toBe(globalMax); // Strength — fallback + expect(result.indicators[2].max).toBe(150); // Agility — explicit + }); + + it("falls back to globalMax when max column contains non-numeric strings", () => { + const data = [ + { indicator: "Speed", value: 80, max: "not-a-number" }, + { indicator: "Strength", value: 60, max: "" }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + const globalMax = Math.ceil(80 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + + it("falls back to globalMax when max column contains Infinity", () => { + const data = [ + { indicator: "Speed", value: 80, max: Infinity }, + { indicator: "Strength", value: 60, max: -Infinity }, + ]; + const result = transform(data) as { indicators: Array<{ name: string; max: number }> }; + const globalMax = Math.ceil(80 * 1.1); + for (const ind of result.indicators) { + expect(ind.max).toBe(globalMax); + } + }); + it("transformWithMapping returns same result as transform", () => { const data = [{ indicator: "Speed", value: 80, max: 100 }]; const result = chartRegistry.radar.transformWithMapping(data, {}); diff --git a/app/src/lib/__tests__/dashboard-import.test.ts b/app/src/lib/__tests__/dashboard-import.test.ts index b3ab57dd..a14d99a3 100644 --- a/app/src/lib/__tests__/dashboard-import.test.ts +++ b/app/src/lib/__tests__/dashboard-import.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from "vitest"; -import { neoboardExportSchema, applyConnectionMapping } from "@/lib/dashboard-import"; +import { + neoboardExportSchema, + applyConnectionMapping, + dashboardLayoutSchema, + validateDashboardLayout, + stylingRuleSchema, + stylingConfigSchema, + colorScaleSchema, + conditionalFormattingSchema, +} from "@/lib/dashboard-import"; import type { DashboardLayoutV2 } from "@/lib/db/schema"; const VALID_EXPORT = { @@ -17,7 +26,12 @@ const VALID_EXPORT = { id: "p1", title: "Page 1", widgets: [ - { id: "w1", chartType: "bar", connectionId: "conn_0", query: "MATCH (n) RETURN n" }, + { + id: "w1", + chartType: "bar", + connectionId: "conn_0", + query: "MATCH (n) RETURN n", + }, ], gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], }, @@ -39,7 +53,10 @@ describe("neoboardExportSchema", () => { }); it("rejects formatVersion: 2 (only v1 supported)", () => { - const result = neoboardExportSchema.safeParse({ ...VALID_EXPORT, formatVersion: 2 }); + const result = neoboardExportSchema.safeParse({ + ...VALID_EXPORT, + formatVersion: 2, + }); expect(result.success).toBe(false); }); @@ -93,7 +110,12 @@ describe("applyConnectionMapping", () => { widgets: [ { id: "w1", chartType: "bar", connectionId: "conn_0", query: "q1" }, { id: "w2", chartType: "table", connectionId: "conn_1", query: "q2" }, - { id: "w3", chartType: "parameter-select", connectionId: "", query: "" }, + { + id: "w3", + chartType: "parameter-select", + connectionId: "", + query: "", + }, ], gridLayout: [ { i: "w1", x: 0, y: 0, w: 6, h: 4 }, @@ -138,8 +160,27 @@ describe("applyConnectionMapping", () => { const multiPage: DashboardLayoutV2 = { version: 2, pages: [ - { id: "p1", title: "P1", widgets: [{ id: "w1", chartType: "bar", connectionId: "conn_0", query: "q" }], gridLayout: [] }, - { id: "p2", title: "P2", widgets: [{ id: "w2", chartType: "table", connectionId: "conn_0", query: "q2" }], gridLayout: [] }, + { + id: "p1", + title: "P1", + widgets: [ + { id: "w1", chartType: "bar", connectionId: "conn_0", query: "q" }, + ], + gridLayout: [], + }, + { + id: "p2", + title: "P2", + widgets: [ + { + id: "w2", + chartType: "table", + connectionId: "conn_0", + query: "q2", + }, + ], + gridLayout: [], + }, ], }; const result = applyConnectionMapping(multiPage, { conn_0: "my-real-id" }); @@ -147,3 +188,258 @@ describe("applyConnectionMapping", () => { expect(result.pages[1].widgets[0].connectionId).toBe("my-real-id"); }); }); + +describe("stylingRuleSchema", () => { + it("accepts a valid styling rule", () => { + const result = stylingRuleSchema.safeParse({ + id: "r1", + operator: ">=", + value: 10, + color: "#22c55e", + }); + expect(result.success).toBe(true); + }); + + it("accepts rule with column and bold", () => { + const result = stylingRuleSchema.safeParse({ + id: "r1", + column: "movies", + operator: ">=", + value: 5, + color: "#22c55e", + target: "backgroundColor", + bold: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.column).toBe("movies"); + expect(result.data.bold).toBe(true); + } + }); + + it("accepts rule with parameterRef and valueTo (between)", () => { + const result = stylingRuleSchema.safeParse({ + id: "r1", + operator: "between", + value: 0, + valueTo: 100, + parameterRef: "threshold", + color: "#f00", + }); + expect(result.success).toBe(true); + }); + + it("rejects rule without id", () => { + const result = stylingRuleSchema.safeParse({ + operator: ">=", + value: 5, + color: "#f00", + }); + expect(result.success).toBe(false); + }); + + it("rejects rule without color", () => { + const result = stylingRuleSchema.safeParse({ + id: "r1", + operator: ">=", + value: 5, + }); + expect(result.success).toBe(false); + }); + + it("accepts string value", () => { + const result = stylingRuleSchema.safeParse({ + id: "r1", + operator: "contains", + value: "error", + color: "#f00", + }); + expect(result.success).toBe(true); + }); +}); + +describe("stylingConfigSchema", () => { + it("accepts valid config", () => { + const result = stylingConfigSchema.safeParse({ + enabled: true, + rules: [{ id: "r1", operator: ">=", value: 5, color: "#22c55e" }], + }); + expect(result.success).toBe(true); + }); + + it("rejects missing enabled", () => { + const result = stylingConfigSchema.safeParse({ + rules: [{ id: "r1", operator: ">=", value: 5, color: "#22c55e" }], + }); + expect(result.success).toBe(false); + }); + + it("accepts empty rules array", () => { + const result = stylingConfigSchema.safeParse({ enabled: true, rules: [] }); + expect(result.success).toBe(true); + }); +}); + +describe("colorScaleSchema", () => { + it("accepts valid color scale", () => { + const result = colorScaleSchema.safeParse({ + column: "revenue", + minColor: "#ef4444", + maxColor: "#22c55e", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing column", () => { + const result = colorScaleSchema.safeParse({ + minColor: "#ef4444", + maxColor: "#22c55e", + }); + expect(result.success).toBe(false); + }); +}); + +describe("conditionalFormattingSchema", () => { + it("accepts valid config with color scales", () => { + const result = conditionalFormattingSchema.safeParse({ + colorScales: [{ column: "score", minColor: "#f00", maxColor: "#0f0" }], + }); + expect(result.success).toBe(true); + }); + + it("accepts empty config", () => { + const result = conditionalFormattingSchema.safeParse({}); + expect(result.success).toBe(true); + }); +}); + +describe("widget settings validation — misplaced chartOptions", () => { + it("rejects colorPalette at settings root", () => { + const layout = { + version: 2, + pages: [ + { + id: "p1", + title: "P1", + widgets: [ + { + id: "w1", + chartType: "pie", + connectionId: "c1", + query: "q", + settings: { title: "Bad", colorPalette: "neon" }, + }, + ], + gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], + }, + ], + }; + const result = dashboardLayoutSchema.safeParse(layout); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => i.message.includes("colorPalette")), + ).toBe(true); + } + }); + + it("rejects colorblindMode at settings root", () => { + const layout = { + version: 2, + pages: [ + { + id: "p1", + title: "P1", + widgets: [ + { + id: "w1", + chartType: "bar", + connectionId: "c1", + query: "q", + settings: { title: "Bad", colorblindMode: true }, + }, + ], + gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], + }, + ], + }; + const result = dashboardLayoutSchema.safeParse(layout); + expect(result.success).toBe(false); + }); + + it("accepts colorPalette inside chartOptions", () => { + const layout = { + version: 2, + pages: [ + { + id: "p1", + title: "P1", + widgets: [ + { + id: "w1", + chartType: "pie", + connectionId: "c1", + query: "q", + settings: { + title: "Good", + chartOptions: { colorPalette: "neon" }, + }, + }, + ], + gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], + }, + ], + }; + const result = dashboardLayoutSchema.safeParse(layout); + expect(result.success).toBe(true); + }); + + it("rejects enableGrouping at settings root", () => { + const layout = { + version: 2, + pages: [ + { + id: "p1", + title: "P1", + widgets: [ + { + id: "w1", + chartType: "table", + connectionId: "c1", + query: "q", + settings: { enableGrouping: true }, + }, + ], + gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], + }, + ], + }; + const result = dashboardLayoutSchema.safeParse(layout); + expect(result.success).toBe(false); + }); +}); + +describe("validateDashboardLayout", () => { + it("returns validated layout for valid input", () => { + const layout = { + version: 2, + pages: [ + { + id: "p1", + title: "P1", + widgets: [ + { id: "w1", chartType: "bar", connectionId: "c", query: "q" }, + ], + gridLayout: [{ i: "w1", x: 0, y: 0, w: 6, h: 4 }], + }, + ], + }; + const result = validateDashboardLayout(layout); + expect(result.version).toBe(2); + expect(result.pages).toHaveLength(1); + }); + + it("throws for invalid layout", () => { + expect(() => validateDashboardLayout({ pages: [] })).toThrow(); + }); +}); diff --git a/app/src/lib/__tests__/migrate-color-thresholds.test.ts b/app/src/lib/__tests__/migrate-color-thresholds.test.ts index aea592d6..0d759317 100644 --- a/app/src/lib/__tests__/migrate-color-thresholds.test.ts +++ b/app/src/lib/__tests__/migrate-color-thresholds.test.ts @@ -41,16 +41,11 @@ describe("migrateColorThresholds", () => { expect(result!.rules[1].id).toBeTruthy(); }); - it("preserves targetColumn when provided", () => { - const input = '[{"value":50,"color":"#aaa"}]'; - const result = migrateColorThresholds(input, "year"); - expect(result!.targetColumn).toBe("year"); - }); - - it("does not set targetColumn when not provided", () => { + it("does not include targetColumn (per-rule column used instead)", () => { const input = '[{"value":50,"color":"#aaa"}]'; const result = migrateColorThresholds(input); - expect(result!.targetColumn).toBeUndefined(); + expect(result).toBeDefined(); + expect("targetColumn" in result!).toBe(false); }); it("skips invalid entries in array", () => { diff --git a/app/src/lib/__tests__/table-utils.test.ts b/app/src/lib/__tests__/table-utils.test.ts new file mode 100644 index 00000000..75aa1e07 --- /dev/null +++ b/app/src/lib/__tests__/table-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { parseGroupByColumns } from "../table-utils"; + +describe("parseGroupByColumns", () => { + it("returns undefined when grouping is disabled", () => { + expect(parseGroupByColumns(false, "country,city")).toBeUndefined(); + }); + + it("returns undefined when groupBy is empty string", () => { + expect(parseGroupByColumns(true, "")).toBeUndefined(); + }); + + it("returns undefined when groupBy is whitespace only", () => { + expect(parseGroupByColumns(true, " ")).toBeUndefined(); + }); + + it("parses a single column", () => { + expect(parseGroupByColumns(true, "country")).toEqual(["country"]); + }); + + it("parses multiple comma-separated columns", () => { + expect(parseGroupByColumns(true, "country,city")).toEqual(["country", "city"]); + }); + + it("trims whitespace around column names", () => { + expect(parseGroupByColumns(true, " country , city , region ")).toEqual([ + "country", + "city", + "region", + ]); + }); + + it("filters out empty entries from trailing commas", () => { + expect(parseGroupByColumns(true, "country,,city,")).toEqual(["country", "city"]); + }); + + it("handles non-string groupBy gracefully", () => { + // Runtime safety: groupBy might come from JSON settings as number/undefined + expect(parseGroupByColumns(true, undefined as unknown as string)).toBeUndefined(); + expect(parseGroupByColumns(true, 123 as unknown as string)).toBeUndefined(); + }); +}); diff --git a/app/src/lib/chart-registry.ts b/app/src/lib/chart-registry.ts index 56507c78..753c8c32 100644 --- a/app/src/lib/chart-registry.ts +++ b/app/src/lib/chart-registry.ts @@ -494,21 +494,23 @@ function transformToRadarData(data: unknown): unknown { const serName = seriesKey ? String(normalizeValue(r[seriesKey]) ?? "Default") : "Default"; if (maxKey) { - const explicitMax = Number(r[maxKey]) || 100; - if (!indicatorExplicitMax.has(indName)) indicatorExplicitMax.set(indName, explicitMax); + const explicitMax = Number(r[maxKey]); + if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) { + indicatorExplicitMax.set(indName, explicitMax); + } } indicatorMaxFromData.set(indName, Math.max(indicatorMaxFromData.get(indName) ?? 0, val)); if (!seriesMap.has(serName)) seriesMap.set(serName, new Map()); seriesMap.get(serName)!.set(indName, val); } - // Use explicit max if provided, otherwise auto-scale from observed values (+10% headroom) + // Use explicit max if provided, otherwise use a single global max across all + // indicators so relative magnitudes are visible (e.g. 172 vs 9). const indicatorEntries = Array.from(indicatorMaxFromData.keys()); + const globalMax = Math.ceil(Math.max(...indicatorMaxFromData.values()) * 1.1) || 100; const indicators = indicatorEntries.map((name) => ({ name, - max: maxKey && indicatorExplicitMax.has(name) - ? indicatorExplicitMax.get(name)! - : Math.ceil((indicatorMaxFromData.get(name) ?? 100) * 1.1) || 100, + max: indicatorExplicitMax.get(name) ?? globalMax, })); const series = Array.from(seriesMap.entries()).map(([name, valMap]) => ({ name, @@ -519,17 +521,18 @@ function transformToRadarData(data: unknown): unknown { } // Wide-format: each column is an indicator, each row is a series - // Auto-scale max from observed values per column (+10% headroom) - const maxPerCol = new Map(); + // Use a single global max so all axes share the same scale + let wideGlobalMax = 0; for (const r of records) { for (const k of keys) { const v = Number(r[k]) || 0; - maxPerCol.set(k, Math.max(maxPerCol.get(k) ?? 0, v)); + if (v > wideGlobalMax) wideGlobalMax = v; } } + const wideMax = Math.ceil(wideGlobalMax * 1.1) || 100; const indicators = keys.map((k) => ({ name: k, - max: Math.ceil((maxPerCol.get(k) ?? 100) * 1.1) || 100, + max: wideMax, })); const series = records.map((r, i) => ({ name: String(i + 1), diff --git a/app/src/lib/dashboard-import.ts b/app/src/lib/dashboard-import.ts index 4b618243..196a9389 100644 --- a/app/src/lib/dashboard-import.ts +++ b/app/src/lib/dashboard-import.ts @@ -1,12 +1,96 @@ import { z } from "zod"; import type { DashboardLayoutV2 } from "@/lib/db/schema"; +// --------------------------------------------------------------------------- +// Widget settings sub-schemas (strict validation for known settings fields) +// --------------------------------------------------------------------------- + +export const stylingRuleSchema = z.object({ + id: z.string(), + column: z.string().optional(), + operator: z.string(), + value: z.union([z.number(), z.string()]), + valueTo: z.union([z.number(), z.string()]).optional(), + parameterRef: z.string().optional(), + parameterRefTo: z.string().optional(), + color: z.string(), + target: z.enum(["color", "backgroundColor", "textColor"]).optional(), + bold: z.boolean().optional(), +}); + +export const stylingConfigSchema = z.object({ + enabled: z.boolean(), + rules: z.array(stylingRuleSchema), +}); + +export const colorScaleSchema = z.object({ + column: z.string(), + minColor: z.string(), + maxColor: z.string(), +}); + +export const conditionalFormattingSchema = z.object({ + colorScales: z.array(colorScaleSchema).optional(), +}); + +// --------------------------------------------------------------------------- +// Widget + layout schemas +// --------------------------------------------------------------------------- + +/** Keys that belong inside chartOptions, NOT at the settings root. */ +const CHART_OPTION_KEYS = new Set([ + "colorPalette", + "colorblindMode", + "donut", + "smooth", + "area", + "stacked", + "showValues", + "showLegend", + "showLabels", + "enableSorting", + "enablePagination", + "pageSize", + "orientation", + "barWidth", + "barGap", + "labelPosition", + "filled", + "shape", + "enableGrouping", + "groupBy", + "aggregationFn", + "enableColumnResizing", + "enableGlobalFilter", + "enableColumnFilters", + "enableSelection", +]); + +const widgetSettingsSchema = z + .object({ + stylingConfig: stylingConfigSchema.optional(), + conditionalFormatting: conditionalFormattingSchema.optional(), + }) + .passthrough() + .superRefine((val, ctx) => { + for (const key of Object.keys(val)) { + if (CHART_OPTION_KEYS.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${key}" should be inside settings.chartOptions, not at the settings root`, + path: [key], + }); + } + } + }); + const widgetSchema = z .object({ id: z.string(), chartType: z.string(), connectionId: z.string(), query: z.string(), + settings: widgetSettingsSchema.optional(), }) .passthrough(); @@ -29,6 +113,14 @@ const pageSchema = z }) .passthrough(); +export const dashboardLayoutSchema = z + .object({ + version: z.literal(2), + pages: z.array(pageSchema), + settings: z.record(z.unknown()).optional(), + }) + .passthrough(); + export const neoboardExportSchema = z.object({ formatVersion: z.literal(1), exportedAt: z.string(), @@ -40,20 +132,24 @@ export const neoboardExportSchema = z.object({ z.object({ name: z.string(), type: z.string(), - }) + }), ), - layout: z.object({ - version: z.literal(2), - pages: z.array(pageSchema), - settings: z.record(z.unknown()).optional(), - }).passthrough(), + layout: dashboardLayoutSchema, }); export type NeoboardExportInput = z.infer; +/** + * Validate a dashboard layout object against the schema. + * Returns the validated layout or throws with descriptive errors. + */ +export function validateDashboardLayout(layout: unknown): DashboardLayoutV2 { + return dashboardLayoutSchema.parse(layout) as DashboardLayoutV2; +} + export function applyConnectionMapping( layout: DashboardLayoutV2, - mapping: Record + mapping: Record, ): DashboardLayoutV2 { return { ...layout, diff --git a/app/src/lib/db/schema.ts b/app/src/lib/db/schema.ts index 5e7c5009..6df254e8 100644 --- a/app/src/lib/db/schema.ts +++ b/app/src/lib/db/schema.ts @@ -260,6 +260,8 @@ export type StylingOperator = export interface StylingRule { id: string; + /** For tables: which column this rule evaluates against */ + column?: string; operator: StylingOperator; value: number | string; /** Upper bound for the "between" operator (inclusive) */ @@ -270,13 +272,12 @@ export interface StylingRule { parameterRefTo?: string; color: string; target?: "color" | "backgroundColor" | "textColor"; + bold?: boolean; } export interface StylingConfig { enabled: boolean; rules: StylingRule[]; - /** For tables: which column to evaluate rules against */ - targetColumn?: string; } export interface ClickAction { diff --git a/app/src/lib/migrate-color-thresholds.ts b/app/src/lib/migrate-color-thresholds.ts index 6931611a..f84a4001 100644 --- a/app/src/lib/migrate-color-thresholds.ts +++ b/app/src/lib/migrate-color-thresholds.ts @@ -12,7 +12,6 @@ interface LegacyThreshold { */ export function migrateColorThresholds( raw: string, - targetColumn?: string, ): StylingConfig | undefined { if (!raw.trim()) return undefined; @@ -47,6 +46,5 @@ export function migrateColorThresholds( return { enabled: true, rules, - targetColumn: targetColumn || undefined, }; } diff --git a/app/src/lib/table-utils.ts b/app/src/lib/table-utils.ts new file mode 100644 index 00000000..ca692422 --- /dev/null +++ b/app/src/lib/table-utils.ts @@ -0,0 +1,16 @@ +/** + * Parses the comma-separated groupBy string into an array of column IDs. + * Returns undefined if grouping is disabled or no valid columns are provided. + */ +export function parseGroupByColumns( + enableGrouping: boolean, + groupBy: string, +): string[] | undefined { + if (!enableGrouping) return undefined; + const raw = typeof groupBy === "string" ? groupBy : ""; + if (!raw.trim()) return undefined; + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} diff --git a/component/src/charts/__tests__/axis-label-utils.test.ts b/component/src/charts/__tests__/axis-label-utils.test.ts new file mode 100644 index 00000000..8058a0a5 --- /dev/null +++ b/component/src/charts/__tests__/axis-label-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { buildCategoryAxisLabel } from "../chart-utils"; + +describe("buildCategoryAxisLabel", () => { + it("returns default config for small category count", () => { + const result = buildCategoryAxisLabel(5); + expect(result.rotate).toBe(0); + expect(result.formatter).toBeUndefined(); + }); + + it("rotates labels at 30° when categories >= 8", () => { + const result = buildCategoryAxisLabel(8); + expect(result.rotate).toBe(30); + }); + + it("rotates labels at 45° when categories >= 15", () => { + const result = buildCategoryAxisLabel(15); + expect(result.rotate).toBe(45); + }); + + it("truncates labels longer than 15 chars with ellipsis", () => { + const result = buildCategoryAxisLabel(10); + expect(result.formatter).toBeDefined(); + const fmt = result.formatter as (value: string) => string; + expect(fmt("Short")).toBe("Short"); + expect(fmt("This is a very long label text")).toBe("This is a very\u2026"); + }); + + it("respects custom maxLength", () => { + const result = buildCategoryAxisLabel(10, { maxLabelLength: 8 }); + const fmt = result.formatter as (value: string) => string; + expect(fmt("12345678")).toBe("12345678"); + expect(fmt("123456789")).toBe("1234567\u2026"); + }); + + it("respects rotation override", () => { + const result = buildCategoryAxisLabel(100, { rotateOverride: 60 }); + expect(result.rotate).toBe(60); + }); + + it("returns rotate 0 with override of 0", () => { + const result = buildCategoryAxisLabel(20, { rotateOverride: 0 }); + expect(result.rotate).toBe(0); + }); + + it("always includes tooltip config for full text", () => { + const result = buildCategoryAxisLabel(10); + expect(result.tooltip).toEqual({ show: true }); + }); + + it("returns show: false when compact is true", () => { + const result = buildCategoryAxisLabel(10, { compact: true }); + expect(result.show).toBe(false); + }); + + it("normalizes -1 sentinel to automatic rotation", () => { + // -1 is the "automatic" sentinel from the UI; it should fall through + // to the category-count heuristic, not produce rotate: -1 + const few = buildCategoryAxisLabel(5, { rotateOverride: -1 }); + expect(few.rotate).toBe(0); + + const medium = buildCategoryAxisLabel(10, { rotateOverride: -1 }); + expect(medium.rotate).toBe(30); + + const many = buildCategoryAxisLabel(20, { rotateOverride: -1 }); + expect(many.rotate).toBe(45); + }); +}); diff --git a/component/src/charts/__tests__/bar-chart.test.tsx b/component/src/charts/__tests__/bar-chart.test.tsx index 90be893e..39c0d0cc 100644 --- a/component/src/charts/__tests__/bar-chart.test.tsx +++ b/component/src/charts/__tests__/bar-chart.test.tsx @@ -149,4 +149,30 @@ describe("BarChart", () => { expect(optionsCall.xAxis.name).toBe("Revenue"); expect(optionsCall.yAxis.name).toBe("Product"); }); + + // --- Reference lines (markLine) --- + + it("attaches markLine to the first series when referenceLines is provided", () => { + const refs = JSON.stringify([{ value: 150, label: "Target", color: "#ff0000" }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[0].markLine.data).toHaveLength(1); + expect(optionsCall.series[0].markLine.data[0].yAxis).toBe(150); + }); + + it("does not attach markLine when referenceLines is not provided", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeUndefined(); + }); + + // --- DataZoom --- + + it("passes enableDataZoom to BaseChart", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.dataZoom).toBeDefined(); + expect(optionsCall.dataZoom.length).toBeGreaterThan(0); + }); }); diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx index b2b37bbe..44a40b39 100644 --- a/component/src/charts/__tests__/base-chart.test.tsx +++ b/component/src/charts/__tests__/base-chart.test.tsx @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); describe("BaseChart", () => { @@ -207,4 +209,30 @@ describe("BaseChart", () => { { notMerge: true }, ); }); + + // --- DataZoom --- + + it("does not include dataZoom by default", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeUndefined(); + }); + + it("injects dataZoom config when enableDataZoom is true", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeDefined(); + expect(Array.isArray(call.dataZoom)).toBe(true); + expect(call.dataZoom).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "inside" }), + ]), + ); + }); + + it("does not inject dataZoom when enableDataZoom is false", () => { + render(); + const call = mockSetOption.mock.calls[0][0]; + expect(call.dataZoom).toBeUndefined(); + }); }); diff --git a/component/src/charts/__tests__/conditional-format.test.ts b/component/src/charts/__tests__/conditional-format.test.ts new file mode 100644 index 00000000..b5fa0d3b --- /dev/null +++ b/component/src/charts/__tests__/conditional-format.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { interpolateColor } from "../styling-rule"; + +describe("interpolateColor", () => { + it("returns minColor at min value", () => { + expect(interpolateColor(0, 0, 100, "#ff0000", "#00ff00")).toBe("#ff0000"); + }); + + it("returns maxColor at max value", () => { + expect(interpolateColor(100, 0, 100, "#ff0000", "#00ff00")).toBe("#00ff00"); + }); + + it("returns midpoint color at 50%", () => { + // #ff0000 (255,0,0) to #00ff00 (0,255,0) at 50% = (128,128,0) = #808000 + const result = interpolateColor(50, 0, 100, "#ff0000", "#00ff00"); + expect(result).toBe("#808000"); + }); + + it("clamps below min to minColor", () => { + expect(interpolateColor(-10, 0, 100, "#ff0000", "#00ff00")).toBe("#ff0000"); + }); + + it("clamps above max to maxColor", () => { + expect(interpolateColor(200, 0, 100, "#ff0000", "#00ff00")).toBe("#00ff00"); + }); + + it("handles min === max by returning minColor", () => { + expect(interpolateColor(50, 50, 50, "#ff0000", "#00ff00")).toBe("#ff0000"); + }); + + it("works with 3-char hex shorthand", () => { + // #f00 → #ff0000, #0f0 → #00ff00 + expect(interpolateColor(0, 0, 100, "#f00", "#0f0")).toBe("#ff0000"); + expect(interpolateColor(100, 0, 100, "#f00", "#0f0")).toBe("#00ff00"); + }); + + it("returns correct color at 25%", () => { + // #000000 to #ffffff at 25% → each channel = 64 = 0x40 + expect(interpolateColor(25, 0, 100, "#000000", "#ffffff")).toBe("#404040"); + }); +}); diff --git a/component/src/charts/__tests__/format-number.test.ts b/component/src/charts/__tests__/format-number.test.ts new file mode 100644 index 00000000..6c911156 --- /dev/null +++ b/component/src/charts/__tests__/format-number.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { formatNumber, buildTooltipFormatter } from "../chart-utils"; + +describe("formatNumber", () => { + it("returns plain number by default", () => { + expect(formatNumber(1234)).toBe("1234"); + }); + + it("respects decimalPlaces", () => { + expect(formatNumber(3.14159, { decimalPlaces: 2 })).toBe("3.14"); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + expect(formatNumber(5, { decimalPlaces: 2 })).toBe("5.00"); + }); + + it("applies comma formatting", () => { + expect(formatNumber(1234567, { numberFormat: "comma" })).toBe("1,234,567"); + }); + + it("applies comma formatting with decimalPlaces", () => { + expect(formatNumber(1234567.891, { numberFormat: "comma", decimalPlaces: 2 })).toBe("1,234,567.89"); + }); + + it("applies compact notation", () => { + const result = formatNumber(1500000, { numberFormat: "compact" }); + expect(result).toMatch(/1\.5M/i); + }); + + it("applies compact notation with decimalPlaces", () => { + const result = formatNumber(1234, { numberFormat: "compact", decimalPlaces: 1 }); + expect(result).toMatch(/1\.2K/i); + }); + + it("applies percent format", () => { + expect(formatNumber(75, { numberFormat: "percent" })).toBe("75%"); + }); + + it("applies percent format with decimalPlaces", () => { + expect(formatNumber(75.678, { numberFormat: "percent", decimalPlaces: 1 })).toBe("75.7%"); + }); + + it("adds prefix", () => { + expect(formatNumber(100, { prefix: "$" })).toBe("$100"); + }); + + it("adds suffix", () => { + expect(formatNumber(100, { suffix: " items" })).toBe("100 items"); + }); + + it("combines prefix, suffix, decimalPlaces, and comma", () => { + expect(formatNumber(9876.5, { prefix: "$", suffix: "M", numberFormat: "comma", decimalPlaces: 1 })).toBe("$9,876.5M"); + }); + + it("handles zero", () => { + expect(formatNumber(0, { decimalPlaces: 2 })).toBe("0.00"); + }); + + it("handles negative numbers", () => { + expect(formatNumber(-42.567, { decimalPlaces: 1 })).toBe("-42.6"); + }); + + it("returns string values unchanged", () => { + expect(formatNumber("N/A" as unknown as number)).toBe("N/A"); + }); +}); + +describe("buildTooltipFormatter", () => { + it("returns a function", () => { + const formatter = buildTooltipFormatter({}); + expect(typeof formatter).toBe("function"); + }); + + it("formats a single value with config", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 1, prefix: "$" }); + // ECharts tooltip params shape for axis trigger + const result = formatter({ + seriesName: "Revenue", + value: 1234.56, + name: "Jan", + marker: '', + }); + expect(result).toContain("$1,234.6"); + expect(result).toContain("Revenue"); + }); + + it("handles array params (axis trigger with multiple series)", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 0 }); + const result = formatter([ + { seriesName: "A", value: 100.7, name: "Jan", marker: "●" }, + { seriesName: "B", value: 200.3, name: "Jan", marker: "●" }, + ]); + expect(result).toContain("101"); + expect(result).toContain("200"); + }); + + it("omits seriesName label when seriesName is undefined", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ value: 42, name: "Jan" }); + expect(result).not.toContain("undefined"); + expect(result).toContain(""); + }); + + it("omits seriesName label when seriesName is empty string", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ seriesName: "", value: 42, name: "Jan" }); + expect(result).not.toContain(": "); + }); +}); diff --git a/component/src/charts/__tests__/gauge-thresholds.test.ts b/component/src/charts/__tests__/gauge-thresholds.test.ts new file mode 100644 index 00000000..58f8ef46 --- /dev/null +++ b/component/src/charts/__tests__/gauge-thresholds.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { parseGaugeThresholdZones } from "../chart-utils"; + +describe("parseGaugeThresholdZones", () => { + it("returns default single gray zone for empty input", () => { + const result = parseGaugeThresholdZones(undefined, 0, 100); + expect(result).toEqual([[1, "#E6EBF8"]]); + }); + + it("parses valid threshold zones", () => { + const input = JSON.stringify([ + { value: 30, color: "#67e0e3" }, + { value: 70, color: "#37a2da" }, + { value: 100, color: "#fd666d" }, + ]); + const result = parseGaugeThresholdZones(input, 0, 100); + expect(result).toEqual([ + [0.3, "#67e0e3"], + [0.7, "#37a2da"], + [1, "#fd666d"], + ]); + }); + + it("normalizes values to percentages based on min/max", () => { + const input = JSON.stringify([ + { value: 50, color: "green" }, + { value: 200, color: "red" }, + ]); + const result = parseGaugeThresholdZones(input, 0, 200); + expect(result).toEqual([ + [0.25, "green"], + [1, "red"], + ]); + }); + + it("returns default for invalid JSON", () => { + const result = parseGaugeThresholdZones("not-json", 0, 100); + expect(result).toEqual([[1, "#E6EBF8"]]); + }); +}); diff --git a/component/src/charts/__tests__/graph-chart.test.tsx b/component/src/charts/__tests__/graph-chart.test.tsx index 059d4f06..435e97bb 100644 --- a/component/src/charts/__tests__/graph-chart.test.tsx +++ b/component/src/charts/__tests__/graph-chart.test.tsx @@ -8,7 +8,7 @@ * - Click callback wiring * - Layout mapping */ -import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { GraphChart } from "../graph-chart"; import type { Node as NvlNode, Relationship as NvlRelationship } from "@neo4j-nvl/base"; @@ -530,29 +530,75 @@ describe("GraphChart", () => { expect(nvlNodes[0].caption).not.toBe("[object Object]"); }); - // --- autoFit --- + // --- Loading overlay / layoutReady --- - describe("autoFit", () => { - afterEach(() => { - vi.restoreAllMocks(); + describe("loading overlay", () => { + it("shows loading overlay on initial render when nodes are present", () => { + render(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); - it("schedules a delayed fit via requestAnimationFrame when autoFit is true", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).toHaveBeenCalledTimes(1); + it("does not show loading overlay when there are no nodes", () => { + render(); + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); + }); + + it("removes loading overlay after onLayoutDone fires", () => { + render(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); + + // Simulate NVL calling onLayoutDone + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); }); - it("does not call requestAnimationFrame for autoFit when prop is false", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + it("resets loading overlay when nodes change", () => { + const { rerender } = render(); + + // Fire onLayoutDone to clear overlay + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); + + // Change nodes — overlay should reappear + const newNodes = [ + { id: "4", label: "Diana", value: 10 }, + { id: "5", label: "Eve", value: 15 }, + ]; + rerender(); + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); }); + }); - it("does not call requestAnimationFrame for autoFit when prop is absent", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0); - render(); - expect(rafSpy).not.toHaveBeenCalled(); + // --- nvlOptions --- + + it("disables web workers in nvlOptions (Next.js bundler compatibility)", () => { + render(); + const opts = capturedProps.nvlOptions as Record; + expect(opts.disableWebWorkers).toBe(true); + }); + + // --- autoFit --- + + describe("autoFit", () => { + it("does not call fitGraph before onLayoutDone fires", () => { + // We can't directly spy on fitGraph, but we can verify through the nvlRef. + // The NVL wrapper is mocked, so we check that autoFit alone doesn't + // cause immediate side effects — the overlay should still be visible. + render(); + // Overlay is still present — layout hasn't completed + expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument(); + }); + + it("calls fitGraph (via onLayoutDone) when autoFit and layout completes", () => { + render(); + // Fire onLayoutDone + const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void }; + act(() => { callbacks.onLayoutDone?.(); }); + // Overlay should be gone — fitGraph was called + expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument(); }); }); }); diff --git a/component/src/charts/__tests__/line-chart.test.tsx b/component/src/charts/__tests__/line-chart.test.tsx index dc3839c1..47e9236a 100644 --- a/component/src/charts/__tests__/line-chart.test.tsx +++ b/component/src/charts/__tests__/line-chart.test.tsx @@ -159,4 +159,40 @@ describe("LineChart", () => { const optionsCall = mockSetOption.mock.calls[0][0]; expect(optionsCall.series[0].step).toBeUndefined(); }); + + // --- Reference lines --- + + it("attaches markLine to the first series when referenceLines is provided", () => { + const refs = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[0].markLine.data).toHaveLength(1); + expect(optionsCall.series[0].markLine.data[0].yAxis).toBe(50); + expect(optionsCall.series[0].markLine.data[0].label.formatter).toBe("Target"); + expect(optionsCall.series[0].markLine.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("does not attach markLine when referenceLines is not provided", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeUndefined(); + }); + + it("only attaches markLine to the first series in multi-series", () => { + const refs = JSON.stringify([{ value: 100 }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[1].markLine).toBeUndefined(); + }); + + // --- DataZoom --- + + it("passes enableDataZoom to BaseChart", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.dataZoom).toBeDefined(); + expect(optionsCall.dataZoom.length).toBeGreaterThan(0); + }); }); diff --git a/component/src/charts/__tests__/pie-chart.test.tsx b/component/src/charts/__tests__/pie-chart.test.tsx index a96f0e1f..3e4e37e7 100644 --- a/component/src/charts/__tests__/pie-chart.test.tsx +++ b/component/src/charts/__tests__/pie-chart.test.tsx @@ -131,4 +131,45 @@ describe("PieChart", () => { const names = (optionsCall.series[0].data as Array<{ name: string }>).map((d) => d.name); expect(names).toEqual(["Desktop", "Mobile", "Tablet"]); }); + + // --- Donut center text --- + + it("shows custom donutCenterText in graphic when donut is enabled", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.graphic).toBeDefined(); + expect(optionsCall.graphic[0].style.text).toBe("Total: 100"); + }); + + it("shows auto-total in graphic when donut enabled without donutCenterText", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.graphic).toBeDefined(); + // 60 + 30 + 10 = 100 + expect(optionsCall.graphic[0].style.text).toBe("100"); + }); + + it("does not show graphic when donut is false", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.graphic).toBeUndefined(); + }); + + // --- Top-N grouping --- + + it("groups slices beyond topN into Other", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + const seriesData = optionsCall.series[0].data as Array<{ name: string; value: number }>; + expect(seriesData).toHaveLength(3); // 2 top + "Other" + expect(seriesData[2].name).toBe("Other"); + expect(seriesData[2].value).toBe(10); + }); + + it("shows all slices when topN is 0", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + const seriesData = optionsCall.series[0].data as Array<{ name: string }>; + expect(seriesData).toHaveLength(3); + }); }); diff --git a/component/src/charts/__tests__/pie-utils.test.ts b/component/src/charts/__tests__/pie-utils.test.ts new file mode 100644 index 00000000..8377827d --- /dev/null +++ b/component/src/charts/__tests__/pie-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { groupTopN } from "../chart-utils"; +import type { PieChartDataPoint } from "../types"; + +describe("groupTopN", () => { + const data: PieChartDataPoint[] = [ + { name: "A", value: 100 }, + { name: "B", value: 80 }, + { name: "C", value: 60 }, + { name: "D", value: 40 }, + { name: "E", value: 20 }, + ]; + + it("returns all data when topN is 0 (disabled)", () => { + expect(groupTopN(data, 0)).toEqual(data); + }); + + it("returns all data when topN >= data length", () => { + expect(groupTopN(data, 5)).toEqual(data); + expect(groupTopN(data, 10)).toEqual(data); + }); + + it("groups remaining items into Other when topN < data length", () => { + const result = groupTopN(data, 3); + expect(result).toHaveLength(4); + expect(result[0].name).toBe("A"); + expect(result[1].name).toBe("B"); + expect(result[2].name).toBe("C"); + expect(result[3].name).toBe("Other"); + expect(result[3].value).toBe(60); // 40 + 20 + }); + + it("handles topN of 1", () => { + const result = groupTopN(data, 1); + expect(result).toHaveLength(2); + expect(result[0].name).toBe("A"); + expect(result[1].name).toBe("Other"); + expect(result[1].value).toBe(200); // 80+60+40+20 + }); + + it("returns empty array for empty input", () => { + expect(groupTopN([], 5)).toEqual([]); + }); +}); diff --git a/component/src/charts/__tests__/reference-line.test.ts b/component/src/charts/__tests__/reference-line.test.ts new file mode 100644 index 00000000..996800e2 --- /dev/null +++ b/component/src/charts/__tests__/reference-line.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { parseReferenceLines, buildMarkLineFromRefs } from "../chart-utils"; +import type { ReferenceLine } from "../chart-utils"; + +describe("parseReferenceLines", () => { + it("returns empty array for undefined input", () => { + expect(parseReferenceLines(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(parseReferenceLines("")).toEqual([]); + }); + + it("parses a single horizontal reference line", () => { + const input = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 50, label: "Target", color: "#ff0000" }); + }); + + it("parses multiple reference lines", () => { + const input = JSON.stringify([ + { value: 25, label: "Low" }, + { value: 75, label: "High", color: "#00ff00" }, + ]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(2); + }); + + it("returns empty array for invalid JSON", () => { + expect(parseReferenceLines("not-json")).toEqual([]); + }); + + it("filters out entries without a value", () => { + const input = JSON.stringify([{ label: "No value" }, { value: 50, label: "OK" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0].value).toBe(50); + }); +}); + +describe("buildMarkLineFromRefs", () => { + it("returns undefined for empty array", () => { + expect(buildMarkLineFromRefs([])).toBeUndefined(); + }); + + it("builds markLine for a single reference line with defaults", () => { + const result = buildMarkLineFromRefs([{ value: 50 }]); + expect(result).toBeDefined(); + expect(result!.silent).toBe(true); + expect(result!.symbol).toBe("none"); + expect(result!.data).toHaveLength(1); + + const entry = result!.data[0]; + expect(entry.yAxis).toBe(50); + expect(entry.label.formatter).toBe("50"); + expect(entry.lineStyle.color).toBe("#888"); + expect(entry.lineStyle.type).toBe("dashed"); + }); + + it("uses the label text when provided", () => { + const result = buildMarkLineFromRefs([{ value: 75, label: "Target" }]); + expect(result!.data[0].label.formatter).toBe("Target"); + }); + + it("uses custom color when provided", () => { + const result = buildMarkLineFromRefs([{ value: 25, color: "#ff0000" }]); + expect(result!.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("falls back to default color #888 when color is omitted", () => { + const result = buildMarkLineFromRefs([{ value: 10 }]); + expect(result!.data[0].lineStyle.color).toBe("#888"); + }); + + it("builds markLine for multiple reference lines", () => { + const lines: ReferenceLine[] = [ + { value: 20, label: "Low", color: "#00ff00" }, + { value: 80, label: "High", color: "#ff0000" }, + ]; + const result = buildMarkLineFromRefs(lines); + expect(result!.data).toHaveLength(2); + expect(result!.data[0].yAxis).toBe(20); + expect(result!.data[0].label.formatter).toBe("Low"); + expect(result!.data[0].lineStyle.color).toBe("#00ff00"); + expect(result!.data[1].yAxis).toBe(80); + expect(result!.data[1].label.formatter).toBe("High"); + expect(result!.data[1].lineStyle.color).toBe("#ff0000"); + }); + + it("sets label position to insideEndTop", () => { + const result = buildMarkLineFromRefs([{ value: 42 }]); + expect(result!.data[0].label.position).toBe("insideEndTop"); + }); +}); + +describe("ReferenceLine type", () => { + it("accepts minimal reference line", () => { + const line: ReferenceLine = { value: 100 }; + expect(line.value).toBe(100); + }); +}); diff --git a/component/src/charts/__tests__/single-value-chart.test.tsx b/component/src/charts/__tests__/single-value-chart.test.tsx index fe89c9bc..5f11bbf9 100644 --- a/component/src/charts/__tests__/single-value-chart.test.tsx +++ b/component/src/charts/__tests__/single-value-chart.test.tsx @@ -123,6 +123,28 @@ describe("SingleValueChart", () => { expect(container.querySelector("[style]")).not.toBeInTheDocument(); }); + // --- decimalPlaces --- + + it("formats value with decimalPlaces", () => { + render(); + expect(screen.getByText("3.14")).toBeInTheDocument(); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + render(); + expect(screen.getByText("5.00")).toBeInTheDocument(); + }); + + it("combines decimalPlaces with numberFormat comma", () => { + render(); + expect(screen.getByText("1,234,567.9")).toBeInTheDocument(); + }); + + it("ignores decimalPlaces of -1 (automatic)", () => { + render(); + expect(screen.getByText("3.14159")).toBeInTheDocument(); + }); + it("handles invalid JSON in colorThresholds gracefully", () => { expect(() => render(), diff --git a/component/src/charts/__tests__/tooltip-formatter.test.ts b/component/src/charts/__tests__/tooltip-formatter.test.ts new file mode 100644 index 00000000..82d4235c --- /dev/null +++ b/component/src/charts/__tests__/tooltip-formatter.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { buildTooltipFormatter } from "../chart-utils"; +import type { TooltipParam } from "../chart-utils"; + +describe("buildTooltipFormatter", () => { + it("returns a function", () => { + const formatter = buildTooltipFormatter(); + expect(typeof formatter).toBe("function"); + }); + + it("includes seriesName when provided", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Revenue", + value: 1234, + name: "Jan", + marker: '', + }; + const result = formatter(param); + expect(result).toContain("Revenue: "); + expect(result).toContain("Jan"); + expect(result).toContain("1,234"); + }); + + it("omits seriesName label when seriesName is undefined", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + value: 42, + name: "Category A", + }; + const result = formatter(param); + expect(result).not.toContain("undefined"); + expect(result).toContain("42"); + expect(result).toContain("Category A"); + }); + + it("omits seriesName label when seriesName is empty string", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "", + value: 100, + name: "X", + }; + const result = formatter(param); + expect(result).not.toContain(": "); + }); + + it("handles array params (axis trigger with multiple series)", () => { + const formatter = buildTooltipFormatter(); + const params: TooltipParam[] = [ + { seriesName: "A", value: 100, name: "Jan", marker: "●" }, + { seriesName: "B", value: 200, name: "Jan", marker: "●" }, + ]; + const result = formatter(params); + expect(result).toContain("A: "); + expect(result).toContain("B: "); + expect(result).toContain("100"); + expect(result).toContain("200"); + expect(result).toContain("Jan"); + }); + + it("handles array value (e.g. scatter/candlestick)", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Data", + value: ["x", 999], + name: "Point", + }; + const result = formatter(param); + expect(result).toContain("999"); + }); + + it("handles missing value gracefully", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Series", + name: "Label", + }; + const result = formatter(param); + expect(result).toContain("Series: "); + expect(result).toContain(""); + }); + + it("handles non-string marker gracefully", () => { + const formatter = buildTooltipFormatter(); + const param: TooltipParam = { + seriesName: "Test", + value: 50, + name: "X", + marker: { type: "rich" } as unknown as string, + }; + const result = formatter(param); + expect(result).toContain("Test: "); + expect(result).toContain("50"); + expect(result).not.toContain("[object"); + }); +}); diff --git a/component/src/charts/bar-chart.tsx b/component/src/charts/bar-chart.tsx index 91f8e88b..0e741dca 100644 --- a/component/src/charts/bar-chart.tsx +++ b/component/src/charts/bar-chart.tsx @@ -9,6 +9,10 @@ import { resolveShowLegend, buildCompactGrid, resolveItemColor, + buildTooltipFormatter, + buildCategoryAxisLabel, + parseReferenceLines, + buildMarkLineFromRefs, } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -34,6 +38,10 @@ export interface BarChartProps extends Omit { xAxisLabel?: string; /** Y-axis name label */ yAxisLabel?: string; + /** Override axis label rotation angle (0-90). Omit for automatic. */ + axisLabelRotation?: number; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds for per-bar coloring */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +70,8 @@ function BarChart({ showGridLines = true, xAxisLabel, yAxisLabel, + axisLabelRotation, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -79,14 +89,23 @@ function BarChart({ const effectiveShowValues = compact ? false : showValues; const effectiveBarWidth = barWidth > 0 ? barWidth : undefined; const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); + + const categoryLabels = data.map((d) => d.label); + const axisLabelConfig = buildCategoryAxisLabel(categoryLabels.length, { + compact, + rotateOverride: axisLabelRotation, + }); const categoryAxis = { type: "category" as const, - data: data.map((d) => d.label), - axisLabel: { show: !compact }, + data: categoryLabels, + axisLabel: axisLabelConfig, + axisPointer: { type: "shadow" as const }, name: compact ? undefined : (isHorizontal ? yAxisLabel : xAxisLabel), nameLocation: "middle" as const, - nameGap: 30, + nameGap: axisLabelConfig.rotate > 0 ? 50 : 30, }; const valueAxis = { type: "value" as const, @@ -98,12 +117,12 @@ function BarChart({ }; return { - tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } }, + tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const }, formatter: buildTooltipFormatter() }, legend: effectiveShowLegend ? { bottom: 0 } : undefined, grid: buildCompactGrid(compact, effectiveShowLegend), xAxis: isHorizontal ? valueAxis : categoryAxis, yAxis: isHorizontal ? categoryAxis : valueAxis, - series: seriesKeys.map((key) => ({ + series: seriesKeys.map((key, idx) => ({ name: key, type: "bar" as const, data: data.map((d) => { @@ -121,9 +140,11 @@ function BarChart({ ? { show: true, position: isHorizontal ? ("right" as const) : ("top" as const) } : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), })), }; - }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, axisLabelRotation, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index 17589360..b5027498 100644 --- a/component/src/charts/base-chart.tsx +++ b/component/src/charts/base-chart.tsx @@ -9,6 +9,8 @@ import { DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; import type { EChartsOption } from "echarts"; @@ -35,6 +37,8 @@ echarts.use([ DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, CanvasRenderer, ]); @@ -99,6 +103,8 @@ function BaseChart({ onChartReady, onClick, onDataZoom, + enableDataZoom = false, + ariaDescription, colorblindMode = false, colorPalette, }: BaseChartProps) { @@ -150,14 +156,23 @@ function BaseChart({ const merged: EChartsOption = { color: resolvedColors, ...options, + ...(enableDataZoom + ? { + dataZoom: [ + { type: "inside", xAxisIndex: 0 }, + { type: "inside", yAxisIndex: 0 }, + ], + } + : {}), aria: { enabled: true, ...userAria, + ...(ariaDescription ? { label: { description: ariaDescription } } : {}), decal: { show: colorblindMode, ...userDecal }, }, }; instance.setOption(merged, { notMerge: true }); - }, [options, colorblindMode, colorPalette, dark]); + }, [options, enableDataZoom, colorblindMode, colorPalette, dark, ariaDescription]); // Loading state useEffect(() => { @@ -213,7 +228,9 @@ function BaseChart({ ref={containerRef} className={cn("h-full w-full", className)} data-testid="base-chart" - aria-label="Chart visualization" + role="img" + aria-label={ariaDescription ?? "Chart visualization"} + tabIndex={0} /> ); } diff --git a/component/src/charts/chart-utils.ts b/component/src/charts/chart-utils.ts index 7fc9ba74..9eec948d 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -3,6 +3,246 @@ import type { ColorThreshold } from "./color-threshold"; import { resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +import type { PieChartDataPoint } from "./types"; + +// --------------------------------------------------------------------------- +// Number formatting +// --------------------------------------------------------------------------- + +export type NumberFormat = "plain" | "comma" | "compact" | "percent"; + +export interface NumberFormatConfig { + numberFormat?: NumberFormat; + decimalPlaces?: number; + prefix?: string; + suffix?: string; +} + +/** + * Format a numeric value with optional decimal places, locale formatting, + * compact notation, prefix, and suffix. Non-numeric values pass through as-is. + */ +export function formatNumber( + value: number | string, + config: NumberFormatConfig = {}, +): string { + if (typeof value !== "number" || !Number.isFinite(value)) + return String(value); + + const { + numberFormat = "plain", + decimalPlaces, + prefix = "", + suffix = "", + } = config; + + let formatted: string; + + switch (numberFormat) { + case "comma": + formatted = + decimalPlaces !== undefined + ? value.toLocaleString("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }) + : value.toLocaleString("en-US"); + break; + case "compact": + formatted = Intl.NumberFormat("en", { + notation: "compact", + ...(decimalPlaces !== undefined + ? { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + } + : {}), + }).format(value); + break; + case "percent": + formatted = + decimalPlaces !== undefined + ? `${value.toFixed(decimalPlaces)}%` + : `${value}%`; + break; + default: // "plain" + formatted = + decimalPlaces !== undefined + ? value.toFixed(decimalPlaces) + : String(value); + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +// --------------------------------------------------------------------------- +// ECharts tooltip formatter +// --------------------------------------------------------------------------- + +export interface TooltipParam { + seriesName?: string; + name?: string; + value?: number | string | (number | string)[]; + marker?: string; +} + +/** + * Build an ECharts tooltip formatter function that applies consistent number + * formatting across all chart types. Works with both single and array params + * (item trigger vs axis trigger). + */ +export function buildTooltipFormatter( + config: NumberFormatConfig = {}, +): (params: unknown) => string { + // Tooltip always uses comma format for readability unless explicitly set + const tooltipConfig: NumberFormatConfig = { + numberFormat: "comma", + ...config, + }; + + return (params: unknown) => { + const items = Array.isArray(params) + ? (params as TooltipParam[]) + : [params as TooltipParam]; + const header = items[0]?.name ?? ""; + const lines = items.map((p) => { + const raw = Array.isArray(p.value) ? p.value[1] : p.value; + const val = + typeof raw === "number" + ? formatNumber(raw, tooltipConfig) + : String(raw ?? ""); + const label = p.seriesName ? `${p.seriesName}: ` : ""; + const marker = typeof p.marker === "string" ? p.marker : ""; + return `${marker} ${label}${val}`; + }); + return header + ? `${header}
${lines.join("
")}` + : lines.join("
"); + }; +} + +// --------------------------------------------------------------------------- +// Axis label auto-rotation and truncation +// --------------------------------------------------------------------------- + +export interface CategoryAxisLabelOptions { + /** Override the automatic rotation angle. -1 means automatic (sentinel). */ + rotateOverride?: number; + /** Maximum label length before truncation (default: 15). */ + maxLabelLength?: number; + /** Whether the chart is in compact mode (hides labels). */ + compact?: boolean; +} + +export interface CategoryAxisLabelConfig { + show: boolean; + rotate: number; + formatter?: (value: string) => string; + tooltip: { show: boolean }; +} + +/** + * Compute axis label rotation and truncation based on category count. + * - 8+ categories: rotate 30° + * - 15+ categories: rotate 45° + * - Labels longer than maxLabelLength are truncated with ellipsis (U+2026) + * - ECharts axisPointer tooltip shows the full text on hover + * + * A `rotateOverride` of -1 is the "automatic" sentinel from the UI and is + * normalized to undefined so the category-count heuristic applies. + */ +export function buildCategoryAxisLabel( + categoryCount: number, + options: CategoryAxisLabelOptions = {}, +): CategoryAxisLabelConfig { + const { maxLabelLength = 15, compact = false } = options; + // Normalize -1 sentinel (automatic mode) to undefined so ECharts uses its + // default auto-rotation instead of receiving an invalid rotate: -1. + const rotateOverride = + options.rotateOverride === -1 ? undefined : options.rotateOverride; + + let rotate: number; + if (rotateOverride !== undefined) { + rotate = rotateOverride; + } else if (categoryCount >= 15) { + rotate = 45; + } else if (categoryCount >= 8) { + rotate = 30; + } else { + rotate = 0; + } + + const needsTruncation = categoryCount >= 8; + const formatter = needsTruncation + ? (value: string) => + value.length > maxLabelLength + ? value.slice(0, maxLabelLength - 1) + "\u2026" + : value + : undefined; + + return { + show: !compact, + rotate, + formatter, + tooltip: { show: true }, + }; +} + +// --------------------------------------------------------------------------- +// Reference lines (markLine) +// --------------------------------------------------------------------------- + +export interface ReferenceLine { + value: number; + label?: string; + color?: string; +} + +/** + * Parse a JSON string of reference lines. Returns empty array on + * invalid input or missing values. + */ +export function parseReferenceLines( + input: string | undefined, +): ReferenceLine[] { + if (!input) return []; + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item: unknown): item is ReferenceLine => + typeof item === "object" && + item !== null && + "value" in item && + typeof (item as ReferenceLine).value === "number", + ); + } catch { + return []; + } +} + +/** + * Build ECharts markLine data from reference lines. + */ +export function buildMarkLineFromRefs(lines: ReferenceLine[]) { + if (!lines.length) return undefined; + return { + silent: true, + symbol: "none", + data: lines.map((line) => ({ + yAxis: line.value, + label: { + formatter: line.label ?? String(line.value), + position: "insideEndTop" as const, + }, + lineStyle: { + color: line.color ?? "#888", + type: "dashed" as const, + }, + })), + }; +} /** Detect whether the document is currently in dark mode. */ export function isDark(): boolean { @@ -97,3 +337,56 @@ export function resolveItemColor( } return undefined; } + +// --------------------------------------------------------------------------- +// Gauge threshold zones +// --------------------------------------------------------------------------- + +/** + * Parse gauge threshold zones from a JSON string into the ECharts + * axisLine.lineStyle.color format: [[percentage, color], ...] + * Each zone's value is normalized to a 0-1 percentage of the min-max range. + */ +export function parseGaugeThresholdZones( + input: string | undefined, + min: number, + max: number, +): [number, string][] { + const DEFAULT_ZONE: [number, string][] = [[1, "#E6EBF8"]]; + if (!input) return DEFAULT_ZONE; + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed) || parsed.length === 0) return DEFAULT_ZONE; + const range = max - min; + if (range <= 0) return DEFAULT_ZONE; + const zones = parsed.filter( + (z: unknown): z is { value: number; color: string } => + typeof z === "object" && z !== null && "value" in z && "color" in z, + ); + return zones.map( + (z) => [(z.value - min) / range, z.color] as [number, string], + ); + } catch { + return DEFAULT_ZONE; + } +} + +// --------------------------------------------------------------------------- +// Pie chart Top-N grouping +// --------------------------------------------------------------------------- + +/** + * Group pie chart data by keeping the top N slices and aggregating the rest + * into an "Other" slice. Returns the original data when topN is 0 or >= data length. + * Data must already be sorted descending by value. + */ +export function groupTopN( + data: PieChartDataPoint[], + topN: number, +): PieChartDataPoint[] { + if (!data.length || topN <= 0 || topN >= data.length) return data; + const top = data.slice(0, topN); + const rest = data.slice(topN); + const otherValue = rest.reduce((sum, d) => sum + d.value, 0); + return [...top, { name: "Other", value: otherValue }]; +} diff --git a/component/src/charts/gauge-chart.tsx b/component/src/charts/gauge-chart.tsx index e8f2b3a9..8a9bd6e6 100644 --- a/component/src/charts/gauge-chart.tsx +++ b/component/src/charts/gauge-chart.tsx @@ -7,7 +7,7 @@ import type { EChartsOption } from "echarts"; import { BaseChart } from "./base-chart"; import type { BaseChartProps } from "./types"; import { useContainerSize } from "@/hooks/useContainerSize"; -import { buildEmptyDataOption, resolveItemColor } from "./chart-utils"; +import { buildEmptyDataOption, resolveItemColor, parseGaugeThresholdZones } from "./chart-utils"; import type { StylingRule } from "./styling-rule"; echarts.use([EGaugeChart, TitleComponent, TooltipComponent, CanvasRenderer]); @@ -34,6 +34,8 @@ export interface GaugeChartProps extends Omit { startAngle?: number; /** End angle in degrees */ endAngle?: number; + /** JSON string of threshold zones: [{ value, color }] */ + thresholdZones?: string; /** Rule-based styling rules */ stylingRules?: StylingRule[]; /** Resolved parameter values for parameterRef comparisons */ @@ -56,6 +58,7 @@ function GaugeChart({ showDetail = true, startAngle = 225, endAngle = -45, + thresholdZones: thresholdZonesJson, stylingRules, paramValues, ...rest @@ -95,6 +98,7 @@ function GaugeChart({ axisLine: { lineStyle: { width: compact ? 8 : 12, + color: parseGaugeThresholdZones(thresholdZonesJson, min, max) as never, }, }, axisTick: { @@ -140,7 +144,7 @@ function GaugeChart({ }, ], }; - }, [measured, data, min, max, startAngle, endAngle, showProgress, showPointer, showDetail, compact, stylingRules, paramValues]); + }, [measured, data, min, max, startAngle, endAngle, showProgress, showPointer, showDetail, thresholdZonesJson, compact, stylingRules, paramValues]); return (
diff --git a/component/src/charts/graph-chart.tsx b/component/src/charts/graph-chart.tsx index 0658e8eb..7bcec670 100644 --- a/component/src/charts/graph-chart.tsx +++ b/component/src/charts/graph-chart.tsx @@ -296,12 +296,21 @@ export function GraphChart({ className, }: GraphChartProps) { const nvlRef = useRef(null); - const cleanupRef = useRef<(() => void) | null>(null); + const [layoutReady, setLayoutReady] = useState(false); const [layout, setLayout] = useState( initialLayout ?? layoutProp, ); const dark = useDarkMode(); + // Reset layoutReady synchronously during render when nodes change. + // Using useEffect would race with onLayoutDone (which fires before + // effects run when the simulation completes on the main thread). + const prevNodesRef = useRef(nodes); + if (prevNodesRef.current !== nodes) { + prevNodesRef.current = nodes; + if (layoutReady) setLayoutReady(false); + } + // Build the label → property keys map from current nodes const labelPropertyMap = useMemo(() => buildLabelPropertyMap(nodes), [nodes]); @@ -375,25 +384,13 @@ export function GraphChart({ } }, []); - // When autoFit is true, schedule a delayed fit after mount so that containers - // which animate to their final size (e.g. fullscreen dialogs) have settled. - // The fullscreen dialog defers mounting until the 200ms animation completes, - // but a small extra delay ensures the canvas is fully initialized. + // When autoFit is true, fit the graph after layout has settled. + // layoutReady flips to true when onLayoutDone fires — deterministic, + // not based on an arbitrary timer. useEffect(() => { - if (!autoFit) return; - const raf = requestAnimationFrame(() => { - const timer = setTimeout(() => { - fitGraph(); - }, 100); - cleanupRef.current = () => clearTimeout(timer); - }); - return () => { - cancelAnimationFrame(raf); - cleanupRef.current?.(); - }; - // fitGraph is stable (useCallback with no deps), so this is safe - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFit]); + if (!autoFit || !layoutReady) return; + fitGraph(); + }, [autoFit, layoutReady, fitGraph]); const mouseEventCallbacks = useMemo( (): InteractiveNvlWrapperProps["mouseEventCallbacks"] => ({ @@ -437,7 +434,10 @@ export function GraphChart({ const nvlCallbacks = useMemo( () => ({ - onLayoutDone: fitGraph, + onLayoutDone: () => { + fitGraph(); + setLayoutReady(true); + }, }), [fitGraph], ); @@ -446,6 +446,7 @@ export function GraphChart({ () => ({ allowDynamicMinZoom: true, initialZoom: 0.7, + // Web workers require bundler-specific config in Next.js; keep on main thread. disableWebWorkers: true, // When physics is disabled, use a static layout (no force simulation) useStaticLayout: !physics, @@ -574,6 +575,15 @@ export function GraphChart({ )}
+ {!layoutReady && nodes.length > 0 && ( +
+
+
+ )} + { showGridLines?: boolean; /** Use stepped line style */ stepped?: boolean; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +67,7 @@ function LineChart({ lineWidth = 2, showGridLines = true, stepped = false, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -76,9 +82,11 @@ function LineChart({ const seriesKeys = Object.keys(data[0]).filter((k) => k !== "x"); const effectiveShowLegend = resolveShowLegend(showLegend, seriesKeys.length, hideLegend); const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); return { - tooltip: { trigger: "axis" }, + tooltip: { trigger: "axis", formatter: buildTooltipFormatter() }, legend: effectiveShowLegend ? { bottom: 0 } : undefined, grid: { ...buildCompactGrid(compact, effectiveShowLegend), @@ -100,7 +108,7 @@ function LineChart({ axisLabel: { show: !compact }, splitLine: { show: showGridLines }, }, - series: seriesKeys.map((key) => { + series: seriesKeys.map((key, idx) => { let lastValue: number | undefined; for (let i = data.length - 1; i >= 0; i -= 1) { const candidate = data[i][key]; @@ -123,10 +131,12 @@ function LineChart({ showSymbol: showPoints, areaStyle: area ? {} : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), }; }), }; - }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/pie-chart.tsx b/component/src/charts/pie-chart.tsx index 98e5af0d..ff6549f6 100644 --- a/component/src/charts/pie-chart.tsx +++ b/component/src/charts/pie-chart.tsx @@ -3,7 +3,7 @@ import type { EChartsOption } from "echarts"; import { BaseChart } from "./base-chart"; import type { BaseChartProps, PieChartDataPoint } from "./types"; import { useContainerSize } from "@/hooks/useContainerSize"; -import { buildEmptyDataOption, getCompactState, isDark, resolveItemColor } from "./chart-utils"; +import { buildEmptyDataOption, getCompactState, isDark, resolveItemColor, groupTopN } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -24,6 +24,10 @@ export interface PieChartProps extends Omit { showPercentage?: boolean; /** Sort slices by value descending */ sortSlices?: boolean; + /** Group slices beyond top N into "Other". 0 = show all. */ + topN?: number; + /** Text shown in the center of a donut chart (e.g. total value). Empty = auto-total. */ + donutCenterText?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -49,6 +53,8 @@ function PieChart({ labelPosition = "outside", showPercentage = true, sortSlices = false, + topN = 0, + donutCenterText, colorThresholds, stylingRules, paramValues, @@ -58,15 +64,18 @@ function PieChart({ const compact = width > 0 && (width < 300 || height < 200); const { hideLegend } = getCompactState(width, height); - const options = useMemo((): EChartsOption => { + // EChartsOption from modular imports may not include 'graphic' — + // we use GraphicComponent which extends the option type at runtime. + const options = useMemo((): EChartsOption & { graphic?: unknown } => { if (!data.length) return buildEmptyDataOption(); const effectiveShowLabel = compact ? false : showLabel; const effectiveShowLegend = hideLegend ? false : showLegend; - const sortedData = sortSlices + const sorted = sortSlices ? [...data].sort((a, b) => b.value - a.value) : data; + const sortedData = groupTopN(sorted, topN); const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); const coloredData = sortedData.map((d) => { @@ -111,8 +120,23 @@ function PieChart({ }, }, ], + // Donut center text: show total or custom text in the center hole + ...(donut && !compact ? { + graphic: [{ + type: "text", + left: "center", + top: effectiveShowLegend ? "42%" : "47%", + style: { + text: donutCenterText ?? String(sortedData.reduce((s, d) => s + d.value, 0)), + align: "center", + fontSize: 20, + fontWeight: "bold", + fill: isDark() ? "#e5e5e5" : "#262626", + }, + }], + } : {}), }; - }, [data, donut, showLabel, showLegend, roseMode, labelPosition, showPercentage, sortSlices, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, donut, showLabel, showLegend, roseMode, labelPosition, showPercentage, sortSlices, topN, donutCenterText, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/single-value-chart.tsx b/component/src/charts/single-value-chart.tsx index 76e67aa6..fbb747b0 100644 --- a/component/src/charts/single-value-chart.tsx +++ b/component/src/charts/single-value-chart.tsx @@ -3,24 +3,12 @@ import { cn } from "@/lib/utils"; import { parseColorThresholds, resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +import { formatNumber } from "./chart-utils"; +import type { NumberFormat } from "./chart-utils"; export type { ColorThreshold } from "./color-threshold"; export type SingleValueFontSize = "sm" | "md" | "lg" | "xl"; -export type SingleValueNumberFormat = "plain" | "comma" | "compact" | "percent"; - -/** Format a numeric value according to the chosen format. */ -function applyNumberFormat(numericValue: number, fmt: SingleValueNumberFormat): string { - switch (fmt) { - case "comma": - return numericValue.toLocaleString(); - case "compact": - return Intl.NumberFormat("en", { notation: "compact" }).format(numericValue); - case "percent": - return `${numericValue}%`; - default: - return String(numericValue); - } -} +export type SingleValueNumberFormat = NumberFormat; const FONT_SIZE_CLASS: Record = { sm: "text-xl", @@ -46,6 +34,8 @@ export interface SingleValueChartProps { fontSize?: SingleValueFontSize; /** Built-in number formatting applied when value is numeric and format is not provided */ numberFormat?: SingleValueNumberFormat; + /** Fixed decimal places (0-6). Set to -1 or omit for automatic. */ + decimalPlaces?: number; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -73,6 +63,7 @@ function SingleValueChart({ format, fontSize = "lg", numberFormat = "plain", + decimalPlaces, colorThresholds, stylingRules, paramValues, @@ -96,10 +87,9 @@ function SingleValueChart({ if (typeof value === "number") { if (format) { displayValue = format(value); - } else if (numberFormat !== "plain") { - displayValue = applyNumberFormat(value, numberFormat); } else { - displayValue = value; + const dp = decimalPlaces !== undefined && decimalPlaces >= 0 ? decimalPlaces : undefined; + displayValue = formatNumber(value, { numberFormat, decimalPlaces: dp }); } } else { displayValue = value; diff --git a/component/src/charts/styling-rule.ts b/component/src/charts/styling-rule.ts index 12231523..03356643 100644 --- a/component/src/charts/styling-rule.ts +++ b/component/src/charts/styling-rule.ts @@ -6,6 +6,8 @@ export type StylingOperator = export interface StylingRule { id: string; + /** For tables: which column this rule evaluates against */ + column?: string; operator: StylingOperator; value: number | string; /** Upper bound for the "between" operator (inclusive) */ @@ -16,13 +18,22 @@ export interface StylingRule { parameterRefTo?: string; color: string; target?: "color" | "backgroundColor" | "textColor"; + bold?: boolean; } export interface StylingConfig { enabled: boolean; rules: StylingRule[]; - /** For tables: which column to evaluate rules against */ - targetColumn?: string; +} + +// --------------------------------------------------------------------------- +// Color scale config +// --------------------------------------------------------------------------- + +export interface ColorScaleConfig { + column: string; + minColor: string; + maxColor: string; } const NUMERIC_OPS = new Set(["<=", ">=", "<", ">", "==", "!="]); @@ -148,3 +159,52 @@ export function resolveStylingRuleColor( return undefined; } + +// --------------------------------------------------------------------------- +// Color scale (gradient interpolation) +// --------------------------------------------------------------------------- + +function parseHex(hex: string): [number, number, number] { + let h = hex.replace("#", ""); + // Expand 3-char shorthand: #f00 → ff0000 + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; +} + +function toHex(r: number, g: number, b: number): string { + const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n))); + return ( + "#" + + clamp(r).toString(16).padStart(2, "0") + + clamp(g).toString(16).padStart(2, "0") + + clamp(b).toString(16).padStart(2, "0") + ); +} + +/** + * Linearly interpolate between two hex colors based on a value's position + * within [min, max]. Values outside the range are clamped. + */ +export function interpolateColor( + value: number, + min: number, + max: number, + minColor: string, + maxColor: string, +): string { + if (min === max) return minColor.length === 4 ? toHex(...parseHex(minColor)) : minColor; + const t = Math.max(0, Math.min(1, (value - min) / (max - min))); + const [r1, g1, b1] = parseHex(minColor); + const [r2, g2, b2] = parseHex(maxColor); + return toHex( + r1 + t * (r2 - r1), + g1 + t * (g2 - g1), + b1 + t * (b2 - b1), + ); +} diff --git a/component/src/charts/types.ts b/component/src/charts/types.ts index 9fff6050..7a9abc38 100644 --- a/component/src/charts/types.ts +++ b/component/src/charts/types.ts @@ -16,6 +16,10 @@ export interface BaseChartProps { onClick?: (params: EChartsClickEvent) => void; /** Called when data zoom changes */ onDataZoom?: (params: unknown) => void; + /** Enable scroll-to-zoom on the data axis (DataZoom type: 'inside') */ + enableDataZoom?: boolean; + /** Custom ARIA description for screen readers (e.g. "Bar chart showing revenue by month") */ + ariaDescription?: string; /** Enable decal overlay patterns for colorblind accessibility */ colorblindMode?: boolean; /** diff --git a/component/src/components/composed/__tests__/chart-options-panel.test.tsx b/component/src/components/composed/__tests__/chart-options-panel.test.tsx index b37345a1..d9c8d88d 100644 --- a/component/src/components/composed/__tests__/chart-options-panel.test.tsx +++ b/component/src/components/composed/__tests__/chart-options-panel.test.tsx @@ -1,8 +1,13 @@ import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll } from "vitest"; import { ChartOptionsPanel } from "../chart-options-panel"; import { getChartOptions } from "../chart-options-schema"; +// cmdk calls scrollIntoView which jsdom doesn't implement +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + /** Expand all collapsed category sections so their content is in the DOM. */ function expandAllCategories() { screen.getAllByRole("button", { expanded: false }).forEach((btn) => fireEvent.click(btn)); @@ -155,4 +160,53 @@ describe("ChartOptionsPanel", () => { expect(label.classList.contains("decoration-dotted")).toBe(true); }); }); + + it("renders MultiSelect for column-multi-select type when columns are provided", () => { + render( + + ); + expandAllCategories(); + // MultiSelect renders a combobox trigger with placeholder text + expect(screen.getByText("Select columns…")).toBeInTheDocument(); + }); + + it("renders text fallback for column-multi-select when no columns are provided", () => { + render( + + ); + expandAllCategories(); + // Falls back to a text input when columns are not available + const input = screen.getByPlaceholderText("Run a preview query to select columns"); + expect(input).toBeInTheDocument(); + expect(input.tagName).toBe("INPUT"); + }); + + it("calls onSettingsChange with comma-separated string when multi-select changes", () => { + const onChange = vi.fn(); + render( + + ); + expandAllCategories(); + // MultiSelect trigger shows placeholder when nothing selected + const trigger = screen.getByText("Select columns…").closest("button")!; + fireEvent.click(trigger); + // Select "city" + const cityOption = screen.getByRole("option", { name: "city" }); + fireEvent.click(cityOption); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ groupBy: "city" })); + }); }); diff --git a/component/src/components/composed/__tests__/chart-options-schema.test.ts b/component/src/components/composed/__tests__/chart-options-schema.test.ts index 77663381..c2bdff8b 100644 --- a/component/src/components/composed/__tests__/chart-options-schema.test.ts +++ b/component/src/components/composed/__tests__/chart-options-schema.test.ts @@ -59,6 +59,14 @@ describe("getChartOptions", () => { expect(keys).toContain("emptyMessage"); }); + it("groupBy option has type column-multi-select", () => { + const options = getChartOptions("table"); + const groupBy = options.find((o) => o.key === "groupBy"); + expect(groupBy).toBeDefined(); + expect(groupBy!.type).toBe("column-multi-select"); + expect(groupBy!.category).toBe("Grouping"); + }); + it("returns options for json chart", () => { const keys = getChartOptions("json").map((o) => o.key); expect(keys).toContain("initialExpanded"); @@ -84,7 +92,7 @@ describe("getChartOptions", () => { expect(opt).toHaveProperty("type"); expect(opt).toHaveProperty("default"); expect(opt).toHaveProperty("category"); - expect(["boolean", "select", "text", "number"]).toContain(opt.type); + expect(["boolean", "select", "text", "number", "column-multi-select"]).toContain(opt.type); } } }); diff --git a/component/src/components/composed/__tests__/conditional-format-panel.test.tsx b/component/src/components/composed/__tests__/conditional-format-panel.test.tsx new file mode 100644 index 00000000..d2ce12e5 --- /dev/null +++ b/component/src/components/composed/__tests__/conditional-format-panel.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ColorScalePanel } from "../conditional-format-panel"; +import type { ColorScaleConfig } from "@/charts/styling-rule"; + +const COLUMNS = ["name", "score", "status"]; + +describe("ColorScalePanel", () => { + it("renders empty state with add button", () => { + render( + {}} + />, + ); + expect(screen.getByRole("button", { name: /add color scale/i })).toBeInTheDocument(); + }); + + it("renders existing color scales", () => { + const scales: ColorScaleConfig[] = [ + { column: "score", minColor: "#ef4444", maxColor: "#22c55e" }, + ]; + render( + {}} + />, + ); + expect(screen.getByRole("button", { name: /remove color scale/i })).toBeInTheDocument(); + }); + + it("adds a color scale", async () => { + const user = userEvent.setup(); + const onColorScalesChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /add color scale/i })); + expect(onColorScalesChange).toHaveBeenCalledTimes(1); + const scales = onColorScalesChange.mock.calls[0][0] as ColorScaleConfig[]; + expect(scales).toHaveLength(1); + expect(scales[0].column).toBe(COLUMNS[0]); + }); + + it("removes a color scale", async () => { + const user = userEvent.setup(); + const onColorScalesChange = vi.fn(); + const scales: ColorScaleConfig[] = [ + { column: "score", minColor: "#ef4444", maxColor: "#22c55e" }, + ]; + render( + , + ); + await user.click(screen.getByRole("button", { name: /remove color scale/i })); + expect(onColorScalesChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/component/src/components/composed/__tests__/data-grid-grouping.test.tsx b/component/src/components/composed/__tests__/data-grid-grouping.test.tsx new file mode 100644 index 00000000..8f51d6d3 --- /dev/null +++ b/component/src/components/composed/__tests__/data-grid-grouping.test.tsx @@ -0,0 +1,226 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { DataGrid } from "../data-grid"; +import type { ColumnDef } from "@tanstack/react-table"; + +interface SalesRow { + country: string; + city: string; + department: string; + revenue: number; + headcount: number; +} + +const columns: ColumnDef[] = [ + { accessorKey: "country", header: "Country" }, + { accessorKey: "city", header: "City" }, + { accessorKey: "department", header: "Department" }, + { accessorKey: "revenue", header: "Revenue" }, + { accessorKey: "headcount", header: "Headcount" }, +]; + +const data: SalesRow[] = [ + { country: "US", city: "New York", department: "Sales", revenue: 100, headcount: 5 }, + { country: "US", city: "New York", department: "Engineering", revenue: 200, headcount: 10 }, + { country: "US", city: "Chicago", department: "Sales", revenue: 80, headcount: 3 }, + { country: "UK", city: "London", department: "Sales", revenue: 150, headcount: 7 }, + { country: "UK", city: "London", department: "Engineering", revenue: 300, headcount: 15 }, + { country: "UK", city: "Manchester", department: "HR", revenue: 50, headcount: 2 }, +]; + +describe("DataGrid grouping", () => { + it("renders group rows when grouping is enabled", () => { + render( + , + ); + // Group rows should contain the group value + expect(screen.getByText(/US/)).toBeInTheDocument(); + expect(screen.getByText(/UK/)).toBeInTheDocument(); + }); + + it("collapses and expands groups on click", async () => { + const user = userEvent.setup(); + render( + , + ); + // All rows should be visible initially (groups expanded by default) + expect(screen.getAllByRole("row").length).toBeGreaterThan(2); + + // Click the first group toggle to collapse + const toggles = screen.getAllByRole("button", { name: /toggle group/i }); + expect(toggles.length).toBeGreaterThan(0); + await user.click(toggles[0]); + + // After collapsing, fewer rows should be visible + const rowsAfterCollapse = screen.getAllByRole("row"); + // Header + collapsed group + remaining group rows + expect(rowsAfterCollapse.length).toBeLessThan(data.length + 3); + }); + + it("shows aggregation values in group header rows", () => { + const columnsWithAgg: ColumnDef[] = [ + { accessorKey: "country", header: "Country" }, + { accessorKey: "city", header: "City" }, + { accessorKey: "department", header: "Department" }, + { + accessorKey: "revenue", + header: "Revenue", + aggregationFn: "sum", + aggregatedCell: ({ getValue }) => `Total: ${getValue()}`, + }, + { accessorKey: "headcount", header: "Headcount" }, + ]; + render( + , + ); + // US: 100+200+80=380, UK: 150+300+50=500 + expect(screen.getByText("Total: 380")).toBeInTheDocument(); + expect(screen.getByText("Total: 500")).toBeInTheDocument(); + }); + + it("supports multi-level grouping", () => { + render( + , + ); + // Should have both country and city group rows + expect(screen.getByText(/US/)).toBeInTheDocument(); + expect(screen.getByText(/New York/)).toBeInTheDocument(); + expect(screen.getByText(/Chicago/)).toBeInTheDocument(); + expect(screen.getByText(/UK/)).toBeInTheDocument(); + expect(screen.getByText(/London/)).toBeInTheDocument(); + }); + + it("renders expand/collapse all toggle", async () => { + const user = userEvent.setup(); + render( + , + ); + const collapseAllBtn = screen.getByRole("button", { name: /collapse all/i }); + expect(collapseAllBtn).toBeInTheDocument(); + await user.click(collapseAllBtn); + + // After collapsing all, only header + group header rows remain + // 1 header row + 2 group rows (US, UK) = 3 total + const rows = screen.getAllByRole("row"); + expect(rows.length).toBe(3); + }); + + it("shows row count in group header", () => { + render( + , + ); + // US has 3 rows, UK has 3 rows — both show (3) + const counts = screen.getAllByText("(3)"); + expect(counts).toHaveLength(2); + }); + + it("produces different aggregated values for different aggregation functions", () => { + // Render with sum aggregation + const makeColumns = (aggFn: string): ColumnDef[] => [ + { accessorKey: "country", header: "Country" }, + { + accessorKey: "revenue", + header: "Revenue", + aggregationFn: aggFn as "sum" | "mean" | "count", + aggregatedCell: ({ getValue }) => { + const v = getValue(); + return v != null ? `${aggFn}: ${typeof v === "number" ? v : String(v)}` : null; + }, + }, + ]; + + // Sum: US = 100+200+80 = 380 + const { unmount: u1 } = render( + , + ); + expect(screen.getByText("sum: 380")).toBeInTheDocument(); + u1(); + + // Count: US has 3 rows + const { unmount: u2 } = render( + , + ); + // Both US and UK have 3 rows + expect(screen.getAllByText("count: 3")).toHaveLength(2); + u2(); + + // Mean: US = 380/3 ≈ 126.67 + render( + , + ); + // Mean of [100, 200, 80] = 126.666... + const meanCell = screen.getByText(/mean: 126/); + expect(meanCell).toBeInTheDocument(); + }); + + it("does not show grouping UI when enableGrouping is false", () => { + render( + , + ); + expect(screen.queryByRole("button", { name: /toggle group/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /collapse all/i })).not.toBeInTheDocument(); + }); +}); diff --git a/component/src/components/composed/__tests__/data-grid.test.tsx b/component/src/components/composed/__tests__/data-grid.test.tsx index c51246ec..5a1722ef 100644 --- a/component/src/components/composed/__tests__/data-grid.test.tsx +++ b/component/src/components/composed/__tests__/data-grid.test.tsx @@ -303,4 +303,74 @@ describe("DataGrid", () => { ); expect(container.firstChild).toHaveClass("custom-class"); }); + + describe("column resizing", () => { + it("renders resize handles when enableColumnResizing is true", () => { + render(); + const handles = document.querySelectorAll(".cursor-col-resize"); + expect(handles.length).toBeGreaterThan(0); + }); + + it("does not render resize handles when enableColumnResizing is false", () => { + render(); + const handles = document.querySelectorAll(".cursor-col-resize"); + expect(handles.length).toBe(0); + }); + + it("sets inline width on header cells when resizing is enabled", () => { + render(); + const headers = document.querySelectorAll("th"); + for (const th of headers) { + expect(th.style.width).toBeTruthy(); + } + }); + }); + + describe("getCellStyle", () => { + it("applies inline styles to individual cells", () => { + const getCellStyle = (_row: TestRow, columnId: string) => { + if (columnId === "status") return { backgroundColor: "#22c55e" }; + return undefined; + }; + render(); + // All status cells should have the background color + const rows = screen.getAllByRole("row").slice(1); // skip header + for (const row of rows) { + const cells = row.querySelectorAll("td"); + // status is the 3rd column (index 2) + expect(cells[2].style.backgroundColor).toBe("rgb(34, 197, 94)"); + // other cells should not have it + expect(cells[0].style.backgroundColor).toBe(""); + } + }); + + it("applies bold style via font-weight", () => { + const getCellStyle = (_row: TestRow, columnId: string) => { + if (columnId === "name") return { fontWeight: "bold" }; + return undefined; + }; + render(); + const rows = screen.getAllByRole("row").slice(1); + for (const row of rows) { + const cells = row.querySelectorAll("td"); + expect(cells[0].style.fontWeight).toBe("bold"); + } + }); + + it("does not interfere with row styles", () => { + const getRowStyle = () => ({ backgroundColor: "#eee" }); + const getCellStyle = (_row: TestRow, columnId: string) => { + if (columnId === "status") return { color: "red" }; + return undefined; + }; + render( + + ); + const rows = screen.getAllByRole("row").slice(1); + // Row has background, cell has text color + expect(rows[0].style.backgroundColor).toBe("rgb(238, 238, 238)"); + const statusCell = rows[0].querySelectorAll("td")[2]; + expect(statusCell.style.color).toBe("red"); + }); + }); }); diff --git a/component/src/components/composed/__tests__/markdown-widget.test.tsx b/component/src/components/composed/__tests__/markdown-widget.test.tsx index 375e3284..cbe49c54 100644 --- a/component/src/components/composed/__tests__/markdown-widget.test.tsx +++ b/component/src/components/composed/__tests__/markdown-widget.test.tsx @@ -392,4 +392,67 @@ describe("MarkdownWidget", () => { const container = screen.getByTestId("markdown-widget"); expect(container.innerHTML).toContain(""); }); + + // ── GFM Tables ──────────────────────────────────────────────────────────── + + it("renders a basic GFM table with headers and body rows", () => { + const md = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + const headers = table!.querySelectorAll("th"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toBe("Name"); + expect(headers[1].textContent).toBe("Age"); + const rows = table!.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(2); + const cells = rows[0].querySelectorAll("td"); + expect(cells[0].textContent).toBe("Alice"); + expect(cells[1].textContent).toBe("30"); + }); + + it("renders a GFM table with alignment markers (colons)", () => { + const md = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.querySelector("table")).not.toBeNull(); + const headers = container.querySelectorAll("th"); + expect(headers).toHaveLength(3); + }); + + it("renders table with empty cells when row has fewer columns than header", () => { + const md = "| A | B | C |\n| --- | --- | --- |\n| x |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const cells = container.querySelectorAll("tbody td"); + expect(cells).toHaveLength(3); + // Last two cells should be empty + expect(cells[1].textContent).toBe(""); + expect(cells[2].textContent).toBe(""); + }); + + it("closes an open list before rendering a table", () => { + const md = "- item\n| A | B |\n| --- | --- |\n| 1 | 2 |"; + render(); + const container = screen.getByTestId("markdown-widget"); + const ulCloseIndex = container.innerHTML.indexOf(""); + const tableIndex = container.innerHTML.indexOf(" { + const md = "| Header |\n| --- |\n| |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.innerHTML).toContain("<script>"); + expect(container.querySelector("script")).toBeNull(); + }); + + it("does not treat lines as table when alignment row is missing", () => { + const md = "| not | a | table |\n| these are just pipes |"; + render(); + const container = screen.getByTestId("markdown-widget"); + expect(container.querySelector("table")).toBeNull(); + }); }); diff --git a/component/src/components/composed/__tests__/query-editor.test.tsx b/component/src/components/composed/__tests__/query-editor.test.tsx index 5ba6f7a3..0d185b1e 100644 --- a/component/src/components/composed/__tests__/query-editor.test.tsx +++ b/component/src/components/composed/__tests__/query-editor.test.tsx @@ -89,6 +89,8 @@ vi.mock("@codemirror/commands", () => ({ vi.mock("@codemirror/autocomplete", () => ({ autocompletion: () => ({ type: "autocompletion" }), completionKeymap: [], + closeBrackets: () => ({ type: "closeBrackets" }), + closeBracketsKeymap: [], })); vi.mock("@codemirror/theme-one-dark", () => ({ diff --git a/component/src/components/composed/chart-options-panel.tsx b/component/src/components/composed/chart-options-panel.tsx index 8d5c9c72..01424946 100644 --- a/component/src/components/composed/chart-options-panel.tsx +++ b/component/src/components/composed/chart-options-panel.tsx @@ -19,12 +19,15 @@ import { } from "@/components/ui/tooltip"; import { ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; +import { MultiSelect } from "./multi-select"; export interface ChartOptionsPanelProps { chartType: string; settings: Record; onSettingsChange: (settings: Record) => void; className?: string; + /** Available column names from query results — used for column-multi-select fields. */ + columns?: string[]; } function OptionLabel({ option }: { option: ChartOptionDef }) { @@ -56,10 +59,12 @@ function OptionField({ option, value, onChange, + columns, }: { option: ChartOptionDef; value: unknown; onChange: (key: string, value: unknown) => void; + columns?: string[]; }) { switch (option.type) { case "boolean": @@ -96,6 +101,38 @@ function OptionField({
); + case "column-multi-select": { + // Fall back to text input when columns are not yet available (no preview query) + if (!columns?.length) { + return ( +
+ + onChange(option.key, e.target.value)} + placeholder="Run a preview query to select columns" + /> +
+ ); + } + const csv = String(value ?? option.default ?? ""); + const selected = csv ? csv.split(",").map((s) => s.trim()).filter(Boolean) : []; + const multiOptions = columns.map((col) => ({ value: col, label: col })); + return ( +
+ + onChange(option.key, vals.join(","))} + placeholder="Select columns…" + className="w-full" + /> +
+ ); + } + case "text": return (
@@ -162,6 +199,7 @@ function ChartOptionsPanel({ settings, onSettingsChange, className, + columns, }: ChartOptionsPanelProps) { const [search, setSearch] = React.useState(""); const options = getChartOptions(chartType); @@ -217,6 +255,7 @@ function ChartOptionsPanel({ option={opt} value={settings[opt.key]} onChange={handleChange} + columns={columns} /> ))} diff --git a/component/src/components/composed/chart-options-schema.ts b/component/src/components/composed/chart-options-schema.ts index 384d21eb..da9b8e91 100644 --- a/component/src/components/composed/chart-options-schema.ts +++ b/component/src/components/composed/chart-options-schema.ts @@ -3,7 +3,7 @@ import { COLOR_PALETTES } from "@/charts/palettes"; export interface ChartOptionDef { key: string; label: string; - type: "boolean" | "select" | "text" | "number"; + type: "boolean" | "select" | "text" | "number" | "column-multi-select"; default: unknown; category: string; /** Only for type: "select" */ @@ -12,6 +12,16 @@ export interface ChartOptionDef { description?: string; } +/** DataZoom option for axis-based charts (bar, line). */ +const dataZoomOptions: ChartOptionDef[] = [ + { key: "enableDataZoom", label: "Enable Scroll Zoom", type: "boolean", default: false, category: "Interaction", description: "Allow scroll-to-zoom on the data axis to explore large datasets." }, +]; + +/** Shared number formatting options for tooltip values on axis-based charts. */ +const tooltipFormatOptions: ChartOptionDef[] = [ + { key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Labels", description: "Fixed number of decimal places in tooltips (0-6). Set to -1 for automatic." }, +]; + const barOptions: ChartOptionDef[] = [ { key: "orientation", @@ -33,6 +43,8 @@ const barOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showGridLines", label: "Show Grid Lines", type: "boolean", default: true, category: "Style", description: "Show faint horizontal reference lines behind the bars." }, + { key: "axisLabelRotation", label: "Axis Label Rotation (°)", type: "number", default: -1, category: "Labels", description: "Override axis label rotation angle (0-90). Set to -1 for automatic (rotates at 8+ categories)." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const lineOptions: ChartOptionDef[] = [ @@ -45,6 +57,7 @@ const lineOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showLegend", label: "Show Legend", type: "boolean", default: true, category: "Labels", description: "Show the chart legend identifying each data series." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const pieOptions: ChartOptionDef[] = [ @@ -67,12 +80,15 @@ const pieOptions: ChartOptionDef[] = [ { key: "showPercentage", label: "Show Percentage", type: "boolean", default: true, category: "Labels", description: "Show the percentage value on each slice." }, { key: "showLegend", label: "Show Legend", type: "boolean", default: true, category: "Labels", description: "Show the chart legend identifying each slice." }, { key: "sortSlices", label: "Sort Slices by Value", type: "boolean", default: false, category: "Layout", description: "Sort slices by value (largest first) for a cleaner visual layout." }, + { key: "topN", label: "Top N Slices", type: "number", default: 0, category: "Layout", description: "Show only the top N slices and group the rest into 'Other'. Set to 0 to show all." }, + { key: "donutCenterText", label: "Donut Center Text", type: "text", default: "", category: "Labels", description: "Custom text in the donut center. Leave blank to show the total." }, ]; const singleValueOptions: ChartOptionDef[] = [ { key: "title", label: "Title", type: "text", default: "", category: "Display", description: "Custom heading shown above the value. Leave blank to hide." }, { key: "prefix", label: "Prefix", type: "text", default: "", category: "Display", description: "Text prepended to the value (e.g. '$', '€')." }, { key: "suffix", label: "Suffix", type: "text", default: "", category: "Display", description: "Text appended to the value (e.g. '%', ' items')." }, + { key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Display", description: "Fixed number of decimal places (0-6). Set to -1 for automatic." }, { key: "fontSize", label: "Font Size", @@ -164,9 +180,13 @@ const tableOptions: ChartOptionDef[] = [ { key: "enableSelection", label: "Row Selection", type: "boolean", default: false, category: "Features", description: "Allow selecting individual rows by clicking them." }, { key: "enableGlobalFilter", label: "Global Search", type: "boolean", default: true, category: "Features", description: "Show a search box that filters all rows across all columns." }, { key: "enableColumnFilters", label: "Column Filters", type: "boolean", default: true, category: "Features", description: "Show per-column filter inputs below each column header." }, + { key: "enableColumnResizing", label: "Column Resizing", type: "boolean", default: false, category: "Features", description: "Allow drag-to-resize column borders. Double-click to auto-fit." }, { key: "enablePagination", label: "Enable Pagination", type: "boolean", default: true, category: "Pagination", description: "Show Previous / Next controls to page through large result sets." }, { key: "pageSize", label: "Page Size", type: "number", default: 10, category: "Pagination", description: "Number of rows shown per page when pagination is enabled." }, { key: "emptyMessage", label: "Empty Message", type: "text", default: "No results", category: "Display", description: "Text displayed when the query returns no rows." }, + { key: "enableGrouping", label: "Enable Row Grouping", type: "boolean", default: false, category: "Grouping", description: "Allow grouping rows by column values. Columns to group by are set in the groupBy field below." }, + { key: "groupBy", label: "Group By Columns", type: "column-multi-select", default: "", category: "Grouping", description: "Select columns to group by. Nested grouping is supported — order determines nesting hierarchy." }, + { key: "aggregationFn", label: "Aggregation Function", type: "select", default: "sum", category: "Grouping", description: "Aggregation function for numeric columns in grouped rows.", options: [{ label: "Sum", value: "sum" }, { label: "Average", value: "mean" }, { label: "Median", value: "median" }, { label: "Count", value: "count" }, { label: "Min", value: "min" }, { label: "Max", value: "max" }] }, ]; const jsonOptions: ChartOptionDef[] = [ @@ -288,6 +308,7 @@ const gaugeOptions: ChartOptionDef[] = [ { key: "showDetail", label: "Show Value Detail", type: "boolean", default: true, category: "Labels", description: "Show the numeric value and name below the gauge." }, { key: "startAngle", label: "Start Angle (°)", type: "number", default: 225, category: "Layout", description: "Starting angle of the gauge arc in degrees (0 = 3 o'clock)." }, { key: "endAngle", label: "End Angle (°)", type: "number", default: -45, category: "Layout", description: "Ending angle of the gauge arc in degrees." }, + { key: "thresholdZones", label: "Threshold Zones (JSON)", type: "text", default: "", category: "Style", description: 'Colored zones on the gauge arc: [{"value":30,"color":"#67e0e3"},{"value":70,"color":"#37a2da"},{"value":100,"color":"#fd666d"}]' }, ]; const sankeyOptions: ChartOptionDef[] = [ @@ -364,9 +385,9 @@ const treemapOptions: ChartOptionDef[] = [ ]; const chartOptionsRegistry: Record = { - bar: [...barOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - line: [...lineOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], - pie: [...pieOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + bar: [...barOptions, ...dataZoomOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + line: [...lineOptions, ...dataZoomOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], + pie: [...pieOptions, ...tooltipFormatOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions], "single-value": [...singleValueOptions, ...behaviorOptions], graph: [...graphOptions, ...behaviorOptions], map: [...mapOptions, ...behaviorOptions], diff --git a/component/src/components/composed/code-preview.tsx b/component/src/components/composed/code-preview.tsx index 30fbbe6f..2535e6b9 100644 --- a/component/src/components/composed/code-preview.tsx +++ b/component/src/components/composed/code-preview.tsx @@ -34,7 +34,7 @@ function CodePreview({ value, language, maxLines = 3, className }: Readonly {language && ( - + {language} )} diff --git a/component/src/components/composed/conditional-format-panel.tsx b/component/src/components/composed/conditional-format-panel.tsx new file mode 100644 index 00000000..4033dfe1 --- /dev/null +++ b/component/src/components/composed/conditional-format-panel.tsx @@ -0,0 +1,130 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Trash2, Plus } from "lucide-react"; +import type { ColorScaleConfig } from "@/charts/styling-rule"; + +export interface ColorScalePanelProps { + columns: string[]; + colorScales: ColorScaleConfig[]; + onColorScalesChange: (scales: ColorScaleConfig[]) => void; +} + +/** @deprecated Use ColorScalePanelProps instead */ +export type ConditionalFormatPanelProps = ColorScalePanelProps; + +function ColorScaleRow({ + scale, + columns, + onChange, + onRemove, +}: { + scale: ColorScaleConfig; + columns: string[]; + onChange: (updated: ColorScaleConfig) => void; + onRemove: () => void; +}) { + return ( +
+
+ + +
+ +
+ + onChange({ ...scale, minColor: e.target.value })} + /> +
+ +
+ +
+ + onChange({ ...scale, maxColor: e.target.value })} + /> +
+ + +
+ ); +} + +function ColorScalePanel({ + columns, + colorScales, + onColorScalesChange, +}: ColorScalePanelProps) { + function addColorScale() { + const newScale: ColorScaleConfig = { + column: columns[0] ?? "", + minColor: "#ef4444", + maxColor: "#22c55e", + }; + onColorScalesChange([...colorScales, newScale]); + } + + function updateColorScale(index: number, updated: ColorScaleConfig) { + const next = [...colorScales]; + next[index] = updated; + onColorScalesChange(next); + } + + function removeColorScale(index: number) { + onColorScalesChange(colorScales.filter((_, i) => i !== index)); + } + + return ( +
+ {colorScales.map((scale, i) => ( + updateColorScale(i, updated)} + onRemove={() => removeColorScale(i)} + /> + ))} + +
+ ); +} + +/** @deprecated Use ColorScalePanel instead */ +const ConditionalFormatPanel = ColorScalePanel; + +export { ColorScalePanel, ConditionalFormatPanel }; diff --git a/component/src/components/composed/cross-filter-tag.tsx b/component/src/components/composed/cross-filter-tag.tsx index 6af42562..3e66a108 100644 --- a/component/src/components/composed/cross-filter-tag.tsx +++ b/component/src/components/composed/cross-filter-tag.tsx @@ -28,25 +28,40 @@ function CrossFilterTag({ className, ); + // When onClick is set the outer element is a + ) + ); + const content = ( <> {field} = {value} - {onRemove && ( - - )} + {removeControl} ); diff --git a/component/src/components/composed/dashboard-grid.tsx b/component/src/components/composed/dashboard-grid.tsx index 79f5e0e6..9d5f2032 100644 --- a/component/src/components/composed/dashboard-grid.tsx +++ b/component/src/components/composed/dashboard-grid.tsx @@ -8,6 +8,7 @@ import { } from "react-grid-layout"; import type { LayoutItem, Layout } from "react-grid-layout"; import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; @@ -34,6 +35,42 @@ function getCompactorByType(type: "vertical" | "horizontal" | null) { return verticalCompactor; } +/** Skeleton grid shown while the container width is being measured. */ +function GridSkeleton({ + layout, + cols, + rowHeight, +}: { + layout: LayoutItem[]; + cols: number; + rowHeight: number; +}) { + if (layout.length === 0) return null; + const colPercent = 100 / cols; + const maxBottom = layout.reduce( + (max, item) => Math.max(max, (item.y + item.h) * rowHeight), + 0, + ); + return ( +
+ {layout.map((item) => ( +
+ +
+ ))} +
+ ); +} + function DashboardGrid({ layout, onLayoutChange, @@ -51,11 +88,14 @@ function DashboardGrid({ const layouts = React.useMemo( () => ({ lg: layout, md: layout, sm: layout, xs: layout }), - [layout] + [layout], ); return (
+ {!mounted && ( + + )} {mounted && ( { diff --git a/component/src/components/composed/data-grid.tsx b/component/src/components/composed/data-grid.tsx index 88d0eff5..dfabe2f8 100644 --- a/component/src/components/composed/data-grid.tsx +++ b/component/src/components/composed/data-grid.tsx @@ -7,6 +7,8 @@ import { getFilteredRowModel, getFacetedRowModel, getFacetedUniqueValues, + getGroupedRowModel, + getExpandedRowModel, useReactTable, } from "@tanstack/react-table"; import type { @@ -15,8 +17,11 @@ import type { VisibilityState, RowSelectionState, ColumnFiltersState, + GroupingState, + ExpandedState, Table, } from "@tanstack/react-table"; +import { ChevronDown, ChevronRight, ChevronsDownUp } from "lucide-react"; import { Table as UITable, TableBody, @@ -66,6 +71,8 @@ export interface DataGridProps { * Whether to show pagination controls. Defaults to `true`. * When `false` all rows are rendered on a single page. */ + /** Allow drag-to-resize column borders. */ + enableColumnResizing?: boolean; enablePagination?: boolean; /** * Fixed fallback page size used when `containerHeight` is not provided or @@ -84,6 +91,12 @@ export interface DataGridProps { onSelectionChange?: (selectedRows: TData[]) => void; /** Optional function to compute a row's inline style (e.g. background color from threshold). */ getRowStyle?: (row: TData) => React.CSSProperties | undefined; + /** Optional function to compute a cell's inline style for conditional formatting. */ + getCellStyle?: (row: TData, columnId: string) => React.CSSProperties | undefined; + /** Enable row grouping. When true, columns with `enableGrouping` can be used for grouping. */ + enableGrouping?: boolean; + /** Column IDs to group by initially. Requires `enableGrouping`. */ + initialGrouping?: string[]; toolbar?: (table: Table) => React.ReactNode; pagination?: (table: Table) => React.ReactNode; className?: string; @@ -96,6 +109,7 @@ function DataGrid({ enableSelection = false, enableGlobalFilter = false, enableColumnFilters = false, + enableColumnResizing = false, enablePagination = true, pageSize = 10, containerHeight, @@ -103,6 +117,9 @@ function DataGrid({ clickableColumns, onSelectionChange, getRowStyle, + getCellStyle, + enableGrouping = false, + initialGrouping, toolbar, pagination, className, @@ -112,6 +129,13 @@ function DataGrid({ const [rowSelection, setRowSelection] = React.useState({}); const [columnFilters, setColumnFilters] = React.useState([]); const [globalFilter, setGlobalFilter] = React.useState(""); + const [grouping, setGrouping] = React.useState(initialGrouping ?? []); + const [expanded, setExpanded] = React.useState(true); + + // Sync grouping state when initialGrouping prop changes + React.useEffect(() => { + if (initialGrouping) setGrouping(initialGrouping); + }, [initialGrouping]); // Toolbar height is non-zero only when a toolbar render prop is supplied. // We use a fixed estimate so the toolbar's own height does not have to be @@ -162,22 +186,31 @@ function DataGrid({ columns: allColumns, getCoreRowModel: getCoreRowModel(), enableSorting, + enableColumnResizing, + columnResizeMode: enableColumnResizing ? "onChange" as const : undefined, + defaultColumn: enableColumnResizing ? { minSize: 50 } : undefined, + enableGrouping, getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, getPaginationRowModel: getPaginationRowModel(), getFilteredRowModel: (enableGlobalFilter || enableColumnFilters) ? getFilteredRowModel() : undefined, getFacetedRowModel: enableColumnFilters ? getFacetedRowModel() : undefined, getFacetedUniqueValues: enableColumnFilters ? getFacetedUniqueValues() : undefined, + getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined, + getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, + onGroupingChange: enableGrouping ? setGrouping : undefined, + onExpandedChange: enableGrouping ? setExpanded : undefined, state: { sorting, columnVisibility, rowSelection, columnFilters, globalFilter, + ...(enableGrouping ? { grouping, expanded } : {}), }, initialState: { pagination: { @@ -207,6 +240,30 @@ function DataGrid({ return (
+ {enableGrouping && grouping.length > 0 && ( +
+ + +
+ )} {toolbar?.(table)}
@@ -214,13 +271,31 @@ function DataGrid({ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - + {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} + {enableColumnResizing && header.column.getCanResize() && ( +
header.column.resetSize()} + className={cn( + "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none", + header.column.getIsResizing() + ? "bg-primary opacity-100" + : "bg-border opacity-0 group-hover/header:opacity-50 hover:opacity-100" + )} + /> + )} ))} @@ -228,20 +303,66 @@ function DataGrid({ {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( + table.getRowModel().rows.map((row) => { + const isGrouped = row.getIsGrouped(); + return ( {row.getVisibleCells().map((cell) => { const isDataCell = cell.column.id !== "select"; const isInClickableColumns = !clickableColumns?.length || clickableColumns.includes(cell.column.id); - const cellClickable = onCellClick && isDataCell && isInClickableColumns; + const cellClickable = !isGrouped && onCellClick && isDataCell && isInClickableColumns; + // Grouped cell: show expand toggle + group value + count + if (cell.getIsGrouped()) { + return ( + + + + ); + } + + // Aggregated cell: show aggregated value + if (cell.getIsAggregated()) { + return ( + + {flexRender( + cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + } + + // Placeholder cell in grouped rows + if (cell.getIsPlaceholder()) { + return ; + } + + // Normal data cell return ( { e.stopPropagation(); onCellClick({ column: cell.column.id, value: cell.getValue() }); @@ -258,7 +379,8 @@ function DataGrid({ ); })} - )) + ); + }) ) : ( diff --git a/component/src/components/composed/index.ts b/component/src/components/composed/index.ts index 72d08fd2..468f3cd7 100644 --- a/component/src/components/composed/index.ts +++ b/component/src/components/composed/index.ts @@ -45,6 +45,7 @@ export { FieldPicker, type FieldPickerProps, type FieldOption } from "./field-pi export { ChartSettingsPanel, type ChartSettingsPanelProps } from "./chart-settings-panel"; export { ChartOptionsPanel, type ChartOptionsPanelProps } from "./chart-options-panel"; export { getChartOptions, getDefaultChartSettings, type ChartOptionDef } from "./chart-options-schema"; +export { ColorScalePanel, ConditionalFormatPanel, type ColorScalePanelProps, type ConditionalFormatPanelProps } from "./conditional-format-panel"; // Connection export { ConnectionStatus, type ConnectionStatusProps, type ConnectionState } from "./connection-status"; diff --git a/component/src/components/composed/json-viewer.tsx b/component/src/components/composed/json-viewer.tsx index 29859442..8967df29 100644 --- a/component/src/components/composed/json-viewer.tsx +++ b/component/src/components/composed/json-viewer.tsx @@ -75,10 +75,13 @@ function JsonNode({ keyName, value, depth, initialExpanded, isLast }: JsonNodePr return (
-
setExpanded(!expanded)} + aria-expanded={expanded} + aria-label={`${expanded ? "Collapse" : "Expand"} ${keyName ?? (type === "array" ? "array" : "object")}`} > ,} )} -
+ {expanded && !isEmpty && ( <> {entries.map(([key, val], index) => ( diff --git a/component/src/components/composed/markdown-widget.tsx b/component/src/components/composed/markdown-widget.tsx index 1c76b487..0157534f 100644 --- a/component/src/components/composed/markdown-widget.tsx +++ b/component/src/components/composed/markdown-widget.tsx @@ -28,6 +28,26 @@ function isSafeUrl(url: string): boolean { return true; } +/** + * Checks whether a line is a GFM table alignment row (e.g. `| --- | :---: |`). + * Uses a linear split-and-check approach instead of a single regex to avoid + * ReDoS (catastrophic backtracking) on adversarial input. + */ +function isTableAlignmentRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + // Split by pipe, trim each cell, filter out empty leading/trailing cells + const cells = trimmed.split("|").map((c) => c.trim()); + // Remove empty strings caused by leading/trailing pipes + const filtered = cells.filter((c, i) => + c.length > 0 || (i > 0 && i < cells.length - 1), + ); + if (filtered.length === 0) return false; + // Each non-empty cell must match :?-{3,}:? + const cellPattern = /^:?-{3,}:?$/; + return filtered.every((c) => c.length === 0 || cellPattern.test(c)); +} + /** * Simple markdown parser that converts a subset of markdown to HTML. * Handles: headings, bold, italic, code, links, lists, blockquotes, paragraphs. @@ -112,6 +132,39 @@ function parseMarkdown(md: string): string { inBlockquote = false; } + // GFM tables: pipe-delimited rows where the next line is the alignment row + if ( + line.includes("|") && + i + 1 < lines.length && + isTableAlignmentRow(lines[i + 1]) + ) { + closeList(); + const parseCells = (row: string) => + row.split("|").map((c: string) => c.trim()).filter((c: string) => c.length > 0); + const headers = parseCells(line); + i++; // skip alignment row + const bodyRows = []; + while (i + 1 < lines.length && lines[i + 1].includes("|")) { + i++; + bodyRows.push(parseCells(lines[i])); + } + result.push(''); + result.push(""); + for (const h of headers) { + result.push(``); + } + result.push(""); + for (const row of bodyRows) { + result.push(""); + for (let c = 0; c < headers.length; c++) { + result.push(``); + } + result.push(""); + } + result.push("
${escapeHtml(h)}
${escapeHtml(row[c] ?? "")}
"); + continue; + } + // Unordered lists if (line.match(/^[-*+]\s+/)) { if (listType !== "ul") { diff --git a/component/src/components/composed/query-editor.tsx b/component/src/components/composed/query-editor.tsx index bd79de56..b9769246 100644 --- a/component/src/components/composed/query-editor.tsx +++ b/component/src/components/composed/query-editor.tsx @@ -59,7 +59,7 @@ async function buildExtensions( const [ { EditorView, keymap, placeholder: cmPlaceholder }, { defaultKeymap, historyKeymap, history: historyExt }, - { autocompletion, completionKeymap }, + { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap }, { oneDark }, ] = await Promise.all([ import("@codemirror/view"), @@ -96,13 +96,15 @@ async function buildExtensions( overflow: "auto", fontFamily: "var(--font-mono, monospace)", fontSize: "0.875rem", + paddingBottom: "0.5rem", }, ".cm-content": { padding: "1rem" }, }); return [ historyExt(), - keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap]), + closeBrackets(), + keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap, ...closeBracketsKeymap]), runKeymap, langCompartmentExt, autocompletion(), diff --git a/component/src/lib/__tests__/cell-formatting.test.ts b/component/src/lib/__tests__/cell-formatting.test.ts new file mode 100644 index 00000000..c784720e --- /dev/null +++ b/component/src/lib/__tests__/cell-formatting.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { evaluateCellRule, resolveCellStyle } from "../cell-formatting"; +import type { CellFormattingRule } from "../cell-formatting"; + +describe("evaluateCellRule", () => { + it("evaluates > condition", () => { + expect(evaluateCellRule({ operator: ">", value: 50 }, 60)).toBe(true); + expect(evaluateCellRule({ operator: ">", value: 50 }, 40)).toBe(false); + }); + + it("evaluates < condition", () => { + expect(evaluateCellRule({ operator: "<", value: 50 }, 40)).toBe(true); + expect(evaluateCellRule({ operator: "<", value: 50 }, 60)).toBe(false); + }); + + it("evaluates == condition for numbers", () => { + expect(evaluateCellRule({ operator: "==", value: 42 }, 42)).toBe(true); + expect(evaluateCellRule({ operator: "==", value: 42 }, 43)).toBe(false); + }); + + it("evaluates == condition for strings", () => { + expect(evaluateCellRule({ operator: "==", value: "active" }, "active")).toBe(true); + expect(evaluateCellRule({ operator: "==", value: "active" }, "inactive")).toBe(false); + }); + + it("evaluates contains condition", () => { + expect(evaluateCellRule({ operator: "contains", value: "err" }, "Error occurred")).toBe(true); + expect(evaluateCellRule({ operator: "contains", value: "err" }, "Success")).toBe(false); + }); + + it("contains is case-insensitive", () => { + expect(evaluateCellRule({ operator: "contains", value: "ERR" }, "error")).toBe(true); + }); + + it("returns false for null/undefined values", () => { + expect(evaluateCellRule({ operator: ">", value: 50 }, null)).toBe(false); + expect(evaluateCellRule({ operator: "==", value: "x" }, undefined)).toBe(false); + }); +}); + +describe("resolveCellStyle", () => { + const rules: CellFormattingRule[] = [ + { operator: ">", value: 80, style: { backgroundColor: "#c6efce", fontWeight: "bold" } }, + { operator: "<", value: 20, style: { backgroundColor: "#ffc7ce", color: "#9c0006" } }, + ]; + + it("returns matching rule style", () => { + const style = resolveCellStyle(rules, 90); + expect(style).toEqual({ backgroundColor: "#c6efce", fontWeight: "bold" }); + }); + + it("returns first matching rule when multiple match", () => { + const style = resolveCellStyle(rules, 10); + expect(style).toEqual({ backgroundColor: "#ffc7ce", color: "#9c0006" }); + }); + + it("returns undefined when no rule matches", () => { + expect(resolveCellStyle(rules, 50)).toBeUndefined(); + }); + + it("returns undefined for empty rules", () => { + expect(resolveCellStyle([], 50)).toBeUndefined(); + }); +}); diff --git a/component/src/lib/cell-formatting.ts b/component/src/lib/cell-formatting.ts new file mode 100644 index 00000000..db6bd13e --- /dev/null +++ b/component/src/lib/cell-formatting.ts @@ -0,0 +1,48 @@ +import type { CSSProperties } from "react"; + +export type CellOperator = ">" | "<" | "==" | "contains"; + +export interface CellFormattingRule { + operator: CellOperator; + value: string | number; + style: CSSProperties; +} + +/** + * Evaluate a single cell formatting rule against a cell value. + */ +export function evaluateCellRule( + rule: Pick, + cellValue: unknown, +): boolean { + if (cellValue === null || cellValue === undefined) return false; + + switch (rule.operator) { + case ">": + return typeof cellValue === "number" && cellValue > Number(rule.value); + case "<": + return typeof cellValue === "number" && cellValue < Number(rule.value); + case "==": + return String(cellValue) === String(rule.value); + case "contains": + return String(cellValue).toLowerCase().includes(String(rule.value).toLowerCase()); + default: + return false; + } +} + +/** + * Resolve the CSS style for a cell value by evaluating rules in order. + * Returns the style of the first matching rule, or undefined if none match. + */ +export function resolveCellStyle( + rules: CellFormattingRule[], + cellValue: unknown, +): CSSProperties | undefined { + for (const rule of rules) { + if (evaluateCellRule(rule, cellValue)) { + return rule.style; + } + } + return undefined; +} diff --git a/component/vitest.setup.ts b/component/vitest.setup.ts index f8443727..48e07097 100644 --- a/component/vitest.setup.ts +++ b/component/vitest.setup.ts @@ -41,6 +41,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); vi.mock("echarts/renderers", () => ({ diff --git a/docker/neo4j/init.cypher b/docker/neo4j/init.cypher index fc4c20ba..864e5588 100755 --- a/docker/neo4j/init.cypher +++ b/docker/neo4j/init.cypher @@ -519,3 +519,31 @@ CREATE (:City {name: "Seattle", latitude: 47.6062, longitude: -122.3321, populat CREATE (:City {name: "Denver", latitude: 39.7392, longitude: -104.9903, population: 715522}); CREATE (:City {name: "Boston", latitude: 42.3601, longitude: -71.0589, population: 692600}); CREATE (:City {name: "Atlanta", latitude: 33.7490, longitude: -84.3880, population: 498715}); + +// ── Filming locations — connect movies to cities ────────────────────────── +MATCH (m:Movie {title: 'The Matrix'}), (c:City {name: 'San Francisco'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'The Matrix'}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'Top Gun'}), (c:City {name: 'San Francisco'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'Top Gun'}), (c:City {name: 'Miami'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'A Few Good Men'}), (c:City {name: 'Boston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: 'A Few Good Men'}), (c:City {name: 'Miami'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Jerry Maguire"}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Jerry Maguire"}), (c:City {name: 'Houston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Sleepless in Seattle"}), (c:City {name: 'Seattle'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Sleepless in Seattle"}), (c:City {name: 'New York'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "When Harry Met Sally"}), (c:City {name: 'New York'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "When Harry Met Sally"}), (c:City {name: 'Chicago'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Apollo 13"}), (c:City {name: 'Houston'}) CREATE (m)-[:FILMED_IN]->(c); +MATCH (m:Movie {title: "Apollo 13"}), (c:City {name: 'Los Angeles'}) CREATE (m)-[:FILMED_IN]->(c); + +// ── Birthplaces — connect people to cities ──────────────────────────────── +MATCH (p:Person {name: 'Keanu Reeves'}), (c:City {name: 'Los Angeles'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Tom Hanks'}), (c:City {name: 'San Francisco'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Tom Cruise'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Jack Nicholson'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Meg Ryan'}), (c:City {name: 'Los Angeles'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Kevin Bacon'}), (c:City {name: 'Boston'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Demi Moore'}), (c:City {name: 'Atlanta'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Cuba Gooding Jr.'}), (c:City {name: 'New York'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Renee Zellweger'}), (c:City {name: 'Houston'}) CREATE (p)-[:BORN_IN]->(c); +MATCH (p:Person {name: 'Bonnie Hunt'}), (c:City {name: 'Chicago'}) CREATE (p)-[:BORN_IN]->(c); diff --git a/docker/postgres/seed-neoboard.sql b/docker/postgres/seed-neoboard.sql index b1c77ba4..61b1a6e4 100644 --- a/docker/postgres/seed-neoboard.sql +++ b/docker/postgres/seed-neoboard.sql @@ -70,17 +70,30 @@ INSERT INTO "dashboard" ("id", "userId", "tenant_id", "name", "description", "is {"i":"w10","x":6,"y":12,"w":6,"h":4} ]}, {"id":"page-styling","title":"Rule-Based Styling","widgets":[ - {"id":"w11","chartType":"bar","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade","settings":{"title":"Movies by Decade (styled)","stylingConfig":{"enabled":true,"rules":[{"id":"r1","operator":"<=","value":2,"color":"#ef4444","target":"color"},{"id":"r2","operator":"<=","value":5,"color":"#f59e0b","target":"color"},{"id":"r3","operator":"<=","value":10,"color":"#22c55e","target":"color"}]}}}, - {"id":"w12","chartType":"single-value","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN count(m) AS value","settings":{"title":"Total Movies (blue > 30)","stylingConfig":{"enabled":true,"rules":[{"id":"r4","operator":">","value":30,"color":"#3b82f6","target":"color"}]}}} + {"id":"w11","chartType":"bar","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade","settings":{"title":"Movies by Decade (styled bars)","stylingConfig":{"enabled":true,"rules":[{"id":"r1","operator":"<=","value":3,"color":"#ef4444","target":"color"},{"id":"r2","operator":"<=","value":8,"color":"#f59e0b","target":"color"},{"id":"r3","operator":">=","value":1,"color":"#22c55e","target":"color"}]}}}, + {"id":"w12","chartType":"single-value","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN count(m) AS value","settings":{"title":"Total Movies (blue > 30)","stylingConfig":{"enabled":true,"rules":[{"id":"r4","operator":">","value":30,"color":"#3b82f6","target":"color"}]}}}, + {"id":"w18","chartType":"table","connectionId":"conn-neo4j-001","query":"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS actor, count(m) AS movies RETURN actor, movies ORDER BY movies DESC LIMIT 15","settings":{"title":"Actors — styled rows (bg + bold)","chartOptions":{"enableSorting":true,"enablePagination":true,"pageSize":10},"stylingConfig":{"enabled":true,"rules":[{"id":"rs1","column":"movies","operator":">=","value":4,"color":"#22c55e","target":"backgroundColor","bold":true},{"id":"rs2","column":"movies","operator":"<=","value":1,"color":"#ef4444","target":"backgroundColor"},{"id":"rs3","column":"movies","operator":"between","value":2,"valueTo":3,"color":"#fbbf24","target":"backgroundColor"}]}}}, + {"id":"w19","chartType":"table","connectionId":"conn-neo4j-001","query":"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS actor, count(m) AS movies RETURN actor, movies ORDER BY movies DESC LIMIT 15","settings":{"title":"Actors — color scale gradient","chartOptions":{"enableSorting":true,"enablePagination":true,"pageSize":10},"conditionalFormatting":{"colorScales":[{"column":"movies","minColor":"#fde68a","maxColor":"#16a34a"}]}}} ],"gridLayout":[ {"i":"w11","x":0,"y":0,"w":6,"h":4}, - {"i":"w12","x":6,"y":0,"w":3,"h":2} + {"i":"w12","x":6,"y":0,"w":3,"h":2}, + {"i":"w18","x":0,"y":4,"w":6,"h":5}, + {"i":"w19","x":6,"y":4,"w":6,"h":5} + ]}, + {"id":"page-table","title":"Table Features","widgets":[ + {"id":"w20","chartType":"table","connectionId":"conn-neo4j-001","query":"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) RETURN p.name AS actor, m.title AS movie, m.released AS year ORDER BY actor, year DESC LIMIT 50","settings":{"title":"Grouped by Actor (count)","chartOptions":{"enableSorting":true,"enableColumnResizing":true,"enablePagination":true,"pageSize":15,"enableGrouping":true,"groupBy":"actor","aggregationFn":"count"}}}, + {"id":"w21","chartType":"table","connectionId":"conn-neo4j-001","query":"MATCH (p:Person)-[r]->(m:Movie) RETURN type(r) AS role, p.name AS person, m.title AS movie, m.released AS year ORDER BY role, person LIMIT 60","settings":{"title":"Grouped by Role + Person (count)","chartOptions":{"enableSorting":true,"enableColumnResizing":true,"enablePagination":true,"pageSize":20,"enableGrouping":true,"groupBy":"role","aggregationFn":"count"}}}, + {"id":"w22","chartType":"table","connectionId":"conn-pg-001","query":"SELECT m.title, m.released, p.name AS director FROM movies m JOIN roles r ON r.movie_id = m.id AND r.relationship = ''DIRECTED'' JOIN people p ON p.id = r.person_id ORDER BY m.released DESC","settings":{"title":"PostgreSQL — resizable columns","chartOptions":{"enableSorting":true,"enableColumnResizing":true,"enableGlobalFilter":true,"enableColumnFilters":true,"enablePagination":true,"pageSize":10}}} + ],"gridLayout":[ + {"i":"w20","x":0,"y":0,"w":6,"h":6}, + {"i":"w21","x":6,"y":0,"w":6,"h":6}, + {"i":"w22","x":0,"y":6,"w":12,"h":5} ]}, {"id":"page-palettes","title":"Color Palettes","widgets":[ - {"id":"w13","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"deep-ocean","colorPalette":"deep-ocean"}}, - {"id":"w14","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"warm-sunset","colorPalette":"warm-sunset"}}, - {"id":"w15","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"neon","colorPalette":"neon"}}, - {"id":"w16","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"monochrome","colorPalette":"monochrome"}} + {"id":"w13","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"deep-ocean","chartOptions":{"colorPalette":"deep-ocean"}}}, + {"id":"w14","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"warm-sunset","chartOptions":{"colorPalette":"warm-sunset"}}}, + {"id":"w15","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"neon","chartOptions":{"colorPalette":"neon"}}}, + {"id":"w16","chartType":"pie","connectionId":"conn-neo4j-001","query":"MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value","settings":{"title":"monochrome","chartOptions":{"colorPalette":"monochrome"}}} ],"gridLayout":[ {"i":"w13","x":0,"y":0,"w":6,"h":4}, {"i":"w14","x":6,"y":0,"w":6,"h":4}, @@ -88,7 +101,7 @@ INSERT INTO "dashboard" ("id", "userId", "tenant_id", "name", "description", "is {"i":"w16","x":6,"y":4,"w":6,"h":4} ]}, {"id":"page-a11y","title":"Accessibility","widgets":[ - {"id":"w17","chartType":"bar","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade","settings":{"title":"Colorblind Mode","colorblindMode":true}} + {"id":"w17","chartType":"bar","connectionId":"conn-neo4j-001","query":"MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade","settings":{"title":"Colorblind Mode","chartOptions":{"colorblindMode":true}}} ],"gridLayout":[ {"i":"w17","x":0,"y":0,"w":8,"h":5} ]} diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index f9148a33..57dadfec 100644 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -68,7 +68,7 @@ function uuid() { // ─── Dashboard layouts ─────────────────────────────────────────────── -function buildWidgetShowcase(neo4jConnId, pgConnId) { +export function buildWidgetShowcase(neo4jConnId, pgConnId) { // Click action page needs stable IDs for page navigation const clickPageId = uuid(); @@ -226,11 +226,10 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { title: "Movies (row color by year)", stylingConfig: { enabled: true, - targetColumn: "released", rules: [ - { id: uuid(), operator: "<=", value: 1995, color: "#3b82f620", target: "backgroundColor" }, - { id: uuid(), operator: "<=", value: 2005, color: "#22c55e20", target: "backgroundColor" }, - { id: uuid(), operator: "<=", value: 2015, color: "#f59e0b20", target: "backgroundColor" }, + { id: uuid(), column: "released", operator: "<=", value: 1995, color: "#3b82f620", target: "backgroundColor" }, + { id: uuid(), column: "released", operator: "<=", value: 2005, color: "#22c55e20", target: "backgroundColor" }, + { id: uuid(), column: "released", operator: "<=", value: 2015, color: "#f59e0b20", target: "backgroundColor" }, ], }, }, @@ -244,9 +243,10 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { settings: { title: "Cast Size (red > 5, green \u2264 3)", stylingConfig: { + enabled: true, rules: [ - { field: "value", operator: ">", value: 5, target: "color", style: "#ef4444" }, - { field: "value", operator: "<=", value: 3, target: "color", style: "#22c55e" }, + { id: uuid(), operator: ">", value: 5, color: "#ef4444", target: "color" }, + { id: uuid(), operator: "<=", value: 3, color: "#22c55e", target: "color" }, ], }, }, @@ -260,8 +260,9 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { settings: { title: "Movie Count (blue > 30)", stylingConfig: { + enabled: true, rules: [ - { field: "value", operator: ">", value: 30, target: "color", style: "#3b82f6" }, + { id: uuid(), operator: ">", value: 30, color: "#3b82f6", target: "color" }, ], }, }, @@ -275,8 +276,9 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { settings: { title: "Hierarchy (orange > 10)", stylingConfig: { + enabled: true, rules: [ - { field: "value", operator: ">", value: 10, target: "color", style: "#f97316" }, + { id: uuid(), operator: ">", value: 10, color: "#f97316", target: "color" }, ], }, }, @@ -408,7 +410,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "deep-ocean (default)", colorPalette: "deep-ocean" }, + settings: { title: "deep-ocean (default)", chartOptions: { colorPalette: "deep-ocean" } }, }, { id: uuid(), @@ -416,7 +418,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "warm-sunset", colorPalette: "warm-sunset" }, + settings: { title: "warm-sunset", chartOptions: { colorPalette: "warm-sunset" } }, }, { id: uuid(), @@ -424,7 +426,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "cool-breeze", colorPalette: "cool-breeze" }, + settings: { title: "cool-breeze", chartOptions: { colorPalette: "cool-breeze" } }, }, { id: uuid(), @@ -432,7 +434,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "earth-tones", colorPalette: "earth-tones" }, + settings: { title: "earth-tones", chartOptions: { colorPalette: "earth-tones" } }, }, { id: uuid(), @@ -440,7 +442,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "neon", colorPalette: "neon" }, + settings: { title: "neon", chartOptions: { colorPalette: "neon" } }, }, { id: uuid(), @@ -448,7 +450,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { connectionId: neo4jConnId, query: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", - settings: { title: "monochrome", colorPalette: "monochrome" }, + settings: { title: "monochrome", chartOptions: { colorPalette: "monochrome" } }, }, ], gridLayout: [ @@ -475,7 +477,7 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", settings: { title: "Movies by Decade (Colorblind Mode)", - colorblindMode: true, + chartOptions: { colorblindMode: true }, }, }, ], @@ -483,11 +485,12 @@ function buildWidgetShowcase(neo4jConnId, pgConnId) { { i: null, x: 0, y: 0, w: 8, h: 5 }, ], }, + ], }; } -function buildParameterTesting(neo4jConnId, pgConnId) { +export function buildParameterTesting(neo4jConnId, pgConnId) { return { version: 2, pages: [ @@ -944,7 +947,7 @@ function buildParameterTesting(neo4jConnId, pgConnId) { }; } -function buildFormTesting(neo4jConnId, pgConnId) { +export function buildFormTesting(neo4jConnId, pgConnId) { return { version: 2, pages: [ @@ -1155,7 +1158,7 @@ function buildFormTesting(neo4jConnId, pgConnId) { }; } -function buildClickActionDemo(neo4jConnId, pgConnId) { +export function buildClickActionDemo(neo4jConnId, pgConnId) { // Page IDs are pre-generated so widgets can reference them in click actions const page1Id = uuid(); const page2Id = uuid(); @@ -1740,13 +1743,46 @@ async function main() { true ); + const catalogLayout = buildChartCatalog(neo4jConnId); + patchGridIds(catalogLayout); + await upsertDashboard( + sql, + adminId, + "Chart Catalog", + "One page per chart type. Each page shows every palette, feature variant, rule-based styling, click actions, and accessibility modes.", + catalogLayout, + true + ); + + const improvementsLayout = buildChartImprovements(neo4jConnId); + patchGridIds(improvementsLayout); + await upsertDashboard( + sql, + adminId, + "Chart Improvements", + "Number formatting, DataZoom, reference lines, axis rotation, donut/top-N pie, click enrichment, radar global scale, graph anti-clump, markdown tables.", + improvementsLayout, + true + ); + + const tableLayout = buildTableFeatures(neo4jConnId, pgConnId); + patchGridIds(tableLayout); + await upsertDashboard( + sql, + adminId, + "Table Features", + "Column resizing, row grouping with nested headers, conditional formatting (numeric, string, null), color scales, icons, and all features combined.", + tableLayout, + true + ); + console.log(" Demo dashboards seeded."); } finally { await sql.end(); } } -function buildStylingRulesDemo(neo4jConnId, pgConnId) { +export function buildStylingRulesDemo(neo4jConnId, pgConnId) { // Reusable styling configs for different chart types const countStyling = { enabled: true, @@ -1956,7 +1992,1046 @@ function buildStylingRulesDemo(neo4jConnId, pgConnId) { }; } +// ─── Chart Improvements — dedicated dashboard for new features ────── +export function buildChartImprovements(neo4jConnId) { + return { + version: 2, + pages: [ + // ── Page 1: Number Formatting ── + { + id: uuid(), + title: "Number Formatting", + widgets: [ + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Plain (default)", chartOptions: { fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Comma + prefix/suffix", chartOptions: { numberFormat: "comma", prefix: "$", suffix: " USD", decimalPlaces: 2, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN count(m) * 12345 AS value", + settings: { title: "Compact notation", chartOptions: { numberFormat: "compact", decimalPlaces: 1, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "single-value", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN 87.654 AS value", + settings: { title: "Percent format", chartOptions: { numberFormat: "percent", decimalPlaces: 1, fontSize: "lg" } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "Bar — tooltip decimal places = 2", chartOptions: { decimalPlaces: 2, xAxisLabel: "Decade", yAxisLabel: "Count" } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — tooltip decimal places = 1", chartOptions: { decimalPlaces: 1, showPoints: true } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 2 }, + { i: null, x: 3, y: 0, w: 3, h: 2 }, + { i: null, x: 6, y: 0, w: 3, h: 2 }, + { i: null, x: 9, y: 0, w: 3, h: 2 }, + { i: null, x: 0, y: 2, w: 6, h: 4 }, + { i: null, x: 6, y: 2, w: 6, h: 4 }, + ], + }, + + // ── Page 2: DataZoom + Reference Lines ── + { + id: uuid(), + title: "DataZoom + Reference Lines", + widgets: [ + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name, movies LIMIT 20", + settings: { title: "Bar — scroll to zoom + 2 reference lines", chartOptions: { + enableDataZoom: true, xAxisLabel: "Actor", yAxisLabel: "Movies", + referenceLines: JSON.stringify([{ value: 3, label: "Average", color: "#f59e0b" }, { value: 5, label: "Prolific", color: "#22c55e" }]), + } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — scroll to zoom + target line", chartOptions: { + enableDataZoom: true, showPoints: true, xAxisLabel: "Year", yAxisLabel: "Releases", + referenceLines: JSON.stringify([{ value: 5, label: "Target", color: "#ef4444" }]), + } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "Bar — no DataZoom (control)", chartOptions: { xAxisLabel: "Decade", yAxisLabel: "Count" } }, + }, + { + id: uuid(), chartType: "line", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.released AS year, count(*) AS count ORDER BY year", + settings: { title: "Line — reference line only (no zoom)", chartOptions: { + smooth: true, area: true, + referenceLines: JSON.stringify([{ value: 3, label: "Threshold", color: "#8b5cf6" }]), + } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 3: Axis Labels + Rotation ── + { + id: uuid(), + title: "Axis Labels", + widgets: [ + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS label, m.released AS value ORDER BY m.released LIMIT 15", + settings: { title: "Auto-rotate (15 items)" }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name AS label, movies AS value LIMIT 20", + settings: { title: "Forced 45\u00b0 rotation", chartOptions: { axisLabelRotation: 45, xAxisLabel: "Actor Name", yAxisLabel: "Movie Count" } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS movies ORDER BY movies DESC RETURN name AS label, movies AS value LIMIT 10", + settings: { title: "Forced 90\u00b0 rotation", chartOptions: { axisLabelRotation: 90 } }, + }, + { + id: uuid(), chartType: "bar", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS decade, count(*) AS count ORDER BY decade", + settings: { title: "No rotation needed (few items)" }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 4: Pie Donut + Top-N ── + { + id: uuid(), + title: "Pie Donut + Top-N", + widgets: [ + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Standard pie (all 15 slices)" }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Donut mode", chartOptions: { donut: true } }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Donut + Top 5 + center text", chartOptions: { donut: true, topN: 5, donutCenterText: "Top Actors", showPercentage: true } }, + }, + { + id: uuid(), chartType: "pie", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS name, count(m) AS value ORDER BY value DESC RETURN name, value LIMIT 15", + settings: { title: "Top 3 only (rest grouped as Other)", chartOptions: { topN: 3, showPercentage: true, sortSlices: true } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 5: Click Action Row Enrichment ── + { + id: uuid(), + title: "Click Action Enrichment", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS title, m.released AS released, m.tagline AS tagline ORDER BY m.released DESC LIMIT 10", + settings: { + title: "Click a row \u2192 tagline fills below", + clickAction: { + type: "set-parameter", + rules: [{ + id: uuid(), type: "set-parameter", triggerColumn: "title", + parameterMapping: { parameterName: "clicked_tagline", sourceField: "tagline" }, + }], + }, + }, + }, + { + id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Clicked Tagline", chartOptions: { parameterType: "text", parameterName: "clicked_tagline" } }, + }, + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (m:Movie) RETURN m.title AS title, m.released AS released, m.tagline AS tagline ORDER BY m.released DESC LIMIT 10", + settings: { title: "Reference table (verify tagline matches)" }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 8, h: 5 }, + { i: null, x: 8, y: 0, w: 4, h: 2 }, + { i: null, x: 0, y: 5, w: 12, h: 4 }, + ], + }, + + // ── Page 6: Radar Global Scale + Graph Anti-Clump + Markdown Table ── + { + id: uuid(), + title: "Radar, Graph, Markdown", + widgets: [ + { + id: uuid(), chartType: "radar", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS indicator, count(*) AS value RETURN indicator, value", + settings: { title: "Radar \u2014 global scale (magnitudes visible)", chartOptions: { filled: true, shape: "polygon" } }, + }, + { + id: uuid(), chartType: "graph", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) RETURN p, r, m LIMIT 25", + settings: { title: "Graph \u2014 anti-clumping layout", chartOptions: { showLabels: true } }, + }, + { + id: uuid(), chartType: "markdown", connectionId: "", query: "", + settings: { title: "Markdown table rendering", chartOptions: { + content: [ + "## Chart Improvements Checklist", + "", + "| # | Feature | Chart Type | What to Check |", + "|---|---|---|---|", + "| 1 | Number Format | Single Value | comma, compact, percent, decimal places |", + "| 2 | Tooltip Decimals | Bar, Line | Hover tooltip shows fixed decimals |", + "| 3 | DataZoom | Bar, Line | Scroll-zoom on axes |", + "| 4 | Reference Lines | Bar, Line | Dashed horizontal lines with labels |", + "| 5 | Axis Rotation | Bar | Auto-rotate at 8+ items, manual override |", + "| 6 | Donut Mode | Pie | Hole in center with text |", + "| 7 | Top-N Grouping | Pie | Extra slices grouped as Other |", + "| 8 | Click Enrichment | All | Non-axis columns available in click data |", + "| 9 | Radar Scale | Radar | Single global max, not per-indicator |", + "| 10 | Graph Layout | Graph | Nodes spread out, no clumping |", + "| 11 | Markdown Table | Markdown | This table renders correctly |", + "| 12 | A11y (ARIA) | All ECharts | role=img, aria-label, tabIndex=0 |", + ].join("\n"), + } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 12, h: 5 }, + ], + }, + ], + }; +} + +// ─── Table Features — column resize, conditional formatting, row grouping ── +export function buildTableFeatures(neo4jConnId, pgConnId) { + return { + version: 2, + pages: [ + // ── Page 1: Column Resizing ── + { + id: uuid(), + title: "Column Resizing", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) RETURN p.name AS actor, m.title AS movie, m.released AS year, m.tagline AS tagline ORDER BY year DESC LIMIT 30", + settings: { title: "Drag column borders to resize (Neo4j)", chartOptions: { enableColumnResizing: true, enableSorting: true, enablePagination: true, pageSize: 10 } }, + }, + { + id: uuid(), chartType: "table", connectionId: pgConnId, + query: "SELECT title, released, tagline FROM movies ORDER BY released DESC LIMIT 30", + settings: { title: "Drag column borders to resize (PostgreSQL)", chartOptions: { enableColumnResizing: true, enableSorting: true, enablePagination: true, pageSize: 10 } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 12, h: 5 }, + { i: null, x: 0, y: 5, w: 12, h: 5 }, + ], + }, + + // ── Page 2: Row Grouping ── + { + id: uuid(), + title: "Row Grouping", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) RETURN type(r) AS relationship, m.title AS movie, p.name AS person, m.released AS year ORDER BY relationship, movie", + settings: { title: "Group by relationship type", chartOptions: { enableGrouping: true, groupBy: "relationship", enableSorting: true, enablePagination: true, pageSize: 15 } }, + }, + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) RETURN type(r) AS relationship, (m.released / 10) * 10 AS decade, m.title AS movie, p.name AS person ORDER BY relationship, decade", + settings: { title: "Nested grouping: relationship > decade", chartOptions: { enableGrouping: true, groupBy: "relationship,decade", enableSorting: true, enablePagination: true, pageSize: 20 } }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 12, h: 6 }, + { i: null, x: 0, y: 6, w: 12, h: 6 }, + ], + }, + + // ── Page 3: Rule-Based Styling — Numeric Rules ── + { + id: uuid(), + title: "Rule-Based Styling", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS actor, count(m) AS movies, min(m.released) AS first_role, max(m.released) AS last_role RETURN actor, movies, first_role, last_role ORDER BY movies DESC", + settings: { + title: "Numeric rules: bg color + bold", + chartOptions: { enableSorting: true, enableColumnResizing: true, enablePagination: true, pageSize: 15 }, + stylingConfig: { + enabled: true, + rules: [ + { id: uuid(), column: "movies", operator: ">=", value: 4, color: "#dcfce7", target: "backgroundColor", bold: true }, + { id: uuid(), column: "movies", operator: "<=", value: 1, color: "#fee2e2", target: "backgroundColor" }, + { id: uuid(), column: "movies", operator: "between", value: 2, valueTo: 3, color: "#fef3c7", target: "backgroundColor" }, + ], + }, + }, + }, + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p.name AS actor, count(m) AS movies, min(m.released) AS first_role, max(m.released) AS last_role RETURN actor, movies, first_role, last_role ORDER BY movies DESC", + settings: { + title: "Color scale: movies (red\u2192green), first_role (blue\u2192red)", + chartOptions: { enableSorting: true, enableColumnResizing: true, enablePagination: true, pageSize: 15 }, + conditionalFormatting: { + colorScales: [ + { column: "movies", minColor: "#ef4444", maxColor: "#22c55e" }, + { column: "first_role", minColor: "#3b82f6", maxColor: "#ef4444" }, + ], + }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 12, h: 6 }, + { i: null, x: 0, y: 6, w: 12, h: 6 }, + ], + }, + + // ── Page 4: Rule-Based Styling — String Rules ── + { + id: uuid(), + title: "String & Null Rules", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) RETURN p.name AS person, type(r) AS role, m.title AS movie, m.tagline AS tagline ORDER BY person LIMIT 40", + settings: { + title: "String operators: contains, starts_with, is_null", + chartOptions: { enableSorting: true, enableColumnResizing: true, enablePagination: true, pageSize: 15 }, + stylingConfig: { + enabled: true, + rules: [ + { id: uuid(), column: "role", operator: "==", value: "DIRECTED", color: "#dbeafe", target: "backgroundColor", bold: true }, + { id: uuid(), column: "role", operator: "==", value: "ACTED_IN", color: "#f0fdf4", target: "backgroundColor" }, + { id: uuid(), column: "role", operator: "==", value: "PRODUCED", color: "#fef3c7", target: "backgroundColor" }, + { id: uuid(), column: "person", operator: "starts_with", value: "Tom", color: "#e0e7ff", target: "backgroundColor", bold: true }, + { id: uuid(), column: "tagline", operator: "is_null", value: "", color: "#f3f4f6", target: "backgroundColor" }, + ], + }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 12, h: 8 }, + ], + }, + + // ── Page 5: All Features Combined ── + { + id: uuid(), + title: "All Features Combined", + widgets: [ + { + id: uuid(), chartType: "table", connectionId: neo4jConnId, + query: "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS role, p.name AS person, count(m) AS movies, min(m.released) AS since RETURN role, person, movies, since ORDER BY role, movies DESC", + settings: { + title: "Grouping + resize + styling rules + color scale", + chartOptions: { + enableColumnResizing: true, + enableSorting: true, + enableGrouping: true, + groupBy: "role", + enablePagination: true, + pageSize: 20, + }, + stylingConfig: { + enabled: true, + rules: [ + { id: uuid(), column: "movies", operator: ">=", value: 4, color: "#dcfce7", target: "backgroundColor", bold: true }, + { id: uuid(), column: "movies", operator: "==", value: 1, color: "#9ca3af", target: "textColor" }, + ], + }, + conditionalFormatting: { + colorScales: [ + { column: "since", minColor: "#3b82f6", maxColor: "#f97316" }, + ], + }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 12, h: 10 }, + ], + }, + ], + }; +} + /** Set gridLayout[n].i = widgets[n].id for each page. */ +// ─── Chart Catalog — comprehensive per-chart-type showcase ────────── +export function buildChartCatalog(neo4jId) { + const P = ["deep-ocean", "warm-sunset", "cool-breeze", "earth-tones", "neon", "monochrome"]; + const detailPageId = uuid(); + const behaviorPageId = uuid(); + + // Reusable queries (Neo4j movie dataset) + const Q = { + barData: "MATCH (m:Movie) RETURN (m.released / 10) * 10 AS label, count(*) AS count ORDER BY label", + barMulti: "MATCH (p:Person)-[r]->(m:Movie) WITH (m.released / 10) * 10 AS decade, type(r) AS rel, count(*) AS cnt RETURN decade AS label, rel, cnt ORDER BY decade", + lineData: "MATCH (m:Movie) RETURN m.released AS x, count(*) AS count ORDER BY x", + pieData: "MATCH ()-[r]->() RETURN type(r) AS name, count(*) AS value", + singleVal: "MATCH (m:Movie) RETURN count(m) AS value", + singleTrend: "MATCH (m:Movie) RETURN count(m) AS value, count(m) - 5 AS previous", + tableData: "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) RETURN p.name AS name, m.title AS movie, m.released AS year ORDER BY year DESC LIMIT 30", + gaugeData: "MATCH (m:Movie) RETURN count(m) AS value, 'Movies' AS name", + radarData: "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS indicator, count(*) AS value RETURN indicator, value", + sankeyData: "MATCH (p:Person)-[r]->(m:Movie) WHERE type(r) IN ['ACTED_IN','DIRECTED'] WITH p.name AS source, m.title AS target, 1 AS value RETURN source, target, value LIMIT 20", + sunburstData: "MATCH ()-[r]->() WITH type(r) AS relType, count(*) AS cnt RETURN '' AS parent, relType AS name, cnt AS value UNION ALL MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS relType, m.title AS movie, count(p) AS cnt RETURN relType AS parent, movie AS name, cnt AS value LIMIT 30", + treemapData: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH m, count(p) AS cast RETURN m.title AS name, cast AS value ORDER BY cast DESC LIMIT 15", + graphData: "MATCH (p:Person)-[r]->(m:Movie) RETURN p, r, m LIMIT 15", + graphSmall: "MATCH (p:Person)-[r:DIRECTED]->(m:Movie) RETURN p, r, m LIMIT 10", + selectSeed: "MATCH (p:Person) RETURN DISTINCT p.name AS value, p.name AS label ORDER BY p.name LIMIT 20", + mapCities: "MATCH (c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, c.population AS value", + mapFilming: "MATCH (m:Movie)-[:FILMED_IN]->(c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, count(m) AS value", + mapBirthplaces: "MATCH (p:Person)-[:BORN_IN]->(c:City) RETURN c.name AS name, c.latitude AS lat, c.longitude AS lng, count(p) AS value, collect(p.name)[0..3] AS people", + }; + + // Styling rules reusable across pages + const barStyling = { + enabled: true, + rules: [ + { id: uuid(), operator: "<=", value: 5, color: "#ef4444", target: "color" }, + { id: uuid(), operator: ">=", value: 15, color: "#22c55e", target: "color" }, + ], + }; + const singleValueStyling = { + enabled: true, + rules: [ + { id: uuid(), operator: "<", value: 20, color: "#ef4444", target: "color" }, + { id: uuid(), operator: ">=", value: 20, color: "#22c55e", target: "color" }, + { id: uuid(), operator: ">=", value: 20, color: "#dcfce7", target: "backgroundColor" }, + ], + }; + + // Click action: set parameter on click + const clickSetParam = (triggerCol, paramName) => ({ + type: "set-parameter", + rules: [{ + id: uuid(), type: "set-parameter", + triggerColumn: triggerCol, + parameterMapping: { parameterName: paramName, sourceField: triggerCol }, + }], + }); + + // Click action: navigate to page + const clickNavPage = (triggerCol, pageId) => ({ + type: "navigate-to-page", + rules: [{ + id: uuid(), type: "navigate-to-page", + triggerColumn: triggerCol, + targetPageId: pageId, + }], + }); + + // Helper to make a palette row of widgets for a given chart type + function paletteRow(chartType, query, baseSettings = {}) { + return P.map((p) => ({ + id: uuid(), + chartType, + connectionId: neo4jId, + query, + settings: { ...baseSettings, title: p, chartOptions: { ...baseSettings.chartOptions, colorPalette: p } }, + })); + } + + function paletteGrid(yStart = 0) { + // 3×2 grid for 6 palettes, each 4×4 + return P.map((_, i) => ({ + i: null, + x: (i % 3) * 4, + y: yStart + Math.floor(i / 3) * 4, + w: 4, + h: 4, + })); + } + + return { + version: 2, + pages: [ + // ── Page 1: Bar Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Bar Chart", + widgets: [ + // Vertical bar (default) + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Vertical (default)" } }, + // Horizontal bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Horizontal", chartOptions: { orientation: "horizontal" } } }, + // Grouped (multi-series, side-by-side) + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + settings: { title: "Grouped (multi-series)", chartOptions: { showLegend: true } } }, + // Stacked bar + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + settings: { title: "Stacked", chartOptions: { stacked: true } } }, + // Bar with values shown + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Show Values", chartOptions: { showValues: true } } }, + // Bar with styling rules + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Rule-Based Styling", stylingConfig: barStyling } }, + // Bar with click action + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("label", "bar_decade") } }, + // Bar with colorblind mode + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + // 6 palette variants + ...paletteRow("bar", Q.barData), + ], + gridLayout: [ + // Row 1: vertical, horizontal, grouped, stacked (3×4 each) + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + // Row 2: show values, styling, click, accessibility (3×4 each) + { i: null, x: 0, y: 4, w: 3, h: 4 }, + { i: null, x: 3, y: 4, w: 3, h: 4 }, + { i: null, x: 6, y: 4, w: 3, h: 4 }, + { i: null, x: 9, y: 4, w: 3, h: 4 }, + // Rows 3-4: palette grid + ...paletteGrid(8), + ], + }, + + // ── Page 2: Line Chart ───────────────────────────────────────── + { + id: uuid(), + title: "Line Chart", + widgets: [ + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Default" } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Smooth + Area", chartOptions: { smooth: true, area: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Stepped", chartOptions: { stepped: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Show Points", chartOptions: { showPoints: true, lineWidth: 3 } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true, area: true } } }, + ...paletteRow("line", Q.lineData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 3: Pie Chart ────────────────────────────────────────── + { + id: uuid(), + title: "Pie Chart", + widgets: [ + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Default Pie" } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Donut", chartOptions: { donut: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Rose / Nightingale", chartOptions: { roseMode: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Labels Inside", chartOptions: { labelPosition: "inside" } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Click → Set Param", clickAction: clickSetParam("name", "pie_type") } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("pie", Q.pieData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + ...paletteGrid(8), + ], + }, + + // ── Page 4: Single Value ─────────────────────────────────────── + { + id: uuid(), + title: "Single Value", + widgets: [ + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Default", chartOptions: { fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "With Prefix/Suffix", chartOptions: { prefix: "$", suffix: "M", fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Comma Format", chartOptions: { numberFormat: "comma", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Compact Format", chartOptions: { numberFormat: "compact", fontSize: "lg" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, query: Q.singleVal, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling, chartOptions: { fontSize: "xl" } } }, + { id: uuid(), chartType: "single-value", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN count(m) AS value, count(m) - 5 AS previous", + settings: { title: "With Trend", chartOptions: { fontSize: "lg", trendEnabled: true } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 3 }, + { i: null, x: 4, y: 0, w: 4, h: 3 }, + { i: null, x: 8, y: 0, w: 4, h: 3 }, + { i: null, x: 0, y: 3, w: 4, h: 3 }, + { i: null, x: 4, y: 3, w: 4, h: 3 }, + { i: null, x: 8, y: 3, w: 4, h: 3 }, + ], + }, + + // ── Page 5: Table ────────────────────────────────────────────── + { + id: uuid(), + title: "Table", + widgets: [ + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Default Table" } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "With Sorting + Filters", chartOptions: { enableSorting: true, enableColumnFilters: true, enableGlobalFilter: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Row Selection", chartOptions: { enableSelection: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Click → Set Parameter", clickAction: clickSetParam("name", "table_actor") } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 6, h: 5 }, + { i: null, x: 6, y: 5, w: 6, h: 5 }, + ], + }, + + // ── Page 6: Gauge Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Gauge Chart", + widgets: [ + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Default Gauge" } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Pointer", chartOptions: { showPointer: false } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Half Gauge", chartOptions: { startAngle: 180, endAngle: 0 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Rule-Based Styling", stylingConfig: singleValueStyling } }, + ...paletteRow("gauge", Q.gaugeData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 7: Radar Chart ──────────────────────────────────────── + { + id: uuid(), + title: "Radar Chart", + widgets: [ + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Default Radar" } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Circle Shape", chartOptions: { shape: "circle" } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Filled + Values", chartOptions: { filled: true, showValues: true } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Colorblind Mode", chartOptions: { colorblindMode: true } } }, + ...paletteRow("radar", Q.radarData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 3, h: 4 }, + { i: null, x: 3, y: 0, w: 3, h: 4 }, + { i: null, x: 6, y: 0, w: 3, h: 4 }, + { i: null, x: 9, y: 0, w: 3, h: 4 }, + ...paletteGrid(4), + ], + }, + + // ── Page 8: Sankey Chart ─────────────────────────────────────── + { + id: uuid(), + title: "Sankey Chart", + widgets: [ + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Horizontal (default)" } }, + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "Vertical", chartOptions: { orient: "vertical" } } }, + ...paletteRow("sankey", Q.sankeyData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 9: Treemap Chart ────────────────────────────────────── + { + id: uuid(), + title: "Treemap Chart", + widgets: [ + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "Default Treemap" } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "With Values", chartOptions: { showValues: true } } }, + ...paletteRow("treemap", Q.treemapData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 10: Sunburst Chart ──────────────────────────────────── + { + id: uuid(), + title: "Sunburst Chart", + widgets: [ + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "Default Sunburst" } }, + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "No Labels", chartOptions: { showLabels: false } } }, + ...paletteRow("sunburst", Q.sunburstData), + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ...paletteGrid(5), + ], + }, + + // ── Page 11: Content Widgets ─────────────────────────────────── + { + id: uuid(), + title: "Content Widgets", + widgets: [ + { id: uuid(), chartType: "markdown", connectionId: "", query: "", + settings: { + title: "Markdown Widget", + chartOptions: { + content: "# NeoBoard Chart Catalog\n\nThis dashboard showcases **every chart type** with all feature variants.\n\n## Features\n- Rule-based styling\n- Click actions\n- Color palettes\n- Accessibility modes\n\n| Chart | Variants |\n| --- | --- |\n| Bar | Vertical, Horizontal, Stacked |\n| Line | Smooth, Area, Stepped |\n| Pie | Donut, Rose, Labels Inside |", + }, + }, + }, + { id: uuid(), chartType: "json", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN m ORDER BY m.released DESC LIMIT 3", + settings: { title: "JSON Viewer", chartOptions: { initialExpanded: 2 } } }, + { id: uuid(), chartType: "iframe", connectionId: "", query: "", + settings: { + title: "Embedded Content", + chartOptions: { url: "https://en.wikipedia.org/wiki/Data_visualization", iframeTitle: "Data Visualization — Wikipedia" }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 6 }, + { i: null, x: 6, y: 0, w: 6, h: 6 }, + { i: null, x: 0, y: 6, w: 12, h: 5 }, + ], + }, + + // ── Page 12: Map Chart ────────────────────────────────────── + { + id: uuid(), + title: "Map Chart", + widgets: [ + // OSM — cities by population + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Cities (OSM)", chartOptions: { tileLayer: "osm", autoFitBounds: true, markerSize: 8, showPopup: true } } }, + // Carto Light — filming locations + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapFilming, + settings: { title: "Filming Locations (Carto Light)", chartOptions: { tileLayer: "carto-light", autoFitBounds: true, markerSize: 10 } } }, + // Carto Dark — birthplaces + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapBirthplaces, + settings: { title: "Birthplaces (Carto Dark)", chartOptions: { tileLayer: "carto-dark", autoFitBounds: true } } }, + // Cluster markers + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Clustered Markers", chartOptions: { clusterMarkers: true, autoFitBounds: true } } }, + // Custom zoom + no popup + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Zoom 4 / No Popup", chartOptions: { zoom: 4, minZoom: 2, maxZoom: 10, showPopup: false, autoFitBounds: false } } }, + // Large markers + click action + { id: uuid(), chartType: "map", connectionId: neo4jId, query: Q.mapCities, + settings: { title: "Large Markers + Click", chartOptions: { markerSize: 14, autoFitBounds: true }, clickAction: clickSetParam("name", "map_city") } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 5 }, + { i: null, x: 4, y: 0, w: 4, h: 5 }, + { i: null, x: 8, y: 0, w: 4, h: 5 }, + { i: null, x: 0, y: 5, w: 4, h: 5 }, + { i: null, x: 4, y: 5, w: 4, h: 5 }, + { i: null, x: 8, y: 5, w: 4, h: 5 }, + ], + }, + + // ── Page 13: Graph Chart ───────────────────────────────────── + { + id: uuid(), + title: "Graph Chart", + widgets: [ + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphData, + settings: { title: "Force Layout (default)", chartOptions: { layout: "force", showLabels: true, showRelationshipLabels: true, physics: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "Circular Layout", chartOptions: { layout: "circular", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "Hierarchical", chartOptions: { layout: "hierarchical", showLabels: true } } }, + { id: uuid(), chartType: "graph", connectionId: neo4jId, query: Q.graphSmall, + settings: { title: "No Labels / No Physics", chartOptions: { showLabels: false, showRelationshipLabels: false, physics: false, nodeSize: "large" } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 6 }, + { i: null, x: 6, y: 0, w: 6, h: 6 }, + { i: null, x: 0, y: 6, w: 6, h: 6 }, + { i: null, x: 6, y: 6, w: 6, h: 6 }, + ], + }, + + // ── Page 13: Parameter Widgets ───────────────────────────────── + { + id: uuid(), + title: "Parameter Widgets", + widgets: [ + { id: uuid(), chartType: "parameter-select", connectionId: neo4jId, query: "", + settings: { title: "Select (Searchable)", chartOptions: { parameterType: "select", parameterName: "cat_person", seedQuery: Q.selectSeed, searchable: true, placeholder: "Choose a person\u2026" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: neo4jId, query: "", + settings: { title: "Select (Not Searchable)", chartOptions: { parameterType: "select", parameterName: "cat_person2", seedQuery: Q.selectSeed, searchable: false } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Free Text", chartOptions: { parameterType: "text", parameterName: "cat_text", placeholder: "Type anything\u2026" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Date Picker", chartOptions: { parameterType: "date", parameterName: "cat_date" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Date Range", chartOptions: { parameterType: "date-range", parameterName: "cat_daterange" } } }, + { id: uuid(), chartType: "parameter-select", connectionId: "", query: "", + settings: { title: "Relative Date", chartOptions: { parameterType: "date-relative", parameterName: "cat_reldate" } } }, + // Bound widget showing parameter in use + { id: uuid(), chartType: "table", connectionId: neo4jId, + query: "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE p.name = $param_cat_person RETURN m.title AS movie, m.released AS year ORDER BY year", + settings: { title: "Movies for $param_cat_person" } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 2 }, + { i: null, x: 4, y: 0, w: 4, h: 2 }, + { i: null, x: 8, y: 0, w: 4, h: 2 }, + { i: null, x: 0, y: 2, w: 4, h: 2 }, + { i: null, x: 4, y: 2, w: 4, h: 2 }, + { i: null, x: 8, y: 2, w: 4, h: 2 }, + { i: null, x: 0, y: 4, w: 12, h: 4 }, + ], + }, + + // ── Page 14: Form Widget ─────────────────────────────────────── + { + id: uuid(), + title: "Form Widget", + widgets: [ + { id: uuid(), chartType: "form", connectionId: neo4jId, + query: "CREATE (n:Feedback {author: $param_cat_author, message: $param_cat_msg}) RETURN n.author AS author", + settings: { + title: "Default Form", + formFields: [ + { id: uuid(), label: "Author", parameterName: "cat_author", parameterType: "text", placeholder: "Your name" }, + { id: uuid(), label: "Message", parameterName: "cat_msg", parameterType: "text", placeholder: "Your message" }, + ], + chartOptions: { submitButtonText: "Submit", successMessage: "Feedback submitted!", resetOnSuccess: true }, + }, + }, + { id: uuid(), chartType: "form", connectionId: neo4jId, + query: "CREATE (p:Person {name: $param_cat_name, born: toInteger($param_cat_born_min)}) RETURN p.name AS name", + settings: { + title: "Custom Button + No Reset", + formFields: [ + { id: uuid(), label: "Name", parameterName: "cat_name", parameterType: "text", placeholder: "Full name" }, + { id: uuid(), label: "Born", parameterName: "cat_born", parameterType: "number-range", rangeMin: 1900, rangeMax: 2010, rangeStep: 1 }, + ], + chartOptions: { submitButtonText: "Create Person", successMessage: "Person created!", resetOnSuccess: false }, + }, + }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + ], + }, + + // ── Page 15: Behavior Options ────────────────────────────────── + { + id: behaviorPageId, + title: "Behavior Options", + widgets: [ + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Refresh Button", chartOptions: { showRefreshButton: true } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Manual Run", chartOptions: { manualRun: true } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Cache Forever", chartOptions: { cacheMode: "forever", showRefreshButton: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line + Refresh", chartOptions: { showRefreshButton: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "Pie + Manual Run", chartOptions: { manualRun: true } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Table + Cache Forever", chartOptions: { cacheMode: "forever", showRefreshButton: true } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + ], + }, + + // ── Page 16: Missing Options — Axis, Grid, Legend ────────────── + { + id: uuid(), + title: "Axis & Grid Options", + widgets: [ + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "With Axis Labels", chartOptions: { xAxisLabel: "Decade", yAxisLabel: "Count" } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "No Grid Lines", chartOptions: { showGridLines: false } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barData, + settings: { title: "Custom Bar Width/Gap", chartOptions: { barWidth: 20, barGap: "50%" } } }, + { id: uuid(), chartType: "bar", connectionId: neo4jId, query: Q.barMulti, + settings: { title: "No Legend", chartOptions: { showLegend: false, stacked: true } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line + Axis Labels", chartOptions: { xAxisLabel: "Year", yAxisLabel: "Movies", showGridLines: false } } }, + { id: uuid(), chartType: "line", connectionId: neo4jId, query: Q.lineData, + settings: { title: "Line No Legend", chartOptions: { showLegend: false } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No Labels", chartOptions: { showLabel: false } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No % + Sorted", chartOptions: { showPercentage: false, sortSlices: true } } }, + { id: uuid(), chartType: "pie", connectionId: neo4jId, query: Q.pieData, + settings: { title: "No Legend", chartOptions: { showLegend: false } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 4 }, + { i: null, x: 4, y: 0, w: 4, h: 4 }, + { i: null, x: 8, y: 0, w: 4, h: 4 }, + { i: null, x: 0, y: 4, w: 4, h: 4 }, + { i: null, x: 4, y: 4, w: 4, h: 4 }, + { i: null, x: 8, y: 4, w: 4, h: 4 }, + { i: null, x: 0, y: 8, w: 4, h: 4 }, + { i: null, x: 4, y: 8, w: 4, h: 4 }, + { i: null, x: 8, y: 8, w: 4, h: 4 }, + ], + }, + + // ── Page 17: Missing Options — Table, Gauge, Others ──────────── + { + id: uuid(), + title: "Advanced Options", + widgets: [ + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "No Pagination (pageSize=100)", chartOptions: { enablePagination: false, pageSize: 100 } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, query: Q.tableData, + settings: { title: "Page Size 5", chartOptions: { pageSize: 5 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "Min=0 Max=200", chartOptions: { min: 0, max: 200 } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Progress Arc", chartOptions: { showProgress: false } } }, + { id: uuid(), chartType: "gauge", connectionId: neo4jId, query: Q.gaugeData, + settings: { title: "No Detail", chartOptions: { showDetail: false } } }, + { id: uuid(), chartType: "radar", connectionId: neo4jId, query: Q.radarData, + settings: { title: "Radar No Legend", chartOptions: { showLegend: false } } }, + { id: uuid(), chartType: "sankey", connectionId: neo4jId, query: Q.sankeyData, + settings: { title: "No Labels + Wide Nodes", chartOptions: { showLabels: false, nodeWidth: 30, nodeGap: 12 } } }, + { id: uuid(), chartType: "sunburst", connectionId: neo4jId, query: Q.sunburstData, + settings: { title: "Sort Asc + No Highlight", chartOptions: { sort: "asc", highlightOnHover: false } } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "No Labels + Low Saturation", chartOptions: { showLabels: false, colorSaturation: "low" } } }, + { id: uuid(), chartType: "treemap", connectionId: neo4jId, query: Q.treemapData, + settings: { title: "No Breadcrumb + High Saturation", chartOptions: { showBreadcrumb: false, colorSaturation: "high" } } }, + { id: uuid(), chartType: "json", connectionId: neo4jId, + query: "MATCH (m:Movie) RETURN m ORDER BY m.released DESC LIMIT 3", + settings: { title: "JSON Large + Light Theme", chartOptions: { initialExpanded: 3, fontSize: "lg", theme: "light", showCopyButton: false } } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 6, h: 5 }, + { i: null, x: 6, y: 0, w: 6, h: 5 }, + { i: null, x: 0, y: 5, w: 3, h: 4 }, + { i: null, x: 3, y: 5, w: 3, h: 4 }, + { i: null, x: 6, y: 5, w: 3, h: 4 }, + { i: null, x: 9, y: 5, w: 3, h: 4 }, + { i: null, x: 0, y: 9, w: 4, h: 4 }, + { i: null, x: 4, y: 9, w: 4, h: 4 }, + { i: null, x: 8, y: 9, w: 4, h: 4 }, + { i: null, x: 0, y: 13, w: 6, h: 4 }, + { i: null, x: 6, y: 13, w: 6, h: 4 }, + ], + }, + + // ── Page 18: Detail (click target) ───────────────────────────── + { + id: detailPageId, + title: "Detail View", + widgets: [ + { id: uuid(), chartType: "single-value", connectionId: neo4jId, + query: "RETURN $param_bar_decade AS value", + settings: { title: "Selected Decade", chartOptions: { fontSize: "xl", prefix: "Decade: " } } }, + { id: uuid(), chartType: "table", connectionId: neo4jId, + query: "MATCH (m:Movie) WHERE (m.released / 10) * 10 = toInteger($param_bar_decade) RETURN m.title AS title, m.released AS year ORDER BY year", + settings: { title: "Movies in Decade" } }, + ], + gridLayout: [ + { i: null, x: 0, y: 0, w: 4, h: 3 }, + { i: null, x: 4, y: 0, w: 8, h: 6 }, + ], + }, + ], + }; +} + function patchGridIds(layout) { for (const page of layout.pages) { for (let idx = 0; idx < page.gridLayout.length; idx++) { diff --git a/scripts/validate-seed-data.ts b/scripts/validate-seed-data.ts new file mode 100644 index 00000000..8ad605be --- /dev/null +++ b/scripts/validate-seed-data.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env npx tsx +/** + * Validates seed-demo dashboard layouts against the app's Zod schemas. + * + * Run: npx tsx scripts/validate-seed-data.ts + * + * This catches schema drift — if the app's widget types change but the seed + * data isn't updated, validation fails with a clear error. + */ + +import { dashboardLayoutSchema } from "../app/src/lib/dashboard-import"; + +const seedModule = await import("./seed-demo.mjs"); + +const DUMMY = "conn-test"; + +const dashboards: { name: string; build: () => unknown }[] = [ + { name: "Widget Showcase", build: () => seedModule.buildWidgetShowcase(DUMMY, DUMMY) }, + { name: "Table Features", build: () => seedModule.buildTableFeatures(DUMMY, DUMMY) }, + { name: "Parameter Testing", build: () => seedModule.buildParameterTesting(DUMMY, DUMMY) }, + { name: "Form Testing", build: () => seedModule.buildFormTesting(DUMMY, DUMMY) }, + { name: "Click Action Demo", build: () => seedModule.buildClickActionDemo(DUMMY, DUMMY) }, + { name: "Styling Rules Demo", build: () => seedModule.buildStylingRulesDemo(DUMMY, DUMMY) }, + { name: "Chart Improvements", build: () => seedModule.buildChartImprovements(DUMMY) }, + { name: "Chart Catalog", build: () => seedModule.buildChartCatalog(DUMMY) }, +]; + +let hasErrors = false; + +for (const { name, build } of dashboards) { + try { + const layout = build(); + // Patch grid IDs (same as seed script does before insert) + const l = layout as { pages: { widgets: { id: string }[]; gridLayout: { i: string | null }[] }[] }; + for (const page of l.pages) { + for (let idx = 0; idx < page.gridLayout.length; idx++) { + if (idx < page.widgets.length) { + page.gridLayout[idx].i = page.widgets[idx].id; + } + } + } + + const result = dashboardLayoutSchema.safeParse(layout); + if (result.success) { + console.log(` ✓ ${name}`); + } else { + hasErrors = true; + console.error(` ✗ ${name}`); + for (const issue of result.error.issues) { + console.error(` → ${issue.path.join(".")}: ${issue.message}`); + } + } + } catch (e) { + hasErrors = true; + console.error(` ✗ ${name}: ${(e as Error).message}`); + } +} + +if (hasErrors) { + console.error("\nSeed data validation FAILED — fix the issues above."); + process.exit(1); +} else { + console.log("\nAll seed dashboards valid."); +}