Export/copy as CSV and JSON

This commit is contained in:
Matt Aitken
2025-12-19 13:36:31 +00:00
parent 3ef5fa543c
commit d632da4ac3
2 changed files with 153 additions and 8 deletions
@@ -1,5 +1,6 @@
import { LightBulbIcon } from "@heroicons/react/20/solid";
import { ColumnSchema } from "@internal/tsql";
import { ArrowDownTrayIcon, ClipboardIcon, LightBulbIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ColumnSchema } from "@internal/tsql";
import { Form, useNavigation } from "@remix-run/react";
import {
type ActionFunctionArgs,
@@ -21,6 +22,12 @@ import { CopyableText } from "~/components/primitives/CopyableText";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
} from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
@@ -36,6 +43,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { executeQuery } from "~/services/queryService.server";
import { requireUser } from "~/services/session.server";
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { defaultQuery, querySchemas } from "~/v3/querySchemas";
@@ -278,12 +286,17 @@ export default function Page() {
</span>
)}
</div>
<Switch
variant="small"
label="Pretty formatting"
checked={prettyFormatting}
onCheckedChange={setPrettyFormatting}
/>
<div className="flex items-center gap-3">
{results?.rows && results?.columns && results.rows.length > 0 && (
<ExportResultsButton rows={results.rows} columns={results.columns} />
)}
<Switch
variant="small"
label="Pretty formatting"
checked={prettyFormatting}
onCheckedChange={setPrettyFormatting}
/>
</div>
</div>
{isLoading ? (
<div className="flex items-center gap-2 p-4 text-text-dimmed">
@@ -328,6 +341,60 @@ export default function Page() {
);
}
function ExportResultsButton({
rows,
columns,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
}) {
const [isOpen, setIsOpen] = useState(false);
const handleCopyCSV = () => {
const csv = rowsToCSV(rows, columns);
navigator.clipboard.writeText(csv);
setIsOpen(false);
};
const handleExportCSV = () => {
const csv = rowsToCSV(rows, columns);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
downloadFile(csv, `query-results-${timestamp}.csv`, "text/csv");
setIsOpen(false);
};
const handleCopyJSON = () => {
const json = rowsToJSON(rows);
navigator.clipboard.writeText(json);
setIsOpen(false);
};
const handleExportJSON = () => {
const json = rowsToJSON(rows);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
downloadFile(json, `query-results-${timestamp}.json`, "application/json");
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverArrowTrigger isOpen={isOpen}>Export</PopoverArrowTrigger>
<PopoverContent className="min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<PopoverMenuItem icon={ClipboardIcon} title="Copy CSV" onClick={handleCopyCSV} />
<PopoverMenuItem icon={ArrowDownTrayIcon} title="Export CSV" onClick={handleExportCSV} />
<PopoverMenuItem icon={ClipboardIcon} title="Copy JSON" onClick={handleCopyJSON} />
<PopoverMenuItem
icon={ArrowDownTrayIcon}
title="Export JSON"
onClick={handleExportJSON}
/>
</div>
</PopoverContent>
</Popover>
);
}
function QueryHelpSidebar({ onClose }: { onClose: () => void }) {
return (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
+78
View File
@@ -0,0 +1,78 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
/**
* Escape a value for CSV format.
* - Wraps in quotes if the value contains commas, quotes, or newlines
* - Escapes quotes by doubling them
*/
function escapeCSVValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value);
// Check if we need to quote the value
if (
stringValue.includes(",") ||
stringValue.includes('"') ||
stringValue.includes("\n") ||
stringValue.includes("\r")
) {
// Escape quotes by doubling them and wrap in quotes
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
/**
* Convert query result rows to CSV format.
*
* @param rows - Array of row objects from query results
* @param columns - Column metadata describing the result columns
* @returns CSV string with header row and data rows
*/
export function rowsToCSV(rows: Record<string, unknown>[], columns: OutputColumnMetadata[]): string {
if (columns.length === 0) {
return "";
}
const columnNames = columns.map((col) => col.name);
// Header row
const headerRow = columnNames.map(escapeCSVValue).join(",");
// Data rows
const dataRows = rows.map((row) => columnNames.map((name) => escapeCSVValue(row[name])).join(","));
return [headerRow, ...dataRows].join("\n");
}
/**
* Convert query result rows to JSON format.
*
* @param rows - Array of row objects from query results
* @returns Formatted JSON string
*/
export function rowsToJSON(rows: Record<string, unknown>[]): string {
return JSON.stringify(rows, null, 2);
}
/**
* Trigger a file download in the browser.
*
* @param content - The file content as a string
* @param filename - The name for the downloaded file
* @param mimeType - The MIME type of the file
*/
export function downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}