diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 23074b605..fbfffca3b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -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() { )} - +
+ {results?.rows && results?.columns && results.rows.length > 0 && ( + + )} + +
{isLoading ? (
@@ -328,6 +341,60 @@ export default function Page() { ); } +function ExportResultsButton({ + rows, + columns, +}: { + rows: Record[]; + 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 ( + + Export + +
+ + + + +
+
+
+ ); +} + function QueryHelpSidebar({ onClose }: { onClose: () => void }) { return (
diff --git a/apps/webapp/app/utils/dataExport.ts b/apps/webapp/app/utils/dataExport.ts new file mode 100644 index 000000000..f29e19817 --- /dev/null +++ b/apps/webapp/app/utils/dataExport.ts @@ -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[], 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 { + 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); +} +