Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
62bd3f7
feat(app): add CSV export to widget cards
alfredorubin96 Mar 22, 2026
a432924
feat(component): add GFM table support to markdown widget
alfredorubin96 Mar 22, 2026
18802be
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
79b9bfb
feat(app): clickable missing parameter badges with navigate-to-source
alfredorubin96 Mar 23, 2026
61c662c
feat(app): client-side data transforms — group, aggregate, filter, so…
alfredorubin96 Mar 23, 2026
0b93c4c
fix: replace new Function() with safe expression parser, add tests
alfredorubin96 Mar 24, 2026
cb25f4f
fix(component): CSV header escaping, \r handling, and export filenames
alfredorubin96 Mar 24, 2026
337688a
fix: apply table alignment markers, preserve empty cells, add tests
alfredorubin96 Mar 24, 2026
4cfed8a
fix(app): CSS.escape widgetId, increase RAF retries, dedup param sour…
alfredorubin96 Mar 24, 2026
61c6a85
fix(e2e): use keyboard fallback when CM6 editor reports readonly
alfredorubin96 Mar 24, 2026
b962834
fix: type vi.fn mock to resolve TS2352/TS2493 in scroll-to-widget test
alfredorubin96 Mar 24, 2026
0066bb1
Merge remote-tracking branch 'origin/feat/issue-143-markdown-tables' …
alfredorubin96 Mar 24, 2026
e0cbea8
Merge remote-tracking branch 'origin/feat/issue-180-clickable-param-b…
alfredorubin96 Mar 24, 2026
96f773f
Merge remote-tracking branch 'origin/feat/issue-105-data-transforms' …
alfredorubin96 Mar 24, 2026
19b32ea
Merge remote-tracking branch 'origin/release/0.9.1' into release/app-…
alfredorubin96 Mar 27, 2026
c8848f3
fix: address critical and high review findings for PR #188
alfredorubin96 Mar 27, 2026
50e9a7e
fix: repair JSX fragment closing tag broken by merge conflict
alfredorubin96 Mar 27, 2026
2aafdd5
refactor: eliminate bidirectional state sync in widget-editor-modal
alfredorubin96 Mar 27, 2026
ea0304f
fix: repair JSX structure in widget-editor-modal from merge artifacts
alfredorubin96 Mar 27, 2026
8119ef4
feat: refactor data transforms — new tab, parameter support, tests
alfredorubin96 Mar 27, 2026
025dbba
test: add edge case tests for data transforms
alfredorubin96 Mar 28, 2026
ad81d64
fix: setRefreshWidgetIds uses store setter (not callback pattern)
alfredorubin96 Mar 28, 2026
646edd3
test: remove v09-features E2E spec (tests non-existent UI controls)
alfredorubin96 Mar 28, 2026
387250e
test: mark 4 flaky E2E tests as test.fixme()
alfredorubin96 Mar 28, 2026
00f9e18
fix: transforms not saving, preview not updating, filter value UX
alfredorubin96 Mar 28, 2026
3e2b66f
fix: address all CodeRabbit review findings
alfredorubin96 Mar 28, 2026
bb42d9a
fix: update export-utils tests for CRLF line endings
alfredorubin96 Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@
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");
Expand All @@ -162,9 +163,9 @@
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 });

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/widgets.spec.ts:174:7 › Widget edit – query cache invalidation › re-fetches query data after editing widget with changed query

5) [chromium] › e2e/widgets.spec.ts:174:7 › Widget edit – query cache invalidation › re-fetches query data after editing widget with changed query Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at /home/runner/work/neoboard/neoboard/app/e2e/widgets.spec.ts:187:23

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/widget-lab.spec.ts:87:9 › Widget Lab › Save / browse / delete template flow › can save a widget as a template and see it in Widget Lab

4) [chromium] › e2e/widget-lab.spec.ts:87:9 › Widget Lab › Save / browse / delete template flow › can save a widget as a template and see it in Widget Lab Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at addBarWidgetToDashboard (/home/runner/work/neoboard/neoboard/app/e2e/widget-lab.spec.ts:17:21) at /home/runner/work/neoboard/neoboard/app/e2e/widget-lab.spec.ts:75:7

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/new-charts.spec.ts:144:7 › New chart types — creation flow › should create a Sunburst widget

