diff --git a/src/components/gui/export/export-result-button.tsx b/src/components/gui/export/export-result-button.tsx index f234c29a..76b3e591 100644 --- a/src/components/gui/export/export-result-button.tsx +++ b/src/components/gui/export/export-result-button.tsx @@ -1,77 +1,307 @@ import { Button, buttonVariants } from "../../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; import { Select, SelectContent, SelectItem, + SelectSeparator, SelectTrigger, SelectValue, } from "../../ui/select"; -import OptimizeTableState from "../table-optimized/OptimizeTableState"; -import { useCallback, useState } from "react"; +import type OptimizeTableState from "../table-optimized/OptimizeTableState"; +import { type ChangeEvent, useCallback, useState } from "react"; import { getFormatHandlers } from "@/components/lib/export-helper"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; export default function ExportResultButton({ data, }: { data: OptimizeTableState; }) { - const [format, setFormat] = useState(null); + const [format, setFormat] = useState("sql"); + + //do we want to make them something like this or just string? + // is we use the as const , where should i move it to? + + const OutputTargetType = { + File: "file", + Clipboard: "clipboard", + } as const; + + type OutputTargetType = + (typeof OutputTargetType)[keyof typeof OutputTargetType]; + + const ExportSelectionType = { + Complete: "complete", + Selected: "selected", + } as const; + + type ExportSelectionType = + (typeof ExportSelectionType)[keyof typeof ExportSelectionType]; + + const [exportSelection, setExportSelection] = useState( + ExportSelectionType.Complete + ); + + const [outputTarget, setOutputTarget] = useState( + OutputTargetType.File + ); + + const [tableName, setTableName] = useState("UnknownTable"); + + const [cellTextLimit, setCellTextLimit] = useState(50); + + const [batchSize, setBatchSize] = useState(1); const onExportClicked = useCallback(() => { if (!format) return; - let content = ""; const headers = data.getHeaders().map((header) => header.name); - const records = data - .getAllRows() + const records = ( + exportSelection === ExportSelectionType.Complete + ? data.getAllRows() + : data.getSelectedRows() + ) // i need more instruction on how to do this getSelectedRows .map((row) => headers.map((header) => row.raw[header])); - const tableName = "UnknownTable"; //TODO: replace with actual table name - - const formatHandlers = getFormatHandlers(records, headers, tableName); + const exportTableName = tableName.trim() || "UnknownTable"; + const formatHandlers = getFormatHandlers( + records, + headers, + exportTableName, + cellTextLimit ?? 50, + batchSize ?? 1 + ); const handler = formatHandlers[format]; - if (handler) { - content = handler(); + if (!handler) return; + + const content = handler(); + + //should we move this to a seperate function by now? + if (outputTarget === OutputTargetType.File) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `export.${format}`; + a.click(); + URL.revokeObjectURL(url); + } else { + navigator.clipboard + .writeText(content) + .then(() => toast.success("Content copied to clipboard")) + .catch((err) => { + toast.error("Failed to copy content to clipboard"); + console.error("Failed to copy content: ", err); + }); + } + }, [ + format, + data, + exportSelection, + ExportSelectionType.Complete, + tableName, + cellTextLimit, + batchSize, + outputTarget, + OutputTargetType.File, + ]); + + const handleCellTextLimitChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "") { + setCellTextLimit(undefined); + return; } - const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `export.${format}`; - a.click(); - URL.revokeObjectURL(url); - }, [format, data]); + const parsedValue = Number.parseInt(value, 10); + setCellTextLimit(Number.isNaN(parsedValue) ? undefined : parsedValue); + }; + + const handleBatchSizeChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "") { + setBatchSize(undefined); + return; + } + + const parsedValue = Number.parseInt(value, 10); + if (!Number.isNaN(parsedValue) && parsedValue > 0) { + setBatchSize(parsedValue); + } + }; return ( - - + +
Export
-
- -
-
Export
- -
-
- + + + Export + +
+
+ + + +
+
setExportSelection(ExportSelectionType.Complete)} + > +
+
Complete
+
{data.getAllRows().length} rows
+
+
+ +
setExportSelection(ExportSelectionType.Selected)} + > +
+
Selected
+
+ {data.getSelectedRows().length} rows +
+
+
+
+
+ + {format === "sql" && ( +
+ + +
+
+ Table Name + setTableName(e.target.value)} + placeholder="UnknownTable" + /> +
+
+ Batch Size + +
+
+
+ )} + + {format === "markdown" && ( +
+ + +
+
+ Cell Text Limit + +
+
+
+ )} + + {format === "ascii" && ( +
+ + +
+
+ Cell Text Limit + +
+
+
+ )} + +
+ +
+ + +
+
- - + + + + +
+ ); } diff --git a/src/components/gui/table-optimized/OptimizeTableState.tsx b/src/components/gui/table-optimized/OptimizeTableState.tsx index c99624ff..a1b7a341 100644 --- a/src/components/gui/table-optimized/OptimizeTableState.tsx +++ b/src/components/gui/table-optimized/OptimizeTableState.tsx @@ -481,6 +481,10 @@ export default class OptimizeTableState { return Array.from(this.selectedRows.values()); } + getSelectedRows(): OptimizeTableRowValue[] { + return this.data.filter((row, index) => this.selectedRows.has(index)); + } + selectRow(y: number, toggle?: boolean) { if (toggle) { if (this.selectedRows.has(y)) { diff --git a/src/components/gui/table-result/context-menu.tsx b/src/components/gui/table-result/context-menu.tsx index 917822d1..237032aa 100644 --- a/src/components/gui/table-result/context-menu.tsx +++ b/src/components/gui/table-result/context-menu.tsx @@ -27,6 +27,7 @@ export default function useTableResultContextMenu({ }) { const { openEditor } = useFullEditor(); const { extensions } = useConfig(); + const batchSize: number = 1; return useCallback( ({ @@ -207,7 +208,8 @@ export default function useTableResultContextMenu({ exportRowsToSqlInsert( tableName ?? "UnknownTable", headers, - state.getSelectedRowsArray() + state.getSelectedRowsArray(), + batchSize ) ); } diff --git a/src/components/lib/export-helper.test.ts b/src/components/lib/export-helper.test.ts new file mode 100644 index 00000000..aec5e05f --- /dev/null +++ b/src/components/lib/export-helper.test.ts @@ -0,0 +1,91 @@ +import { + exportRowsToMarkdown, + exportRowsToAsciiTable, +} from "@/components/lib/export-helper"; + +describe("exportRowsToMarkdown", () => { + it("should export rows to markdown", () => { + const headers = ["Name", "Age", "City"]; + const records = [ + ["Alice", 30, "New York"], + ["Bob", 25, "San Francisco"], + ["Charlie", 35, "Los Angeles"], + ]; + const cellTextLimit = 50; + + const result = exportRowsToMarkdown(headers, records, cellTextLimit); + + expect(result).toBe( + `| Name | Age | City | +| ------- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Charlie | 35 | Los Angeles |` + ); + }); + + it("should truncate cell text if it exceeds the limit", () => { + const headers = ["Name", "Description"]; + const records = [ + ["Alice", "A very long description that exceeds the limit"], + ["Bob", "Short description"], + ]; + const cellTextLimit = 20; + + const result = exportRowsToMarkdown(headers, records, cellTextLimit); + + expect(result).toBe( + `| Name | Description | +| ----- | ----------------------- | +| Alice | A very long descript... | +| Bob | Short description |` + ); + }); +}); + +describe("exportRowsToAsciiTable", () => { + it("should export rows to ASCII table format", () => { + const headers = ["Name", "Age", "City"]; + const records = [ + ["Alice", 30, "New York"], + ["Bob", 25, "San Francisco"], + ["Charlie", 35, "Los Angeles"], + ]; + const cellTextLimit = 50; + + const result = exportRowsToAsciiTable(headers, records, cellTextLimit); + + expect(result).toBe( + `┌─────────┬─────┬───────────────┐ +│ Name │ Age │ City │ +╞═════════╪═════╪═══════════════╡ +│ Alice │ 30 │ New York │ +├─────────┼─────┼───────────────┤ +│ Bob │ 25 │ San Francisco │ +├─────────┼─────┼───────────────┤ +│ Charlie │ 35 │ Los Angeles │ +└─────────┴─────┴───────────────┘` + ); + }); + + it("should truncate cell text if it exceeds the limit in ASCII table", () => { + const headers = ["Name", "Description"]; + const records = [ + ["Alice", "A very long description that exceeds the limit"], + ["Bob", "Short description"], + ]; + const cellTextLimit = 20; + + const result = exportRowsToAsciiTable(headers, records, cellTextLimit); + + expect(result).toBe( + `┌───────┬─────────────────────────┐ +│ Name │ Description │ +╞═══════╪═════════════════════════╡ +│ Alice │ A very long descript... │ +├───────┼─────────────────────────┤ +│ Bob │ Short description │ +└───────┴─────────────────────────┘` + ); + }); +}); diff --git a/src/components/lib/export-helper.ts b/src/components/lib/export-helper.ts index 112b4264..951b8a36 100644 --- a/src/components/lib/export-helper.ts +++ b/src/components/lib/export-helper.ts @@ -14,22 +14,23 @@ export function selectArrayFromIndexList( export function exportRowsToSqlInsert( tableName: string, headers: string[], - records: unknown[][] + records: unknown[][], + batchSize: number ): string { const result: string[] = []; - const headersPart = headers.map(escapeIdentity).join(", "); - for (const record of records) { - const valuePart = record.map(escapeSqlValue).join(", "); - const line = `INSERT INTO ${escapeIdentity( - tableName - )}(${headersPart}) VALUES(${valuePart});`; + for (let i = 0; i < records.length; i += batchSize) { + const batch = records.slice(i, i + batchSize); + const valuesPart = batch + .map((record) => `(${record.map(escapeSqlValue).join(", ")})`) + .join(",\n "); + const line = `INSERT INTO ${escapeIdentity(tableName)}(${headersPart}) VALUES\n ${valuesPart};`; result.push(line); } - return result.join("\r\n"); + return result.join("\n\n"); } function cellToExcelValue(value: unknown) { @@ -93,14 +94,116 @@ export function exportRowsToCsv( return result.join("\n"); } +function truncateText(text: string, limit: number): string { + if (text.length <= limit) return text; + return `${text.slice(0, limit)}...`; +} + +function calculateColumnWidths( + headers: string[], + records: unknown[][], + cellTextLimit: number +): number[] { + return headers.map((header, index) => { + const maxContentWidth = Math.max( + header.length, + ...records.map((record) => { + const cellContent = String(record[index]); + return cellContent.length > cellTextLimit + ? cellTextLimit + 3 + : cellContent.length; + }) + ); + return Math.min(maxContentWidth, cellTextLimit + 3); + }); +} + +export function exportRowsToMarkdown( + headers: string[], + records: unknown[][], + cellTextLimit: number +): string { + const result: string[] = []; + const columnWidths = calculateColumnWidths(headers, records, cellTextLimit); + + // Add headers + const headerRow = `| ${headers.map((h, i) => truncateText(h, cellTextLimit).padEnd(columnWidths[i])).join(" | ")} |`; + result.push(headerRow); + + // Add separator + const separator = `| ${columnWidths.map((width) => "-".repeat(width)).join(" | ")} |`; + result.push(separator); + + // Add records + for (const record of records) { + const row = `| ${record + .map((cell, index) => + truncateText(String(cell), cellTextLimit).padEnd(columnWidths[index]) + ) + .join(" | ")} |`; + result.push(row); + } + + return result.join("\n"); +} + +export function exportRowsToAsciiTable( + headers: string[], + records: unknown[][], + cellTextLimit: number +): string { + const result: string[] = []; + const columnWidths = calculateColumnWidths(headers, records, cellTextLimit); + + // Create top border + const topBorder = `┌${columnWidths.map((width) => "─".repeat(width + 2)).join("┬")}┐`; + result.push(topBorder); + + // Add headers + const headerRow = `│ ${headers + .map((h, i) => truncateText(h, cellTextLimit).padEnd(columnWidths[i])) + .join(" │ ")} │`; + result.push(headerRow); + + // Add separator + const headerSeparator = `╞${columnWidths.map((width) => "═".repeat(width + 2)).join("╪")}╡`; + result.push(headerSeparator); + + // Add records + for (const record of records) { + const row = `│ ${record + .map((cell, index) => + truncateText(String(cell), cellTextLimit).padEnd(columnWidths[index]) + ) + .join(" │ ")} │`; + result.push(row); + + // Add separator between rows, except for the last row + if (record !== records[records.length - 1]) { + const rowSeparator = `├${columnWidths.map((width) => "─".repeat(width + 2)).join("┼")}┤`; + result.push(rowSeparator); + } + } + + // Add bottom border + const bottomBorder = `└${columnWidths.map((width) => "─".repeat(width + 2)).join("┴")}┘`; + result.push(bottomBorder); + + return result.join("\n"); +} + export function getFormatHandlers( records: unknown[][], headers: string[], - tableName: string + tableName: string, + cellTextLimit: number, + batchSize: number ): Record string) | undefined> { return { csv: () => exportRowsToCsv(headers, records), json: () => exportRowsToJson(headers, records), - sql: () => exportRowsToSqlInsert(tableName, headers, records), + sql: () => exportRowsToSqlInsert(tableName, headers, records, batchSize), + markdown: () => exportRowsToMarkdown(headers, records, cellTextLimit), + ascii: () => exportRowsToAsciiTable(headers, records, cellTextLimit), }; }