diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 6bbbc2b1..f7a21b2f 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -7,14 +7,18 @@ import { AuthPage } from "./pages/auth"; import { SidebarPage } from "./pages/sidebar"; // Load test container env vars (quiet suppresses dotenvx tip banners). -dotenv.config({ path: path.resolve(__dirname, "..", ".env.test"), quiet: true }); +dotenv.config({ + path: path.resolve(__dirname, "..", ".env.test"), + quiet: true, +}); /** Seed user credentials (from docker/postgres/init.sql). */ export const ALICE = { email: "alice@example.com", password: "password123" }; export const BOB = { email: "bob@example.com", password: "password123" }; /** Dynamic test container URLs. */ -export const TEST_NEO4J_BOLT_URL = process.env.TEST_NEO4J_BOLT_URL ?? "bolt://localhost:7687"; +export const TEST_NEO4J_BOLT_URL = + process.env.TEST_NEO4J_BOLT_URL ?? "bolt://localhost:7687"; export const TEST_PG_PORT = process.env.TEST_PG_PORT ?? "5432"; type Fixtures = { @@ -74,15 +78,21 @@ export async function typeInEditor( // destroy and recreate the CM6 editor mid-flow. await expect(async () => { // Wait for CM6 to mount (re-checked each iteration in case of remount) - await cmContainer.locator(".cm-editor").waitFor({ state: "visible", timeout: 5_000 }); + await cmContainer + .locator(".cm-editor") + .waitFor({ state: "visible", timeout: 5_000 }); // Wait for the React wrapper to signal writable - await expect(cmContainer).toHaveAttribute("data-readonly", "false", { timeout: 5_000 }); + await expect(cmContainer).toHaveAttribute("data-readonly", "false", { + timeout: 5_000, + }); // Wait for initEditor to complete (view + compartments fully initialized). // This prevents the race where data-readonly is "false" but the CM6 view // hasn't been created yet because async imports are still in progress. - await expect(cmContainer).toHaveAttribute("data-editor-ready", "true", { timeout: 5_000 }); + await expect(cmContainer).toHaveAttribute("data-editor-ready", "true", { + timeout: 5_000, + }); // Pre-dispatch stability: poll until the editor has been continuously // ready for 3 consecutive checks (600ms stable window). Connection and @@ -106,72 +116,59 @@ export async function typeInEditor( } // Strategy 1: Use CM6's internal dispatch API (most reliable). - // 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; + // The QueryEditor component exposes `__cmView` on the container DOM element + // when initialization completes. This is more reliable than the internal + // `cmTile` property which may be mangled or inaccessible in production builds. + const dispatched = await cmContainer.evaluate( + (el: HTMLElement, text: string) => { // 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"; - const view = findView(cmContent) ?? findView(el.querySelector(".cm-editor")); - if (!view) return "no-view"; - if (view.state.readOnly) return "readonly"; - - // Replace entire document content - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: text }, - }); - return view.state.doc.toString().includes(text.substring(0, 20)) - ? "ok" - : "dispatch-failed"; - }, query); + const view = (el as any).__cmView; + if (!el.querySelector(".cm-content")) return "no-editor"; + if (!view) return "no-view"; + if (view.state.readOnly) return "readonly"; + + // Replace entire document content + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + }); + return view.state.doc.toString().includes(text.substring(0, 20)) + ? "ok" + : "dispatch-failed"; + }, + query, + ); if (dispatched === "ok") { // Post-dispatch stability: verify text survives any late re-renders. // The pre-dispatch check handles most cases; this is a safety net. // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(300); - const stillPresent = 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; + const stillPresent = await cmContainer.evaluate( + (el: HTMLElement, text: string) => { // 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); + const view = (el as any).__cmView; + if (!view) return false; + return view.state.doc.toString().includes(text.substring(0, 20)); + }, + query, + ); if (!stillPresent) { - throw new Error("Text overwritten after dispatch (editor re-mounted) — retrying"); + throw new Error( + "Text overwritten after dispatch (editor re-mounted) — retrying", + ); } return; } - // Strategy 2: Keyboard fallback (for environments where cmView is not accessible - // or when the view is temporarily readonly during initialization) + // __cmView not ready yet — retry (editor may be re-mounting) if (dispatched === "no-view" || dispatched === "readonly") { - await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 }); - await cm.click(); - await page.keyboard.press("ControlOrMeta+a"); - await page.keyboard.press("Backspace"); - await page.keyboard.insertText(query); - const text = await cm.textContent(); - if (!text || !text.includes(query.substring(0, 20))) { - throw new Error("Keyboard fallback: text not inserted"); - } + throw new Error(`CM6 __cmView not available (${dispatched}) — retrying`); return; } // Retry-worthy states: no-editor, dispatch-failed throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); - }).toPass({ timeout: 20_000 }); + }).toPass({ timeout: 30_000 }); } /** diff --git a/app/src/app/(dashboard)/[id]/edit/page.tsx b/app/src/app/(dashboard)/[id]/edit/page.tsx index d6fdb188..b3725c2a 100644 --- a/app/src/app/(dashboard)/[id]/edit/page.tsx +++ b/app/src/app/(dashboard)/[id]/edit/page.tsx @@ -485,7 +485,9 @@ export default function DashboardEditorPage({ initialTemplate={ pendingTemplateId ? templateMap[pendingTemplateId] : undefined } - initialPreviewData={editorMode === "edit" ? cachedPreviewData : undefined} + initialPreviewData={ + editorMode === "edit" ? cachedPreviewData : undefined + } /> {templateWidget && @@ -545,23 +547,24 @@ export default function DashboardEditorPage({ { - const target = page.widgets.find( - (w) => w.id === widgetId, - ); - if (target) { - updateWidget(widgetId, { ...target, settings }); - } + actions={{ + onRemoveWidget: removeWidget, + onEditWidget: openEditWidget, + onDuplicateWidget: duplicateWidget, + onLayoutChange: isActive ? updateGridLayout : undefined, + onWidgetSettingsChange: (widgetId, settings) => { + const target = page.widgets.find( + (w) => w.id === widgetId, + ); + if (target) + updateWidget(widgetId, { ...target, settings }); + }, + onNavigateToPage: handleNavigateToPage, + onSaveAsTemplate: setTemplateWidget, + onSyncWidget: handleSyncWidget, + onDetachWidget: handleDetachWidget, }} - onNavigateToPage={handleNavigateToPage} - onSaveAsTemplate={setTemplateWidget} templateMap={templateMap} - onSyncWidget={handleSyncWidget} - onDetachWidget={handleDetachWidget} showParameterBar={showParameterBar} /> diff --git a/app/src/app/(dashboard)/[id]/page.tsx b/app/src/app/(dashboard)/[id]/page.tsx index bed0b5ed..b921e434 100644 --- a/app/src/app/(dashboard)/[id]/page.tsx +++ b/app/src/app/(dashboard)/[id]/page.tsx @@ -1,8 +1,22 @@ "use client"; -import React, { use, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; +import React, { + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Filter, Pencil, LayoutDashboard, RefreshCw } from "lucide-react"; +import { + ArrowLeft, + Filter, + Pencil, + LayoutDashboard, + RefreshCw, +} from "lucide-react"; import { useDashboard, useUpdateDashboard } from "@/hooks/use-dashboards"; import { useParameterStore } from "@/stores/parameter-store"; import { filterParentParams } from "@/lib/format-parameter-value"; @@ -79,7 +93,7 @@ export default function DashboardViewerPage({ const [showParameterBar, setShowParameterBar] = useState(true); const [activePageIndex, setActivePageIndex] = useState(0); const [visitedPages, setVisitedPages] = useState>( - () => new Set([0]) + () => new Set([0]), ); function markVisited(index: number) { @@ -98,13 +112,17 @@ export default function DashboardViewerPage({ const [isPending, startTransition] = useTransition(); const layout = useMemo( () => (dashboard ? migrateLayout(dashboard.layoutJson) : null), - [dashboard] + [dashboard], ); // Auto-refresh: local override (null = use persisted settings from layout). // Keyed by dashboard id so navigating to a different dashboard resets the override. - const [localSettings, setLocalSettings] = useState<{ dashboardId: string; settings: DashboardSettings } | null>(null); - const activeLocalSettings = localSettings?.dashboardId === id ? localSettings.settings : null; + const [localSettings, setLocalSettings] = useState<{ + dashboardId: string; + settings: DashboardSettings; + } | null>(null); + const activeLocalSettings = + localSettings?.dashboardId === id ? localSettings.settings : null; const autoRefreshSettings = activeLocalSettings ?? layout?.settings ?? {}; const refetchInterval = getRefetchInterval(autoRefreshSettings); @@ -126,12 +144,18 @@ export default function DashboardViewerPage({ : { autoRefresh: true, refreshIntervalSeconds: seconds }; setLocalSettings({ dashboardId: id, settings: newSettings }); if (layout) { - const payload = { id, layoutJson: { ...layout, settings: newSettings } }; + const payload = { + id, + layoutJson: { ...layout, settings: newSettings }, + }; persistQueueRef.current = persistQueueRef.current .catch(() => undefined) .then(() => updateDashboard.mutateAsync(payload)) .catch((err: unknown) => { - console.error("[auto-save] Failed to persist dashboard settings:", err); + console.error( + "[auto-save] Failed to persist dashboard settings:", + err, + ); }); } }, @@ -154,15 +178,19 @@ export default function DashboardViewerPage({ }, [customSeconds, applyInterval]); // Derive display values from the effective (normalized) interval - const effectiveSeconds = typeof refetchInterval === "number" ? refetchInterval / 1000 : null; - const intervalLabel = effectiveSeconds !== null - ? formatInterval(effectiveSeconds) - : "Auto-refresh"; - const dropdownValue = effectiveSeconds !== null ? String(effectiveSeconds) : "off"; + const effectiveSeconds = + typeof refetchInterval === "number" ? refetchInterval / 1000 : null; + const intervalLabel = + effectiveSeconds !== null + ? formatInterval(effectiveSeconds) + : "Auto-refresh"; + const dropdownValue = + effectiveSeconds !== null ? String(effectiveSeconds) : "off"; // Toolbar button label: show interval + live countdown when active - const buttonLabel = countdown !== null - ? `${intervalLabel} · ${formatCountdown(countdown)}` - : intervalLabel; + const buttonLabel = + countdown !== null + ? `${intervalLabel} · ${formatCountdown(countdown)}` + : intervalLabel; const handleNavigateToPage = useCallback( (pageId: string) => { @@ -173,7 +201,7 @@ export default function DashboardViewerPage({ setActivePageIndex(index); } }, - [layout] + [layout], ); if (isLoading) { @@ -211,17 +239,16 @@ export default function DashboardViewerPage({ // layout is non-null here because dashboard is defined (guarded above) const resolvedLayout = layout!; const safeIndex = Math.min(activePageIndex, resolvedLayout.pages.length - 1); - const canEdit = dashboard.role === "owner" || dashboard.role === "editor" || dashboard.role === "admin"; + const canEdit = + dashboard.role === "owner" || + dashboard.role === "editor" || + dashboard.role === "admin"; return (
- @@ -231,7 +258,9 @@ export default function DashboardViewerPage({ {dashboard.role} · updated - {dashboard.updatedByName ? <> by {dashboard.updatedByName} : null} + {dashboard.updatedByName ? ( + <> by {dashboard.updatedByName} + ) : null} @@ -241,16 +270,24 @@ export default function DashboardViewerPage({ size="sm" disabled={!hasParameters} onClick={() => setShowParameterBar((prev) => !prev)} - aria-label={showParameterBar ? "Hide parameters" : "Show parameters"} + aria-label={ + showParameterBar ? "Hide parameters" : "Show parameters" + } > - {!hasParameters || showParameterBar ? "Filters" : `Filters (${parameterCount})`} + {!hasParameters || showParameterBar + ? "Filters" + : `Filters (${parameterCount})`} {canEdit && ( <> -
@@ -270,9 +312,11 @@ export function CardContainer({ type={chartConfig.type} data={null} settings={widget.settings as Record} - connectionId={widget.connectionId} - widgetId={widget.id} - query={widget.query} + meta={{ + connectionId: widget.connectionId, + widgetId: widget.id, + query: widget.query, + }} /> @@ -314,7 +358,10 @@ export function CardContainer({ // show an overlay button instead of the loading skeleton. if (isManualRun && !hasEverRun) { return ( -
+

Query execution is paused. @@ -339,11 +386,16 @@ export function CardContainer({ return (

-

Waiting for parameters…

+

+ Waiting for parameters… +

{missingParams.length > 0 && (
{missingParams.map((name) => ( - + $param_{name} ))} @@ -372,7 +424,10 @@ export function CardContainer({ Query Failed

{widgetQuery.error.message}

-

+

{widget.query}

@@ -404,7 +459,10 @@ export function CardContainer({ ); } - const transformedData = chartConfig.transformWithMapping(rawData, columnMapping); + const transformedData = chartConfig.transformWithMapping( + rawData, + columnMapping, + ); const availableColumns = extractColumnNames(rawData); return ( @@ -412,7 +470,9 @@ export function CardContainer({ {widgetQuery.data?.truncated && (
- Showing first 10,000 rows. Refine your query to see all results. + + Showing first 10,000 rows. Refine your query to see all results. +
)}
@@ -420,15 +480,22 @@ export function CardContainer({ type={chartConfig.type} data={transformedData} settings={chartOptions} - onChartClick={hasClickAction ? handleChartClick : undefined} - clickableColumns={clickableColumns} - connectionId={widget.connectionId} - widgetId={widget.id} - resultId={widgetQuery.data.resultId} - stylingRules={resolvedStylingConfig?.rules} - paramValues={allParamValues} - autoFit={autoFit} - colorScales={colorScales} + styling={{ + rules: resolvedStylingConfig?.rules, + paramValues: allParamValues, + colorScales, + }} + interaction={ + hasClickAction + ? { onChartClick: handleChartClick, clickableColumns } + : undefined + } + meta={{ + connectionId: widget.connectionId, + widgetId: widget.id, + resultId: widgetQuery.data.resultId, + autoFit, + }} />
{showOverlay && ( diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx index d0c52a79..a6b7a893 100644 --- a/app/src/components/chart-renderer.tsx +++ b/app/src/components/chart-renderer.tsx @@ -16,19 +16,22 @@ import { // Chart components use ECharts (browser APIs) — must be loaded client-side only const BarChart = dynamic( () => import("@neoboard/components").then((m) => ({ default: m.BarChart })), - { ssr: false, loading: () => } + { ssr: false, loading: () => }, ); const LineChart = dynamic( () => import("@neoboard/components").then((m) => ({ default: m.LineChart })), - { ssr: false, loading: () => } + { ssr: false, loading: () => }, ); const PieChart = dynamic( () => import("@neoboard/components").then((m) => ({ default: m.PieChart })), - { ssr: false, loading: () => } + { ssr: false, loading: () => }, ); const SingleValueChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.SingleValueChart })), - { ssr: false, loading: () => } + () => + import("@neoboard/components").then((m) => ({ + default: m.SingleValueChart, + })), + { ssr: false, loading: () => }, ); import type { BarChartDataPoint, @@ -56,13 +59,15 @@ import { FormWidgetRenderer } from "./form-widget-renderer"; // Lazy-load GraphChart so NVL (WebGL) is only bundled when a graph widget is rendered const GraphChart = dynamic( - () => import("@neoboard/components").then((mod) => ({ default: mod.GraphChart })), - { ssr: false, loading: () => } + () => + import("@neoboard/components").then((mod) => ({ default: mod.GraphChart })), + { ssr: false, loading: () => }, ); // Dynamically import MapChart to avoid SSR issues with Leaflet const MapChart = dynamic( - () => import("@neoboard/components").then((mod) => ({ default: mod.MapChart })), + () => + import("@neoboard/components").then((mod) => ({ default: mod.MapChart })), { ssr: false, loading: () => ( @@ -70,59 +75,84 @@ const MapChart = dynamic(
), - } + }, ); // New ECharts chart types — loaded client-side only const GaugeChart = dynamic( () => import("@neoboard/components").then((m) => ({ default: m.GaugeChart })), - { ssr: false, loading: () => } + { ssr: false, loading: () => }, ); const SankeyChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.SankeyChart })), - { ssr: false, loading: () => } + () => + import("@neoboard/components").then((m) => ({ default: m.SankeyChart })), + { ssr: false, loading: () => }, ); const SunburstChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.SunburstChart })), - { ssr: false, loading: () => } + () => + import("@neoboard/components").then((m) => ({ default: m.SunburstChart })), + { ssr: false, loading: () => }, ); const RadarChart = dynamic( () => import("@neoboard/components").then((m) => ({ default: m.RadarChart })), - { ssr: false, loading: () => } + { ssr: false, loading: () => }, ); const TreemapChart = dynamic( - () => import("@neoboard/components").then((m) => ({ default: m.TreemapChart })), - { ssr: false, loading: () => } + () => + import("@neoboard/components").then((m) => ({ default: m.TreemapChart })), + { ssr: false, loading: () => }, ); -export interface ChartRendererProps { - type: ChartType; - data: unknown; - settings?: Record; +/** Styling-related props grouped together. */ +export interface ChartStylingProps { + rules?: StylingRule[]; + paramValues?: Record; + colorScales?: ColorScaleConfig[]; +} + +/** Interaction-related props grouped together. */ +export interface ChartInteractionProps { onChartClick?: (point: Record) => void; - /** Restrict which table columns are clickable. Only applies to table type. */ clickableColumns?: string[]; +} + +/** Widget metadata props grouped together. */ +export interface ChartMetaProps { connectionId?: string; widgetId?: string; resultId?: string; query?: string; - /** Rule-based styling rules */ - stylingRules?: StylingRule[]; - /** Resolved parameter values for parameterRef comparisons */ - 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[]; +} + +export interface ChartRendererProps { + type: ChartType; + data: unknown; + settings?: Record; + styling?: ChartStylingProps; + interaction?: ChartInteractionProps; + meta?: ChartMetaProps; } /** * 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, colorScales }: ChartRendererProps) { +export function ChartRenderer({ + type, + data, + settings = {}, + styling, + interaction, + meta, +}: ChartRendererProps) { + const { rules: stylingRules, paramValues, colorScales } = styling ?? {}; + const { onChartClick, clickableColumns } = interaction ?? {}; + const { connectionId, widgetId, resultId, query, autoFit } = meta ?? {}; const colorThresholds = - typeof settings.colorThresholds === "string" ? settings.colorThresholds : undefined; + typeof settings.colorThresholds === "string" + ? settings.colorThresholds + : undefined; const handleEChartsClick = useMemo(() => { if (!onChartClick) return undefined; @@ -130,7 +160,9 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab // 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; + const row = Array.isArray(data) + ? (data[e.dataIndex] as Record | undefined) + : undefined; onChartClick({ ...(row ?? {}), name: e.name, @@ -146,7 +178,9 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab return ( { if (ids.length) onChartClick({ nodeId: ids[0] }); } : undefined} + onNodeSelect={ + onChartClick + ? (ids) => { + if (ids.length) onChartClick({ nodeId: ids[0] }); + } + : undefined + } autoFit={autoFit} /> ); @@ -273,7 +333,17 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab minZoom={settings.minZoom as number | undefined} maxZoom={settings.maxZoom as number | undefined} autoFitBounds={settings.autoFitBounds !== false} - onMarkerClick={onChartClick ? (m) => onChartClick({ id: m.id, label: m.label, lat: m.lat, lng: m.lng }) : undefined} + onMarkerClick={ + onChartClick + ? (m) => + onChartClick({ + id: m.id, + label: m.label, + lat: m.lat, + lng: m.lng, + }) + : undefined + } /> ); } @@ -283,7 +353,15 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab onChartClick({ _clickedColumn: info.column, _clickedValue: info.value }) : undefined} + onCellClick={ + onChartClick + ? (info) => + onChartClick({ + _clickedColumn: info.column, + _clickedValue: info.value, + }) + : undefined + } clickableColumns={clickableColumns} stylingRules={stylingRules} paramValues={paramValues} @@ -306,14 +384,20 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab
@@ -342,9 +426,7 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab case "markdown": return ( - + ); case "iframe": @@ -408,7 +490,10 @@ export function ChartRenderer({ type, data, settings = {}, onChartClick, clickab ); case "radar": { - const radarData = (data as RadarChartData) ?? { indicators: [], series: [] }; + const radarData = (data as RadarChartData) ?? { + indicators: [], + series: [], + }; return ( void; onEditWidget?: (widget: DashboardWidget) => void; onDuplicateWidget?: (widgetId: string) => void; onLayoutChange?: (gridLayout: GridLayoutItem[]) => void; - /** - * Called when a widget's settings are updated inline (e.g. column mapping). - * The caller should persist the updated widget to the dashboard layout. - */ onWidgetSettingsChange?: ( widgetId: string, settings: Record, ) => void; - /** TanStack Query refetchInterval — periodically re-executes all widget queries. */ - refetchInterval?: number | false; - /** Called when a click action navigates to a different page. */ onNavigateToPage?: (pageId: string) => void; - /** Called when the user chooses "Save to Widget Lab" for a widget. */ onSaveAsTemplate?: (widget: DashboardWidget) => void; - /** Map of template ID → template for outdated-sync detection. */ - templateMap?: Record; - /** Called when the user confirms "Sync with template". */ onSyncWidget?: (widget: DashboardWidget) => void; - /** Called when the user chooses "Detach from template". */ onDetachWidget?: (widgetId: string) => void; - /** When false, the parameter bar is hidden. Defaults to true. */ +} + +interface DashboardContainerProps { + page: DashboardPage; + editable?: boolean; + actions?: WidgetActions; + refetchInterval?: number | false; + templateMap?: Record; showParameterBar?: boolean; } function getWidgetTitle(widget: DashboardWidget): string { - if (widget.settings?.title && typeof widget.settings.title === "string") { - return widget.settings.title; - } + const title = (widget.settings ?? {}).title; + if (title && typeof title === "string") return title; return getChartConfig(widget.chartType)?.label ?? widget.chartType; } export function DashboardContainer({ page, editable = false, - onRemoveWidget, - onEditWidget, - onDuplicateWidget, - onLayoutChange, - onWidgetSettingsChange, + actions, refetchInterval, - onNavigateToPage, - onSaveAsTemplate, templateMap, - onSyncWidget, - onDetachWidget, showParameterBar = true, }: DashboardContainerProps) { + const { + onRemoveWidget, + onEditWidget, + onDuplicateWidget, + onLayoutChange, + onWidgetSettingsChange, + onNavigateToPage, + onSaveAsTemplate, + onSyncWidget, + onDetachWidget, + } = actions ?? {}; const queryClient = useQueryClient(); const [fullscreenWidget, setFullscreenWidget] = useState(null); @@ -210,92 +205,92 @@ export function DashboardContainer({ )}
- onLayoutChange?.(items as GridLayoutItem[])} - isDraggable={editable} - isResizable={editable} - > - {page.widgets.map((widget) => { - const outdated = editable && isWidgetOutdated(widget); - const chartOpts = (widget.settings?.chartOptions ?? {}) as Record< - string, - unknown - >; - const showRefresh = shouldShowRefreshButton(chartOpts); - return ( -
- { - // Invalidate all TanStack Query entries matching this widget's - // connection + query combo. This triggers a refetch. - void queryClient.invalidateQueries({ - queryKey: [ - "widget-query", - widget.connectionId, - widget.query, - widget.params, - ], - }); - } - : undefined - } - headerExtra={ - <> - {outdated && ( + + onLayoutChange?.(items as GridLayoutItem[]) + } + isDraggable={editable} + isResizable={editable} + > + {page.widgets.map((widget) => { + const outdated = editable && isWidgetOutdated(widget); + const chartOpts = ((widget.settings ?? {}).chartOptions ?? + {}) as Record; + const showRefresh = shouldShowRefreshButton(chartOpts); + return ( +
+ { + // Invalidate all TanStack Query entries matching this widget's + // connection + query combo. This triggers a refetch. + void queryClient.invalidateQueries({ + queryKey: [ + "widget-query", + widget.connectionId, + widget.query, + widget.params, + ], + }); + } + : undefined + } + headerExtra={ + <> + {outdated && ( + + )} - )} - - - } - > - - onWidgetSettingsChange(widget.id, settings) - : undefined + } - refetchInterval={refetchInterval} - onNavigateToPage={onNavigateToPage} - /> - -
- ); - })} -
+ > + + onWidgetSettingsChange(widget.id, settings) + : undefined + } + refetchInterval={refetchInterval} + onNavigateToPage={onNavigateToPage} + /> +
+
+ ); + })} +
{ + useWidgetEditorStore.setState({ chartType, connectionId, query }); + }, [chartType, connectionId, query]); + useLayoutEffect(() => { + useWidgetEditorStore.setState({ availableFields }); + }, [availableFields]); + useLayoutEffect(() => { + useWidgetEditorStore.setState({ parameterSuggestions }); + }, [parameterSuggestions]); + + // Bidirectional sync for mutable state between local state and store. + const syncingFromStore = useRef(false); + useLayoutEffect(() => { + if (!syncingFromStore.current) { + useWidgetEditorStore.setState({ + stylingRules, + actionRules, + formFields, + query, + chartOptions, + connectionId, + paramUIType, + dateSub, + multiSelect, + paramWidgetName, + }); + } + }, [ + stylingRules, + actionRules, + formFields, + query, + chartOptions, + connectionId, + paramUIType, + dateSub, + multiSelect, + paramWidgetName, + ]); + // Reverse: store → local when sub-editors modify state + useEffect(() => { + return useWidgetEditorStore.subscribe((state, prev) => { + syncingFromStore.current = true; + if (state.stylingRules !== prev.stylingRules) + setStylingRules(state.stylingRules); + if (state.actionRules !== prev.actionRules) + setActionRules(state.actionRules); + if (state.formFields !== prev.formFields) setFormFields(state.formFields); + if (state.query !== prev.query) setQuery(state.query); + if (state.chartOptions !== prev.chartOptions) + setChartOptions(state.chartOptions); + if (state.paramUIType !== prev.paramUIType) + setParamUIType(state.paramUIType); + if (state.dateSub !== prev.dateSub) setDateSub(state.dateSub); + if (state.multiSelect !== prev.multiSelect) + setMultiSelect(state.multiSelect); + if (state.paramWidgetName !== prev.paramWidgetName) + setParamWidgetName(state.paramWidgetName); + syncingFromStore.current = false; + }); + }, []); + const isParamSelect = chartType === "parameter-select"; const isForm = chartType === "form"; const isMarkdown = chartType === "markdown"; @@ -936,23 +1003,10 @@ export function WidgetEditorModal({ className="max-w-[1200px] max-h-[90vh] flex flex-col overflow-hidden" > {dialogStep === "styling-rules" ? ( - setDialogStep("main")} - chartType={chartType} - availableFields={availableFields} - parameterSuggestions={parameterSuggestions} - stylingTargets={getStylingTargets(chartType)} - /> + setDialogStep("main")} /> ) : dialogStep === "rules" ? ( setDialogStep("main")} - chartType={chartType} - availableFields={availableFields} - parameterSuggestions={parameterSuggestions} pages={(layout?.pages ?? []).map((p) => ({ id: p.id, title: p.title, @@ -1193,17 +1247,6 @@ export function WidgetEditorModal({ {/* Parameter config (when parameter-select) */} {isParamSelect && ( @@ -1233,24 +1276,14 @@ export function WidgetEditorModal({ {/* Query editor (non-parameter and non-content types) */} {!isParamSelect && !isContentOnly && ( )} {/* Form fields editor (form type only) */} - {isForm && ( - - )} + {isForm && }
} styleTab={ @@ -1698,7 +1731,7 @@ export function WidgetEditorModal({ ? !paramWidgetName.trim() || (paramUIType === "select" && (!connectionId || - !(chartOptions.seedQuery as string)?.trim())) + !String(chartOptions.seedQuery ?? "").trim())) : isContentOnly ? false : isForm diff --git a/app/src/components/widget-editor/action-rules-editor.tsx b/app/src/components/widget-editor/action-rules-editor.tsx index c9bfb83e..0d4c58b9 100644 --- a/app/src/components/widget-editor/action-rules-editor.tsx +++ b/app/src/components/widget-editor/action-rules-editor.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { ClickActionRule } from "@/lib/db/schema"; +import { useWidgetEditorStore } from "@/stores/widget-editor-store"; import { ArrowLeft, Plus, Trash2 } from "lucide-react"; import { Accordion, @@ -23,24 +24,18 @@ import { useAccordionCrud } from "./use-accordion-crud"; import { FieldSelectorInput } from "./field-selector-input"; interface ActionRulesEditorProps { - rules: ClickActionRule[]; - onRulesChange: (rules: ClickActionRule[]) => void; onBack: () => void; - chartType: string; - availableFields: string[]; - parameterSuggestions: string[]; pages: { id: string; title: string }[]; } -export function ActionRulesEditor({ - rules, - onRulesChange, - onBack, - chartType, - availableFields, - parameterSuggestions, - pages, -}: ActionRulesEditorProps) { +export function ActionRulesEditor({ onBack, pages }: ActionRulesEditorProps) { + const rules = useWidgetEditorStore((s) => s.actionRules); + const onRulesChange = useWidgetEditorStore((s) => s.setActionRules); + const chartType = useWidgetEditorStore((s) => s.chartType); + const availableFields = useWidgetEditorStore((s) => s.availableFields); + const parameterSuggestions = useWidgetEditorStore( + (s) => s.parameterSuggestions, + ); const isTable = chartType === "table"; const { openItems, setOpenItems, addItem, removeItem, updateItem } = @@ -59,8 +54,11 @@ export function ActionRulesEditor({ addItem(() => ({ id: crypto.randomUUID(), type: "set-parameter", - triggerColumn: isTable ? availableFields[0] ?? "" : undefined, - parameterMapping: { parameterName: "", sourceField: availableFields[0] ?? "" }, + triggerColumn: isTable ? (availableFields[0] ?? "") : undefined, + parameterMapping: { + parameterName: "", + sourceField: availableFields[0] ?? "", + }, })); } @@ -82,9 +80,17 @@ export function ActionRulesEditor({

)} - + {rules.map((rule, index) => ( - +
Rule {index + 1} @@ -108,7 +114,9 @@ export function ActionRulesEditor({ updateItem(rule.id, { triggerColumn: v })} + onChange={(v) => + updateItem(rule.id, { triggerColumn: v }) + } fields={availableFields} label="Trigger Column" placeholder="Select column..." @@ -131,15 +139,22 @@ export function ActionRulesEditor({ - Set Parameter - Navigate to Page - Set Parameter & Navigate + + Set Parameter + + + Navigate to Page + + + Set Parameter & Navigate +
{/* Parameter name */} - {(rule.type === "set-parameter" || rule.type === "set-parameter-and-navigate") && ( + {(rule.type === "set-parameter" || + rule.type === "set-parameter-and-navigate") && ( <>
@@ -150,7 +165,8 @@ export function ActionRulesEditor({ updateItem(rule.id, { parameterMapping: { parameterName: v, - sourceField: rule.parameterMapping?.sourceField ?? "", + sourceField: + rule.parameterMapping?.sourceField ?? "", }, }) } @@ -167,7 +183,8 @@ export function ActionRulesEditor({ onChange={(v) => updateItem(rule.id, { parameterMapping: { - parameterName: rule.parameterMapping?.parameterName ?? "", + parameterName: + rule.parameterMapping?.parameterName ?? "", sourceField: v, }, }) @@ -182,13 +199,16 @@ export function ActionRulesEditor({ )} {/* Target page */} - {(rule.type === "navigate-to-page" || rule.type === "set-parameter-and-navigate") && ( + {(rule.type === "navigate-to-page" || + rule.type === "set-parameter-and-navigate") && (
{pages.length > 0 ? ( @@ -191,7 +208,9 @@ function SortableFieldItem({ field, index, onRemove, onUpdate }: SortableFieldIt