3) [chromium] › e2e/new-charts.spec.ts:144:7 › New chart types — creation flow › should create a Sunburst widget Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at /home/runner/work/neoboard/neoboard/app/e2e/new-charts.spec.ts:159:23

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/design-system.spec.ts:110:7 › Design system — Deep Ocean palette & accessibility › Colorblind Mode option appears in Style tab for bar chart

2) [chromium] › e2e/design-system.spec.ts:110:7 › Design system — Deep Ocean palette & accessibility › Colorblind Mode option appears in Style tab for bar chart Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at addBarChartWithData (/home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:40:23) at /home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:113:20

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/design-system.spec.ts:125:7 › Design system — Deep Ocean palette & accessibility › toggling Colorblind Mode re-renders chart (no crash)

1) [chromium] › e2e/design-system.spec.ts:125:7 › Design system — Deep Ocean palette & accessibility › toggling Colorblind Mode re-renders chart (no crash) Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at addBarChartWithData (/home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:40:23) at /home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:129:20

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/design-system.spec.ts:125:7 › Design system — Deep Ocean palette & accessibility › toggling Colorblind Mode re-renders chart (no crash)

1) [chromium] › e2e/design-system.spec.ts:125:7 › Design system — Deep Ocean palette & accessibility › toggling Colorblind Mode re-renders chart (no crash) Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at addBarChartWithData (/home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:40:23) at /home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:129:20
}

