diff --git a/src/components/PivotTable.tsx b/src/components/PivotTable.tsx
index e10f0c6a9..cd75d6714 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,29 @@ 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 +265,24 @@ 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..a7b4a24c5
--- /dev/null
+++ b/src/lib/export/pivot-table.ts
@@ -0,0 +1,15 @@
+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");
+}
diff --git a/tests/components/PivotTable.test.tsx b/tests/components/PivotTable.test.tsx
index a06ebc4a8..2169a650c 100644
--- a/tests/components/PivotTable.test.tsx
+++ b/tests/components/PivotTable.test.tsx
@@ -5,6 +5,23 @@ 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 +40,62 @@ 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("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", () => {
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 \