Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
Binary file modified app/.screenshots/form-widget-403-write-permission.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 @@ -566,6 +576,7 @@ export default function DashboardEditorPage({
}}
templateMap={templateMap}
showParameterBar={showParameterBar}
parameterSourceMap={parameterSourceMap}
/>
</div>
);
Expand Down
13 changes: 12 additions & 1 deletion app/src/app/(dashboard)/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
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 @@ -192,13 +194,21 @@ 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],
Expand Down Expand Up @@ -412,6 +422,7 @@ export default function DashboardViewerPage({
refetchInterval={refetchInterval}
actions={{ 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;
}
113 changes: 100 additions & 13 deletions app/src/components/card-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import { useWidgetQuery } from "@/hooks/use-widget-query";
import { resolveCacheOptions } from "@/lib/resolve-cache-options";
import { getChartConfig } from "@/lib/chart-registry";
import type { ColumnMapping } 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 type { ColorScaleConfig } from "@neoboard/components";
import {
useParameterStore,
Expand All @@ -18,6 +19,9 @@ 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 @@ -26,6 +30,9 @@ import {
AlertDescription,
AlertTitle,
Button,
Popover,
PopoverTrigger,
PopoverContent,
} from "@neoboard/components";
import {
EmptyState,
Expand Down Expand Up @@ -60,10 +67,12 @@ interface CardContainerProps {
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 @@ -78,6 +87,74 @@ function extractColumnNames(data: unknown): string[] {
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;
}) {
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" : ""}
</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 @@ -95,6 +172,7 @@ export function CardContainer({
refetchInterval,
onNavigateToPage,
autoFit,
parameterSourceMap,
}: CardContainerProps) {
const chartConfig = getChartConfig(widget.chartType);

Expand Down Expand Up @@ -141,6 +219,12 @@ export function CardContainer({
[ws.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 @@ -245,10 +329,13 @@ export function CardContainer({
/>
);
}
const transformedData = chartConfig.transformWithMapping(
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 @@ -392,12 +479,12 @@ export function CardContainer({
{missingParams.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{missingParams.map((name) => (
<code
<MissingParamBadge
key={name}
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-foreground"
>
$param_{name}
</code>
name={name}
parameterSourceMap={parameterSourceMap}
onNavigateToPage={onNavigateToPage}
/>
))}
</div>
)}
Expand Down Expand Up @@ -459,10 +546,10 @@ export function CardContainer({
);
}

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
47 changes: 45 additions & 2 deletions app/src/components/dashboard-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ import { useState, useMemo, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CardContainer } from "./card-container";
import { getChartConfig } from "@/lib/chart-registry";
import {
buildCsvString,
triggerDownload,
buildExportFilename,
} from "@neoboard/components";
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 Down Expand Up @@ -47,7 +53,9 @@ export interface WidgetActions {
widgetId: string,
settings: Record<string, unknown>,
) => void;
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;
onSyncWidget?: (widget: DashboardWidget) => void;
onDetachWidget?: (widgetId: string) => void;
Expand All @@ -60,6 +68,8 @@ interface DashboardContainerProps {
refetchInterval?: number | false;
templateMap?: Record<string, WidgetTemplate>;
showParameterBar?: boolean;
/** Maps parameter names to the widgets that set them (for clickable badges). */
parameterSourceMap?: ParameterSourceMap;
}

function getWidgetTitle(widget: DashboardWidget): string {
Expand All @@ -75,6 +85,7 @@ export function DashboardContainer({
refetchInterval,
templateMap,
showParameterBar = true,
parameterSourceMap,
}: DashboardContainerProps) {
const {
onRemoveWidget,
Expand Down Expand Up @@ -139,9 +150,39 @@ export function DashboardContainer({
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 @@ -285,6 +326,7 @@ export function DashboardContainer({
}
refetchInterval={refetchInterval}
onNavigateToPage={onNavigateToPage}
parameterSourceMap={parameterSourceMap}
/>
</WidgetCard>
</div>
Expand Down Expand Up @@ -312,6 +354,7 @@ export function DashboardContainer({
widget={fullscreenWidget}
refetchInterval={refetchInterval}
onNavigateToPage={onNavigateToPage}
parameterSourceMap={parameterSourceMap}
autoFit
/>
) : (
Expand Down
Loading
Loading