/**
Expand Down
13 changes: 12 additions & 1 deletion app/src/app/(dashboard)/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { useConnections } from "@/hooks/use-connections";
import { useUnsavedChangesWarning } from "@/hooks/use-unsaved-changes-warning";
import { useParameterStore } from "@/stores/parameter-store";
import { filterParentParams } from "@/lib/format-parameter-value";
import { buildParameterSourceMap } from "@/lib/collect-parameter-names";
import { scrollToWidgetWhenReady } from "@/lib/scroll-to-widget";
import { useDashboardStore } from "@/stores/dashboard-store";
import { useWidgetTemplates } from "@/hooks/use-widget-templates";
import { DashboardContainer } from "@/components/dashboard-container";
Expand Down Expand Up @@ -148,12 +150,20 @@ export default function DashboardEditorPage({
requestNavigation,
} = useUnsavedChangesWarning();

const parameterSourceMap = useMemo(
() => buildParameterSourceMap(layout),
[layout],
);

const handleNavigateToPage = useCallback(
(pageId: string) => {
(pageId: string, scrollToWidgetId?: string) => {
const index = layout.pages.findIndex((p) => p.id === pageId);
if (index >= 0) {
markVisited(index);
setActivePage(index);
if (scrollToWidgetId) {
scrollToWidgetWhenReady(scrollToWidgetId);
}
}
},
[layout.pages, setActivePage],
Expand Down Expand Up @@ -552,6 +562,7 @@ export default function DashboardEditorPage({
onSyncWidget={handleSyncWidget}
onDetachWidget={handleDetachWidget}
showParameterBar={showParameterBar}
parameterSourceMap={parameterSourceMap}
/>
</div>
);
Expand Down
16 changes: 13 additions & 3 deletions app/src/app/(dashboard)/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { ArrowLeft, Filter, Pencil, LayoutDashboard, RefreshCw } from "lucide-re
import { useDashboard, useUpdateDashboard } from "@/hooks/use-dashboards";
import { useParameterStore } from "@/stores/parameter-store";
import { filterParentParams } from "@/lib/format-parameter-value";
import { buildParameterSourceMap } from "@/lib/collect-parameter-names";
import { scrollToWidgetWhenReady } from "@/lib/scroll-to-widget";
import { DashboardContainer } from "@/components/dashboard-container";
import { PageTabs } from "@/components/page-tabs";
import { migrateLayout } from "@/lib/migrate-layout";
Expand Down Expand Up @@ -164,16 +166,24 @@ export default function DashboardViewerPage({
? `${intervalLabel} · ${formatCountdown(countdown)}`
: intervalLabel;

const parameterSourceMap = useMemo(
() => (layout ? buildParameterSourceMap(layout) : {}),
[layout],
);

const handleNavigateToPage = useCallback(
(pageId: string) => {
(pageId: string, scrollToWidgetId?: string) => {
if (!layout) return;
const index = layout.pages.findIndex((p) => p.id === pageId);
if (index >= 0) {
markVisited(index);
setActivePageIndex(index);
if (scrollToWidgetId) {
scrollToWidgetWhenReady(scrollToWidgetId);
}
}
},
[layout]
[layout],
);

if (isLoading) {
Expand Down Expand Up @@ -350,7 +360,7 @@ export default function DashboardViewerPage({
className={isActive ? undefined : "hidden"}
aria-hidden={!isActive}
>
<DashboardContainer page={page} refetchInterval={refetchInterval} onNavigateToPage={handleNavigateToPage} showParameterBar={showParameterBar} />
<DashboardContainer page={page} refetchInterval={refetchInterval} onNavigateToPage={handleNavigateToPage} showParameterBar={showParameterBar} parameterSourceMap={parameterSourceMap} />
</div>
);
})}
Expand Down
10 changes: 10 additions & 0 deletions app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@
@apply bg-background text-foreground;
}
}

@keyframes widget-highlight-pulse {
0% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.5); }
50% { box-shadow: 0 0 0 4px hsl(var(--primary) / 0.3); }
100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0); }
}

.widget-highlight {
animation: widget-highlight-pulse 1.5s ease-out;
}
107 changes: 100 additions & 7 deletions app/src/components/card-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
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 { ParameterSourceMap } from "@/lib/collect-parameter-names";
import { useParameterStore, useParameterValues } from "@/stores/parameter-store";
import { resolveClickActions, deriveClickableColumns } from "@/lib/resolve-click-action";
import { scrollAndHighlight } from "@/lib/scroll-to-widget";
import { applyTransforms } from "@/lib/data-transforms";
import type { Transform } from "@/lib/data-transforms";
import React, { useMemo, useCallback, useState } from "react";
import { AlertCircle, Play } from "lucide-react";
import {
Expand All @@ -15,6 +19,9 @@
AlertDescription,
AlertTitle,
Button,
Popover,
PopoverTrigger,
PopoverContent,
} from "@neoboard/components";
import {
EmptyState,
Expand Down Expand Up @@ -46,10 +53,12 @@
onWidgetSettingsChange?: (settings: Record<string, unknown>) => void;
/** TanStack Query refetchInterval — periodically re-executes the widget query. */
refetchInterval?: number | false;
/** Called when a click action navigates to a different page. */
onNavigateToPage?: (pageId: string) => void;
/** Called when a click action navigates to a different page. Optionally scrolls to a widget. */
onNavigateToPage?: (pageId: string, scrollToWidgetId?: string) => void;
/** When true, graph widgets trigger a fit-to-viewport after mount. */
autoFit?: boolean;
/** Maps parameter names to the widgets that set them (for clickable badges). */
parameterSourceMap?: ParameterSourceMap;
}

/**
Expand All @@ -64,6 +73,74 @@
return Object.keys(first);
}

/**
* Renders a parameter badge in the "Waiting for parameters" section.
* When source widgets exist, shows a clickable badge with a popover listing
* which widgets set that parameter and enabling navigation to them.
*/
function MissingParamBadge({
name,
parameterSourceMap,
onNavigateToPage,
}: {
name: string;
parameterSourceMap?: ParameterSourceMap;
onNavigateToPage?: (pageId: string, scrollToWidgetId?: string) => void;
}) {

Check warning on line 89 in app/src/components/card-container.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0fycxuTb56BCVJQFN7&open=AZ0fycxuTb56BCVJQFN7&pullRequest=188
const sources = parameterSourceMap?.[name];

if (!sources || sources.length === 0) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-foreground">
$param_{name}
</code>
);
}

function handleNavigateToWidget(pageId: string, widgetId: string) {
// Try same-page scroll first
if (scrollAndHighlight(widgetId)) return;
// Cross-page navigation
onNavigateToPage?.(pageId, widgetId);
}

