From ebebcf28f43a121b8a3dae4be42824532b6cd13b Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Thu, 20 Jun 2024 18:10:20 +0700 Subject: [PATCH] feat: add export for whole query result (#95) * feat: add design for export * feat: implementing export results as file * using LF as default for vscode * feat: export row to csv --------- Co-authored-by: Ave Aristov --- .vscode/settings.json | 3 +- .../export/export-result-button.tsx | 77 +++++ .../table-optimized/OptimizeTableState.tsx | 4 + gui/src/components/tabs/query-tab.tsx | 8 +- gui/src/lib/export-helper.ts | 62 +++- gui/src/sqlite/sql-helper.ts | 18 ++ studio/src/app/client/layout.tsx | 20 +- studio/src/components/my-studio.tsx | 266 +++++++++--------- 8 files changed, 312 insertions(+), 146 deletions(-) create mode 100644 gui/src/components/export/export-result-button.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 5d3253ee..e24e1d51 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.formatOnSave": true, - "typescript.tsdk": "node_modules\\typescript\\lib" + "typescript.tsdk": "node_modules\\typescript\\lib", + "files.eol": "\n" } \ No newline at end of file diff --git a/gui/src/components/export/export-result-button.tsx b/gui/src/components/export/export-result-button.tsx new file mode 100644 index 00000000..ecfad2ad --- /dev/null +++ b/gui/src/components/export/export-result-button.tsx @@ -0,0 +1,77 @@ +import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import OptimizeTableState from "../table-optimized/OptimizeTableState"; +import { useCallback, useState } from "react"; +import { getFormatHandlers } from "@gui/lib/export-helper"; + +export default function ExportResultButton({ + data, +}: { + data: OptimizeTableState; +}) { + const [format, setFormat] = useState(null); + + const onExportClicked = useCallback(() => { + if (!format) return; + + let content = ""; + const headers = data.getHeaders().map((header) => header.name); + const records = data + .getAllRows() + .map((row) => headers.map((header) => row.raw[header])); + + const tableName = "UnknownTable"; //TODO: replace with actual table name + + const formatHandlers = getFormatHandlers(records, headers, tableName); + + const handler = formatHandlers[format]; + if (handler) { + content = handler(); + } + + 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]); + + return ( + + + + + +
+
Export
+ +
+
+ +
+
+
+ ); +} diff --git a/gui/src/components/table-optimized/OptimizeTableState.tsx b/gui/src/components/table-optimized/OptimizeTableState.tsx index c0f450fc..e2f063a9 100644 --- a/gui/src/components/table-optimized/OptimizeTableState.tsx +++ b/gui/src/components/table-optimized/OptimizeTableState.tsx @@ -335,6 +335,10 @@ export default class OptimizeTableState { return !!this.data[index]?.isRemoved; } + getAllRows() { + return this.data; + } + // ------------------------------------------------ // Handle focus logic // ------------------------------------------------ diff --git a/gui/src/components/tabs/query-tab.tsx b/gui/src/components/tabs/query-tab.tsx index f60070ae..65978203 100644 --- a/gui/src/components/tabs/query-tab.tsx +++ b/gui/src/components/tabs/query-tab.tsx @@ -21,6 +21,7 @@ import { MultipleQueryProgress, multipleQuery } from "@gui/lib/multiple-query"; import { DatabaseResultStat } from "@gui/driver"; import ResultStats from "../result-stat"; import isEmptyResultStats from "@gui/lib/empty-stats"; +import ExportResultButton from "../export/export-result-button"; export default function QueryWindow() { const { schema } = useAutoComplete(); @@ -130,7 +131,12 @@ export default function QueryWindow() { {stats && !isEmptyResultStats(stats) && (
- +
+ +
+ +
+
)} diff --git a/gui/src/lib/export-helper.ts b/gui/src/lib/export-helper.ts index 3b3c7bd6..943cc596 100644 --- a/gui/src/lib/export-helper.ts +++ b/gui/src/lib/export-helper.ts @@ -1,4 +1,8 @@ -import { escapeIdentity, escapeSqlValue } from "@gui/sqlite/sql-helper"; +import { + escapeCsvValue, + escapeIdentity, + escapeSqlValue, +} from "@gui/sqlite/sql-helper"; export function selectArrayFromIndexList( data: T[], @@ -44,3 +48,59 @@ export function exportRowsToExcel(records: unknown[][]) { return result.join("\r\n"); } + +export function exportRowsToJson( + headers: string[], + records: unknown[][] +): string { + const recordsWithBigIntAsString = records.map((record) => + record.map((value) => + typeof value === "bigint" ? value.toString() : value + ) + ); + + const recordsAsObjects = recordsWithBigIntAsString.map((record) => + record.reduce>((obj, value, index) => { + const header = headers[index]; + if (header !== undefined) { + obj[header] = value; + } + return obj; + }, {}) + ); + + return JSON.stringify(recordsAsObjects, null, 2); +} + +export function exportRowsToCsv( + headers: string[], + records: unknown[][] +): string { + const result: string[] = []; + + // Add headers + const escapedHeaders = headers.map(escapeCsvValue); + const headerLine = escapedHeaders.join(","); + result.push(headerLine); + + // Add records + for (const record of records) { + const escapedRecord = record.map(escapeCsvValue); + const recordLine = escapedRecord.join(","); + result.push(recordLine); + } + + return result.join("\n"); +} + +export function getFormatHandlers( + records: unknown[][], + headers: string[], + tableName: string +): Record string) | undefined> { + return { + csv: () => exportRowsToCsv(headers, records), + json: () => exportRowsToJson(headers, records), + sql: () => exportRowsToSqlInsert(tableName, headers, records), + }; +} diff --git a/gui/src/sqlite/sql-helper.ts b/gui/src/sqlite/sql-helper.ts index 6ff97968..d3c82224 100644 --- a/gui/src/sqlite/sql-helper.ts +++ b/gui/src/sqlite/sql-helper.ts @@ -137,3 +137,21 @@ export function selectStatementFromPosition( } return undefined; } + +export function escapeCsvValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + const stringValue = value.toString(); + const needsEscaping = + stringValue.includes(",") || + stringValue.includes('"') || + stringValue.includes("\n"); + + if (needsEscaping) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +} diff --git a/studio/src/app/client/layout.tsx b/studio/src/app/client/layout.tsx index c283e7a3..a9c949bd 100644 --- a/studio/src/app/client/layout.tsx +++ b/studio/src/app/client/layout.tsx @@ -1,10 +1,10 @@ -import "@libsqlstudio/gui/css"; -import { Fragment } from "react"; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} +import "@libsqlstudio/gui/css"; +import { Fragment } from "react"; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/studio/src/components/my-studio.tsx b/studio/src/components/my-studio.tsx index f77a062e..ff23ac0f 100644 --- a/studio/src/components/my-studio.tsx +++ b/studio/src/components/my-studio.tsx @@ -1,133 +1,133 @@ -import { BaseDriver, CollaborationDriver } from "@libsqlstudio/gui/driver"; -import { Studio, StudioExtension } from "@libsqlstudio/gui"; -import { useTheme } from "@studio/context/theme-provider"; -import { useRouter } from "next/navigation"; -import { useCallback, useMemo } from "react"; -import { toast } from "sonner"; -import { triggerSelectFiles, uploadFile } from "./file-upload"; -import { - BlockEditorProvider, - useBlockEditor, -} from "@studio/context/block-editor-provider"; - -interface MyStudioProps { - name: string; - color: string; - driver: BaseDriver; - collabarator?: CollaborationDriver; -} - -function MyStudioInternal({ - name, - color, - driver, - collabarator, -}: MyStudioProps) { - const router = useRouter(); - const { openBlockEditor } = useBlockEditor(); - const { theme, toggleTheme } = useTheme(); - - const goBack = useCallback(() => { - router.push("/connect"); - }, [router]); - - const extensions = useMemo(() => { - return [ - { - contextMenu: (state) => { - return [ - { - title: "Upload File", - onClick: async () => { - const files = await triggerSelectFiles(); - - if (files.error) return toast.error(files.error.message); - - const file = files.value[0]; - if (!file) return; - - const toastId = toast.loading("Uploading file..."); - const { data, error } = await uploadFile(file); - if (error) - return toast.error("Upload failed!", { - id: toastId, - description: error.message, - }); - - state.setFocusValue(data.url); - return toast.success("File uploaded!", { id: toastId }); - }, - }, - ]; - }, - }, - { - contextMenu: (state) => { - return [ - { - title: "Edit with Block Editor", - onClick: () => { - openBlockEditor({ - initialContent: state.getFocusValue() as string, - onSave: (newValue) => state.setFocusValue(newValue), - }); - }, - }, - ]; - }, - }, - ]; - }, [openBlockEditor]); - - const sideBanner = useMemo(() => { - return ( -
- LibStudio Studio is open-source database GUI. - -
- ); - }, []); - - return ( - - ); -} - -export default function MyStudio(props: MyStudioProps) { - return ( - - - - ); -} +import { BaseDriver, CollaborationDriver } from "@libsqlstudio/gui/driver"; +import { Studio, StudioExtension } from "@libsqlstudio/gui"; +import { useTheme } from "@studio/context/theme-provider"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; +import { triggerSelectFiles, uploadFile } from "./file-upload"; +import { + BlockEditorProvider, + useBlockEditor, +} from "@studio/context/block-editor-provider"; + +interface MyStudioProps { + name: string; + color: string; + driver: BaseDriver; + collabarator?: CollaborationDriver; +} + +function MyStudioInternal({ + name, + color, + driver, + collabarator, +}: MyStudioProps) { + const router = useRouter(); + const { openBlockEditor } = useBlockEditor(); + const { theme, toggleTheme } = useTheme(); + + const goBack = useCallback(() => { + router.push("/connect"); + }, [router]); + + const extensions = useMemo(() => { + return [ + { + contextMenu: (state) => { + return [ + { + title: "Upload File", + onClick: async () => { + const files = await triggerSelectFiles(); + + if (files.error) return toast.error(files.error.message); + + const file = files.value[0]; + if (!file) return; + + const toastId = toast.loading("Uploading file..."); + const { data, error } = await uploadFile(file); + if (error) + return toast.error("Upload failed!", { + id: toastId, + description: error.message, + }); + + state.setFocusValue(data.url); + return toast.success("File uploaded!", { id: toastId }); + }, + }, + ]; + }, + }, + { + contextMenu: (state) => { + return [ + { + title: "Edit with Block Editor", + onClick: () => { + openBlockEditor({ + initialContent: state.getFocusValue() as string, + onSave: (newValue) => state.setFocusValue(newValue), + }); + }, + }, + ]; + }, + }, + ]; + }, [openBlockEditor]); + + const sideBanner = useMemo(() => { + return ( +
+ LibStudio Studio is open-source database GUI. + +
+ ); + }, []); + + return ( + + ); +} + +export default function MyStudio(props: MyStudioProps) { + return ( + + + + ); +}