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 \