From 40076c6430f616464fc90a001050020a1d7f118d Mon Sep 17 00:00:00 2001 From: Swarnabha Nandi Date: Fri, 11 Sep 2026 13:33:57 +0530 Subject: [PATCH 1/4] feat: add pivot table export --- src/components/PivotTable.tsx | 60 +++++++++++++++++++++- src/lib/export/pivot-table.ts | 17 +++++++ tests/components/PivotTable.test.tsx | 76 +++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/lib/export/pivot-table.ts diff --git a/src/components/PivotTable.tsx b/src/components/PivotTable.tsx index e10f0c6a9..40bd765f6 100644 --- a/src/components/PivotTable.tsx +++ b/src/components/PivotTable.tsx @@ -1,11 +1,19 @@ "use client"; import React, { useState, useMemo, useCallback, useEffect } from "react"; -import { Columns3, GripVertical, ArrowRight } from "lucide-react"; +import { Columns3, GripVertical, ArrowRight, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { DatabaseType, QueryResult } from "@/lib/types"; import { quoteLiteral } from "@/lib/sql/values"; import { quoteIdentifier } from "@/lib/sql/identifier"; +import { downloadText } from "@/lib/export/download"; +import { pivotTableText } from "@/lib/export/pivot-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface PivotTableProps { result: QueryResult | null; @@ -107,6 +115,34 @@ export function PivotTable({ result, onLoadQuery, databaseType }: PivotTableProp return { colKeys, pivotRows }; }, [rows, rowField, colField, valueField, aggFunction]); + const exportPivot = useCallback( + (format: "csv" | "json") => { + if (!pivotData || !rowField) return; + + const headers = [ + rowField, + ...pivotData.colKeys.map((ck) => + ck === "__all__" + ? `${AGG_LABELS[aggFunction]}(${valueField || "*"})` + : ck, + ), + ]; + + const rows = pivotData.pivotRows.map((row) => [ + row.rowKey, + ...pivotData.colKeys.map((ck) => row.values.get(ck) || "0"), + ]); + + downloadText( + pivotTableText(headers, rows, format), + format === "csv" ? "text/csv" : "application/json", + `pivot_table_${Date.now()}.${format}`, + ); + }, + [pivotData, rowField, valueField, aggFunction], + ); + + // Generate SQL const generateSQL = useCallback(() => { if (!rowField) return ""; @@ -234,6 +270,28 @@ export function PivotTable({ result, onLoadQuery, databaseType }: PivotTableProp Generate SQL )} + {pivotData && ( + + + + + + + exportPivot("csv")}> + Export as CSV + + exportPivot("json")}> + Export as JSON + + + + )}
diff --git a/src/lib/export/pivot-table.ts b/src/lib/export/pivot-table.ts new file mode 100644 index 000000000..1c3739ab9 --- /dev/null +++ b/src/lib/export/pivot-table.ts @@ -0,0 +1,17 @@ +import { csvRow } from "./csv"; +import { jsonText } from "./json"; + +export function pivotTableText( + headers: readonly string[], + rows: readonly (readonly unknown[])[], + format: "csv" | "json", +): string { + if (format === "json") { + const records = rows.map((row) => + Object.fromEntries(headers.map((header, index) => [header, row[index]])), + ); + return jsonText(records, 2); + } + + return [csvRow(headers), ...rows.map((row) => csvRow(row))].join("\n"); +} \ No newline at end of file diff --git a/tests/components/PivotTable.test.tsx b/tests/components/PivotTable.test.tsx index a06ebc4a8..86b3c6bdb 100644 --- a/tests/components/PivotTable.test.tsx +++ b/tests/components/PivotTable.test.tsx @@ -5,6 +5,41 @@ import "../helpers/mock-navigation"; import React from "react"; import { afterEach, describe, expect, mock, test } from "bun:test"; import { cleanup, render, fireEvent } from "@testing-library/react"; + +const mockDownloadText = mock( + (_content: string, _mimeType: string, _fileName: string) => { }, +); +mock.module("@/lib/export/download", () => ({ + downloadText: mockDownloadText, +})); + +mock.module("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ + children, + }: { + children: React.ReactNode; + }) =>
{children}
, + DropdownMenuContent: ({ + children, + }: { + children: React.ReactNode; + }) =>
{children}
, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), +})); + import { PivotTable, aggregate } from "@/components/PivotTable"; import type { QueryResult } from "@/lib/types"; @@ -23,6 +58,45 @@ const result: QueryResult = { describe("PivotTable", () => { afterEach(() => { cleanup(); + mockDownloadText.mockClear(); + }); + + test("exports the configured pivot table as CSV", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Export as CSV")); + + expect(mockDownloadText).toHaveBeenCalledTimes(1); + + const [content, mimeType, fileName] = mockDownloadText.mock.calls[0]; + + expect(mimeType).toBe("text/csv"); + expect(fileName).toMatch(/^pivot_table_\d+\.csv$/); + expect(content).toContain("dept"); + expect(content).toContain("COUNT(salary)"); + expect(content).toContain("Engineering"); + expect(content).toContain("Sales"); + expect(content).toContain("2"); + }); + + test("exports the configured pivot table as JSON", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Export as JSON")); + + expect(mockDownloadText).toHaveBeenCalledTimes(1); + + const [content, mimeType, fileName] = mockDownloadText.mock.calls[0]; + + expect(mimeType).toBe("application/json"); + expect(fileName).toMatch(/^pivot_table_\d+\.json$/); + + const exported = JSON.parse(content); + + expect(exported).toEqual([ + { dept: "Engineering", "COUNT(salary)": "2" }, + { dept: "Sales", "COUNT(salary)": "2" }, + ]); }); test("shows empty state when result is null", () => { @@ -124,7 +198,7 @@ describe("PivotTable", () => { }); test("Generate SQL button appears when onLoadQuery provided and row selected", () => { - const onLoadQuery = mock(() => {}); + const onLoadQuery = mock(() => { }); const { queryByText } = render(); expect(queryByText("Generate SQL")).not.toBeNull(); }); From 057ead32b2bd05e6505fe0e86cf8c8ab1126a90a Mon Sep 17 00:00:00 2001 From: Swarnabha Nandi Date: Fri, 11 Sep 2026 23:55:51 +0530 Subject: [PATCH 2/4] style: format pivot table export --- src/components/PivotTable.tsx | 15 +++----------- src/lib/export/pivot-table.ts | 20 +++++++++---------- tests/components/PivotTable.test.tsx | 30 ++++++---------------------- 3 files changed, 18 insertions(+), 47 deletions(-) diff --git a/src/components/PivotTable.tsx b/src/components/PivotTable.tsx index 40bd765f6..cd75d6714 100644 --- a/src/components/PivotTable.tsx +++ b/src/components/PivotTable.tsx @@ -121,11 +121,7 @@ export function PivotTable({ result, onLoadQuery, databaseType }: PivotTableProp const headers = [ rowField, - ...pivotData.colKeys.map((ck) => - ck === "__all__" - ? `${AGG_LABELS[aggFunction]}(${valueField || "*"})` - : ck, - ), + ...pivotData.colKeys.map((ck) => (ck === "__all__" ? `${AGG_LABELS[aggFunction]}(${valueField || "*"})` : ck)), ]; const rows = pivotData.pivotRows.map((row) => [ @@ -142,7 +138,6 @@ export function PivotTable({ result, onLoadQuery, databaseType }: PivotTableProp [pivotData, rowField, valueField, aggFunction], ); - // Generate SQL const generateSQL = useCallback(() => { if (!rowField) return ""; @@ -283,12 +278,8 @@ export function PivotTable({ result, onLoadQuery, databaseType }: PivotTableProp - exportPivot("csv")}> - Export as CSV - - exportPivot("json")}> - Export as JSON - + exportPivot("csv")}>Export as CSV + exportPivot("json")}>Export as JSON )} diff --git a/src/lib/export/pivot-table.ts b/src/lib/export/pivot-table.ts index 1c3739ab9..a7b4a24c5 100644 --- a/src/lib/export/pivot-table.ts +++ b/src/lib/export/pivot-table.ts @@ -2,16 +2,14 @@ import { csvRow } from "./csv"; import { jsonText } from "./json"; export function pivotTableText( - headers: readonly string[], - rows: readonly (readonly unknown[])[], - format: "csv" | "json", + headers: readonly string[], + rows: readonly (readonly unknown[])[], + format: "csv" | "json", ): string { - if (format === "json") { - const records = rows.map((row) => - Object.fromEntries(headers.map((header, index) => [header, row[index]])), - ); - return jsonText(records, 2); - } + if (format === "json") { + const records = rows.map((row) => Object.fromEntries(headers.map((header, index) => [header, row[index]]))); + return jsonText(records, 2); + } - return [csvRow(headers), ...rows.map((row) => csvRow(row))].join("\n"); -} \ No newline at end of file + return [csvRow(headers), ...rows.map((row) => csvRow(row))].join("\n"); +} diff --git a/tests/components/PivotTable.test.tsx b/tests/components/PivotTable.test.tsx index 86b3c6bdb..397570fc6 100644 --- a/tests/components/PivotTable.test.tsx +++ b/tests/components/PivotTable.test.tsx @@ -6,34 +6,16 @@ import React from "react"; import { afterEach, describe, expect, mock, test } from "bun:test"; import { cleanup, render, fireEvent } from "@testing-library/react"; -const mockDownloadText = mock( - (_content: string, _mimeType: string, _fileName: string) => { }, -); +const mockDownloadText = mock((_content: string, _mimeType: string, _fileName: string) => {}); mock.module("@/lib/export/download", () => ({ downloadText: mockDownloadText, })); mock.module("@/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DropdownMenuTrigger: ({ - children, - }: { - children: React.ReactNode; - }) =>
{children}
, - DropdownMenuContent: ({ - children, - }: { - children: React.ReactNode; - }) =>
{children}
, - DropdownMenuItem: ({ - children, - onClick, - }: { - children: React.ReactNode; - onClick?: () => void; - }) => ( + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( @@ -198,7 +180,7 @@ describe("PivotTable", () => { }); test("Generate SQL button appears when onLoadQuery provided and row selected", () => { - const onLoadQuery = mock(() => { }); + const onLoadQuery = mock(() => {}); const { queryByText } = render(); expect(queryByText("Generate SQL")).not.toBeNull(); }); From 7c2732fac715e1fe5396f990a8fa3864d990aab5 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 14 Sep 2026 16:09:36 +0300 Subject: [PATCH 3/4] test(pivot-table): run the pivot table suite in its own component group Its mock of @/lib/export/download is process-wide, so in the shared smoke group it replaced the real download for every file bun ran after it. Whenever PivotTable ran before DatabaseDocs, the two Export MD tests saw no URL.createObjectURL call and failed. --- tests/run-components.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/run-components.sh b/tests/run-components.sh index ec7216889..bebedfcaf 100755 --- a/tests/run-components.sh +++ b/tests/run-components.sh @@ -33,7 +33,7 @@ FAIL=0 # stale a fourth time by naming a DIGIT for the current value, which is the one thing # here that cannot stay true: the value is whatever that grep prints, never a number # written in prose. -TOTAL_GROUPS=44 +TOTAL_GROUPS=45 EXTRA_BUN_ARGS=("$@") GROUP_INDEX=0 COVERAGE_MODE=0 @@ -243,6 +243,11 @@ run_group "Group 9/12: StudioHeaders & TableItem" \ run_group "Group 10/12: PoolTab" \ tests/components/monitoring/PoolTab.test.tsx +# Group 10b: PivotTable (isolated - mocks @/lib/export/download and dropdown-menu, which +# the DatabaseDocs export tests in the smoke group need real) +run_group "Group 10b: PivotTable" \ + tests/components/PivotTable.test.tsx + # Group 11: Smoke tests (isolated - mock globalThis.fetch + MonitoringEmbed) run_group "Group 11/12: Smoke tests" \ tests/components/agent/AgentRail.test.tsx \ @@ -254,7 +259,6 @@ run_group "Group 11/12: Smoke tests" \ tests/components/VisualExplain.test.tsx \ tests/components/DatabaseDocs.test.tsx \ tests/components/SnapshotTimeline.test.tsx \ - tests/components/PivotTable.test.tsx \ tests/components/CodeGenerator.test.tsx \ tests/components/TestDataGenerator.test.tsx \ tests/components/CreateTableModal.test.tsx \ From 401b9c7742c5827529d63def5880a72d1963cc09 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 14 Sep 2026 16:10:14 +0300 Subject: [PATCH 4/4] test(pivot-table): pin the export to the configured column field and aggregation The existing cases export the default layout, where the only column is the aggregate, so an export that ignored the column field or wrote a malformed CSV still passed. This case sets a column field and SUM and compares the whole CSV and JSON output. --- tests/components/PivotTable.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/components/PivotTable.test.tsx b/tests/components/PivotTable.test.tsx index 397570fc6..2169a650c 100644 --- a/tests/components/PivotTable.test.tsx +++ b/tests/components/PivotTable.test.tsx @@ -81,6 +81,23 @@ describe("PivotTable", () => { ]); }); + test("exports the column field and aggregation the screen shows, not the defaults", () => { + const { container, getByText } = render(); + fireEvent.change(container.querySelectorAll("select")[1]!, { target: { value: "status" } }); + fireEvent.click(getByText("SUM")); + + fireEvent.click(getByText("Export as CSV")); + fireEvent.click(getByText("Export as JSON")); + + expect(mockDownloadText.mock.calls[0][0]).toBe( + ["dept,active,inactive", "Engineering,90000.00,85000.00", "Sales,145000.00,0"].join("\n"), + ); + expect(JSON.parse(mockDownloadText.mock.calls[1][0])).toEqual([ + { dept: "Engineering", active: "90000.00", inactive: "85000.00" }, + { dept: "Sales", active: "145000.00", inactive: "0" }, + ]); + }); + test("shows empty state when result is null", () => { const { queryByText } = render(); expect(queryByText("Pivot Table")).not.toBeNull();