Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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.
23 changes: 18 additions & 5 deletions 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 @@ -309,11 +319,13 @@ export default function DashboardEditorPage({
>();

function openEditWidget(widget: DashboardWidget) {
// Grab cached query data so the editor preview shows instantly
const cached = queryClient.getQueryData<{
// Grab cached query data so the editor preview shows instantly.
// Use getQueriesData with partial key — params vary with parameter store values.
const cachedEntries = queryClient.getQueriesData<{
data: unknown;
resultId: string;
}>(["widget-query", widget.connectionId, widget.query, undefined]);
}>({ queryKey: ["widget-query", widget.connectionId, widget.query] });
const cached = cachedEntries.length > 0 ? cachedEntries[0][1] : undefined;
setCachedPreviewData(cached ?? undefined);
setEditorMode("edit");
setEditingWidget(widget);
Expand All @@ -334,7 +346,7 @@ export default function DashboardEditorPage({
updateWidget(widget.id, widget);
}
queryClient.invalidateQueries({
queryKey: ["widget-query", widget.id],
queryKey: ["widget-query", widget.connectionId, widget.query],
});
}

Expand Down Expand Up @@ -566,6 +578,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;
}
142 changes: 116 additions & 26 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";

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'ChartType'.

See more on https://sonarcloud.io/project/issues?id=alfredo1996_neoboard&issues=AZ0wLugcBGG9nGF3U4ZW&open=AZ0wLugcBGG9nGF3U4ZW&pullRequest=188
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 @@
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 @@
AlertDescription,
AlertTitle,
Button,
Popover,
PopoverTrigger,
PopoverContent,
} from "@neoboard/components";
import {
EmptyState,
Expand Down Expand Up @@ -60,10 +67,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 @@ -78,6 +87,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 103 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 133 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 @@ -95,18 +172,20 @@
refetchInterval,
onNavigateToPage,
autoFit,
parameterSourceMap,
}: CardContainerProps) {
const chartConfig = getChartConfig(widget.chartType);

function handleChartClick(point: Record<string, unknown>) {
const result = resolveClickActions(widget, point);
if (!result) return;
const setParameter = useParameterStore((s) => s.setParameter);
const handleChartClick = useCallback(
(point: Record<string, unknown>) => {
const result = resolveClickActions(widget, point);
if (!result) return;

if (result.setParameter) {
const { parameterName, value, label, sourceField } = result.setParameter;
useParameterStore
.getState()
.setParameter(
if (result.setParameter) {
const { parameterName, value, label, sourceField } =
result.setParameter;
setParameter(
parameterName,
value,
label,
Expand All @@ -115,12 +194,14 @@
"click-action",
widget.id,
);
}
}

if (result.navigateToPageId) {
onNavigateToPage?.(result.navigateToPageId);
}
}
if (result.navigateToPageId) {
onNavigateToPage?.(result.navigateToPageId);
}
},
[widget, setParameter, onNavigateToPage],
);
const ws = widget.settings ?? {};
const clickAction = ws.clickAction as ClickAction | undefined;
const hasClickAction = !!clickAction;
Expand All @@ -141,6 +222,12 @@
[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 +332,13 @@
/>
);
}
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 +482,12 @@
{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 +549,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
Loading
Loading