return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-accent cursor-pointer transition-colors"
>
$param_{name}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="center">
<p className="text-xs font-medium text-muted-foreground mb-2">
Set by {sources.length} widget{sources.length !== 1 ? "s" : ""}

Check warning on line 119 in app/src/components/card-container.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0fycxuTb56BCVJQFN8&open=AZ0fycxuTb56BCVJQFN8&pullRequest=188
</p>
<ul className="space-y-1">
{sources.map((source) => (
<li key={`${source.pageId}-${source.widgetId}`}>
<button
type="button"
className="w-full text-left rounded px-2 py-1.5 text-sm hover:bg-accent transition-colors"
onClick={() =>
handleNavigateToWidget(source.pageId, source.widgetId)
}
>
<span className="font-medium">{source.widgetTitle}</span>
<span className="text-muted-foreground text-xs ml-1">
({source.pageTitle})
</span>
</button>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

/**
* CardContainer: Fetches query results and renders the appropriate chart.
* Uses React Query caching so queries are deduplicated across view->edit navigation.
Expand All @@ -81,6 +158,7 @@
refetchInterval,
onNavigateToPage,
autoFit,
parameterSourceMap,
}: CardContainerProps) {
const chartConfig = getChartConfig(widget.chartType);

Expand Down Expand Up @@ -117,6 +195,12 @@
[widget.settings?.chartOptions],
);

// Client-side transforms pipeline (applied post-query, pre-render)
const dataTransforms = useMemo(
() => (widget.settings?.transforms ?? []) as Transform[],
[widget.settings?.transforms],
);

const { staleTime, gcTime } = useMemo(
() => resolveCacheOptions(chartOptions, enableCache, cacheTtlMinutes),
[chartOptions, enableCache, cacheTtlMinutes],
Expand Down Expand Up @@ -208,7 +292,10 @@
/>
);
}
const transformedData = chartConfig.transformWithMapping(previewData, columnMapping);
const mappedData = chartConfig.transformWithMapping(previewData, columnMapping);
const transformedData = dataTransforms.length
? applyTransforms(mappedData as Record<string, unknown>[], dataTransforms)
: mappedData;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const availableColumns = extractColumnNames(previewData);
return (
<div className="h-full w-full flex flex-col">
Expand Down Expand Up @@ -338,9 +425,12 @@
{missingParams.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{missingParams.map((name) => (
<code key={name} className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-foreground">
$param_{name}
</code>
<MissingParamBadge
key={name}
name={name}
parameterSourceMap={parameterSourceMap}
onNavigateToPage={onNavigateToPage}
/>
))}
</div>
)}
Expand Down Expand Up @@ -399,7 +489,10 @@
);
}

const transformedData = chartConfig.transformWithMapping(rawData, columnMapping);
const mappedData = chartConfig.transformWithMapping(rawData, columnMapping);
const transformedData = dataTransforms.length
? applyTransforms(mappedData as Record<string, unknown>[], dataTransforms)
: mappedData;
const availableColumns = extractColumnNames(rawData);

return (
Expand Down
35 changes: 32 additions & 3 deletions app/src/components/dashboard-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import { useQueryClient } from "@tanstack/react-query";
import { CardContainer } from "./card-container";
import { getChartConfig } from "@/lib/chart-registry";
import { buildCsvString, triggerDownload, buildExportFilename } from "@neoboard/components";

Check warning on line 7 in app/src/components/dashboard-container.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'@neoboard/components' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0fycw5Tb56BCVJQFN5&open=AZ0fycw5Tb56BCVJQFN5&pullRequest=188
import { interpolateTitle } from "@/lib/interpolate-title";
import type {
DashboardPage,
DashboardWidget,
GridLayoutItem,
WidgetTemplate,
} from "@/lib/db/schema";
import type { ParameterSourceMap } from "@/lib/collect-parameter-names";
import { useParameterStore } from "@/stores/parameter-store";
import {
formatParameterValue,
Expand All @@ -35,7 +37,7 @@
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@neoboard/components";

Check warning on line 40 in app/src/components/dashboard-container.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'@neoboard/components' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0fycw5Tb56BCVJQFN6&open=AZ0fycw5Tb56BCVJQFN6&pullRequest=188

interface DashboardContainerProps {
/** The active page to render. */
Expand All @@ -55,8 +57,8 @@
) => 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 a click action navigates to a different page. Optionally scrolls to a widget. */
onNavigateToPage?: (pageId: string, scrollToWidgetId?: 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. */
Expand All @@ -67,6 +69,8 @@
onDetachWidget?: (widgetId: string) => void;
/** When false, the parameter bar is hidden. Defaults to true. */
showParameterBar?: boolean;
/** Maps parameter names to the widgets that set them (for clickable badges). */
parameterSourceMap?: ParameterSourceMap;
}

function getWidgetTitle(widget: DashboardWidget): string {
Expand All @@ -91,6 +95,7 @@
onSyncWidget,
onDetachWidget,
showParameterBar = true,
parameterSourceMap,
}: DashboardContainerProps) {
const queryClient = useQueryClient();
const [fullscreenWidget, setFullscreenWidget] =
Expand Down Expand Up @@ -144,9 +149,31 @@
return new Date(tmpl.updatedAt) > new Date(widget.templateSyncedAt);
}

function exportWidgetCsv(widget: DashboardWidget) {
const cached = queryClient.getQueryData<{ data: unknown }>([
"widget-query",
widget.connectionId,
widget.query,
widget.params,
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the same query key shape as useWidgetQuery().

Rendered widgets are cached under ["widget-query", connectionId, query, mergedParams], where mergedParams includes the live $param_* values referenced by the query. Looking up [... , widget.params] here misses parameterized widgets, so “Export CSV” becomes a silent no-op for exactly the dashboards that rely on parameters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/dashboard-container.tsx` around lines 152 - 158, The
exportWidgetCsv function is using queryClient.getQueryData with widget.params,
which misses live $param_* overrides; change exportWidgetCsv to build the same
query key shape useWidgetQuery uses by computing mergedParams (merge
widget.params with current live parameter values used for rendering) and then
call queryClient.getQueryData([{ "widget-query", widget.connectionId,
widget.query, mergedParams }]) so parameterized widgets resolve correctly;
reference exportWidgetCsv, useWidgetQuery, queryClient.getQueryData,
widget.params and mergedParams when making the change.

const rawData = cached?.data;
if (!Array.isArray(rawData) || rawData.length === 0) return;
const csv = buildCsvString(rawData as Record<string, unknown>[]);
const title = (widget.settings?.title as string) || widget.chartType;
const filename = buildExportFilename(title, "csv", page.title);
Comment on lines +177 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prefix exports with the dashboard name, not the page title.

The requirement for this feature is dashboard name + widget title, but this passes page.title into buildExportFilename(). On multi-page dashboards the exported filename is wrong, and page names also collide much more easily across dashboards.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/dashboard-container.tsx` around lines 162 - 163, The
filename is built with page.title but should use the dashboard name; update the
export filename construction so buildExportFilename receives the dashboard's
title/name instead of page.title (locate the variables around title, filename,
and buildExportFilename in dashboard-container.tsx), e.g. pass dashboard.title
(or dashboard.name depending on your model) and ensure you still fall back to a
sensible value if dashboard is undefined.

triggerDownload(csv, filename);
}

const buildActions = (widget: DashboardWidget) => {
if (!editable) return undefined;
const actions = [];

// Export CSV — available for data-producing widgets in both edit and view mode
const isDataWidget = !["markdown", "iframe", "form", "parameter-select"].includes(widget.chartType);
if (isDataWidget) {
actions.push({ label: "Export CSV", onClick: () => exportWidgetCsv(widget) });
}

if (!editable) return actions.length > 0 ? actions : undefined;
if (onEditWidget) {
actions.push({
label: "Edit",
Expand Down Expand Up @@ -290,6 +317,7 @@
}
refetchInterval={refetchInterval}
onNavigateToPage={onNavigateToPage}
parameterSourceMap={parameterSourceMap}
/>
</WidgetCard>
</div>
Expand Down Expand Up @@ -317,6 +345,7 @@
widget={fullscreenWidget}
refetchInterval={refetchInterval}
onNavigateToPage={onNavigateToPage}
parameterSourceMap={parameterSourceMap}
autoFit
/>
) : (
Expand Down
Loading
Loading