From 62bd3f715eea04d0d6abf551805767fb25928b98 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 22 Mar 2026 18:43:58 +0100 Subject: [PATCH 01/23] feat(app): add CSV export to widget cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add buildCsvString() and triggerDownload() utilities to component library. Wire CSV export into dashboard-container buildActions() — reads cached query data from TanStack Query and triggers browser download. Available for all data-producing widgets in both edit and view mode. Closes #135 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/dashboard-container.tsx | 25 ++++++++- .../src/lib/__tests__/export-utils.test.ts | 56 +++++++++++++++++++ component/src/lib/export-utils.ts | 52 +++++++++++++++++ component/src/utils/index.ts | 1 + 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 component/src/lib/__tests__/export-utils.test.ts create mode 100644 component/src/lib/export-utils.ts diff --git a/app/src/components/dashboard-container.tsx b/app/src/components/dashboard-container.tsx index 05fd68b2..c3f4fda2 100644 --- a/app/src/components/dashboard-container.tsx +++ b/app/src/components/dashboard-container.tsx @@ -4,6 +4,7 @@ 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 } from "@neoboard/components"; import { interpolateTitle } from "@/lib/interpolate-title"; import type { DashboardPage, @@ -144,9 +145,31 @@ 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, + ]); + const rawData = cached?.data; + if (!Array.isArray(rawData) || rawData.length === 0) return; + const csv = buildCsvString(rawData as Record[]); + const title = (widget.settings?.title as string) || widget.chartType; + const slug = title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, ""); + triggerDownload(csv, `${slug}.csv`); + } + 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", diff --git a/component/src/lib/__tests__/export-utils.test.ts b/component/src/lib/__tests__/export-utils.test.ts new file mode 100644 index 00000000..2b7bff43 --- /dev/null +++ b/component/src/lib/__tests__/export-utils.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { buildCsvString, triggerDownload } from "../export-utils"; + +describe("buildCsvString", () => { + it("returns empty string for empty data", () => { + expect(buildCsvString([])).toBe(""); + }); + + it("builds CSV with headers from first row keys", () => { + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]; + const csv = buildCsvString(data); + const lines = csv.split("\n"); + expect(lines[0]).toBe("name,age"); + expect(lines[1]).toBe("Alice,30"); + expect(lines[2]).toBe("Bob,25"); + }); + + it("escapes values containing commas", () => { + const data = [{ city: "New York, NY", pop: 8000000 }]; + const csv = buildCsvString(data); + expect(csv).toContain('"New York, NY"'); + }); + + it("escapes values containing double quotes", () => { + const data = [{ note: 'He said "hello"' }]; + const csv = buildCsvString(data); + expect(csv).toContain('"He said ""hello"""'); + }); + + it("escapes values containing newlines", () => { + const data = [{ text: "line1\nline2" }]; + const csv = buildCsvString(data); + expect(csv).toContain('"line1\nline2"'); + }); + + it("handles null and undefined values", () => { + const data = [{ a: null, b: undefined, c: 1 }]; + const csv = buildCsvString(data); + expect(csv).toBe("a,b,c\n,,1"); + }); + + it("handles nested objects by JSON-stringifying them", () => { + const data = [{ id: 1, props: { x: 10 } }]; + const csv = buildCsvString(data); + expect(csv).toContain('"{""x"":10}"'); + }); +}); + +describe("triggerDownload", () => { + it("is a function", () => { + expect(typeof triggerDownload).toBe("function"); + }); +}); diff --git a/component/src/lib/export-utils.ts b/component/src/lib/export-utils.ts new file mode 100644 index 00000000..2a6c6cee --- /dev/null +++ b/component/src/lib/export-utils.ts @@ -0,0 +1,52 @@ +/** + * Escape a CSV cell value per RFC 4180. + * Wraps in quotes if the value contains comma, double-quote, or newline. + */ +function escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = typeof value === "object" ? JSON.stringify(value) : String(value); + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Build a CSV string from an array of flat record objects. + * Headers are derived from the keys of the first row. + */ +export function buildCsvString(data: Record[]): string { + if (!data.length) return ""; + const headers = Object.keys(data[0]); + const headerLine = headers.join(","); + const rows = data.map((row) => headers.map((h) => escapeCsvCell(row[h])).join(",")); + return [headerLine, ...rows].join("\n"); +} + +/** + * Trigger a browser file download from a string or data URL. + * Works by creating a temporary anchor element. + */ +export function triggerDownload(content: string, filename: string, mimeType = "text/csv"): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Trigger a PNG download from a data URL (e.g. from ECharts getDataURL). + */ +export function triggerPngDownload(dataUrl: string, filename: string): void { + const a = document.createElement("a"); + a.href = dataUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/component/src/utils/index.ts b/component/src/utils/index.ts index ba5f0f54..1591492b 100644 --- a/component/src/utils/index.ts +++ b/component/src/utils/index.ts @@ -1,3 +1,4 @@ // Utility functions export { cn } from "../lib/utils"; export { substituteParams } from "../lib/param-substitute"; +export { buildCsvString, triggerDownload, triggerPngDownload } from "../lib/export-utils"; From a432924e5629f5783bb80500e8da34c986bfe78d Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 22 Mar 2026 19:16:28 +0100 Subject: [PATCH 02/23] feat(component): add GFM table support to markdown widget Parse pipe-delimited GFM tables (header + alignment row + body rows) and render as styled HTML tables. Cell content is escaped for XSS safety. - Table detection in the markdown parser for-loop - Styled with design tokens (border-border, bg-muted/30) - 5 new tests including XSS escaping Closes #143 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/markdown-tables.test.tsx | 45 +++++++++++++++++++ .../components/composed/markdown-widget.tsx | 33 ++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 component/src/components/composed/__tests__/markdown-tables.test.tsx diff --git a/component/src/components/composed/__tests__/markdown-tables.test.tsx b/component/src/components/composed/__tests__/markdown-tables.test.tsx new file mode 100644 index 00000000..dde64d18 --- /dev/null +++ b/component/src/components/composed/__tests__/markdown-tables.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MarkdownWidget } from "../markdown-widget"; + +describe("MarkdownWidget — GFM tables", () => { + it("renders a simple table", () => { + const md = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |"; + render(); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("25")).toBeInTheDocument(); + }); + + it("renders header cells in ", () => { + const md = "| Col1 | Col2 |\n| --- | --- |\n| a | b |"; + const { container } = render(); + const ths = container.querySelectorAll("th"); + expect(ths).toHaveLength(2); + expect(ths[0].textContent).toBe("Col1"); + }); + + it("renders body cells in ", () => { + const md = "| Col1 |\n| --- |\n| value |"; + const { container } = render(); + const tds = container.querySelectorAll("td"); + expect(tds).toHaveLength(1); + expect(tds[0].textContent).toBe("value"); + }); + + it("escapes HTML in cell content", () => { + const md = "| Header |\n| --- |\n| |"; + const { container } = render(); + const td = container.querySelector("td"); + expect(td?.innerHTML).not.toContain("