Export/copy as CSV and JSON
This commit is contained in:
+75
-8
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user