From b59603f898dd76104c25bb490ff49fbcf3b5d334 Mon Sep 17 00:00:00 2001 From: thormengkheang Date: Fri, 25 Apr 2025 15:03:26 +0700 Subject: [PATCH 1/3] fix cell bg when pin header --- src/components/gui/table-optimized/table-cell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/gui/table-optimized/table-cell.tsx b/src/components/gui/table-optimized/table-cell.tsx index 2b0cc4ac..71edad73 100644 --- a/src/components/gui/table-optimized/table-cell.tsx +++ b/src/components/gui/table-optimized/table-cell.tsx @@ -34,7 +34,7 @@ export default function OptimizeTableCell({ return { zIndex: 15, left: state.gutterColumnWidth + "px" }; }, [state.gutterColumnWidth, isSticky]); - let cellBackgroundColor = "bg-transparent"; + let cellBackgroundColor = "bg-background"; if (isSelected) { if (isRemoved) { From a1e9e04134605b1649a074b5fe8ba47b8f40b74c Mon Sep 17 00:00:00 2001 From: keppere <2keppere@gmail.com> Date: Sat, 26 Apr 2025 15:58:20 +0700 Subject: [PATCH 2/3] feat: enhance export functionality with null value handling and refactor export options --- .../gui/export/export-result-button.tsx | 226 ++++++++++-------- src/drivers/sqlite/sql-helper.ts | 10 +- src/lib/export-helper.ts | 101 ++++++-- 3 files changed, 208 insertions(+), 129 deletions(-) diff --git a/src/components/gui/export/export-result-button.tsx b/src/components/gui/export/export-result-button.tsx index 023b674e..6ab4388f 100644 --- a/src/components/gui/export/export-result-button.tsx +++ b/src/components/gui/export/export-result-button.tsx @@ -10,7 +10,7 @@ import { SelectValue, } from "@/components/ui/select"; import { getFormatHandlers } from "@/lib/export-helper"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; import OptimizeTableState, { TableSelectionRange, @@ -23,10 +23,31 @@ export type ExportSelection = | "selected_row" | "selected_col" | "selected_range"; + +const csvDelimeter = { + fieldSeparator: ",", + lineTerminator: "\\n", + encloser: '"', + nullValue: "NULL", +}; +const excelDelimeter = { + fieldSeparator: "\\t", + lineTerminator: "\\r\\n", + encloser: '"', + nullValue: "NULL", +}; + +const textDelimeter = { + fieldSeparator: "\\t", + lineTerminator: "\\n", + encloser: '"', + nullValue: "NULL", +}; export interface ExportOptions { fieldSeparator?: string; lineTerminator?: string; encloser?: string; + nullValue?: string; } interface selectionCount { @@ -40,6 +61,7 @@ interface ExportSettings { target: ExportTarget; selection: ExportSelection; options?: ExportOptions; + formatTemplate?: Record; } export default function ExportResultButton({ @@ -47,41 +69,48 @@ export default function ExportResultButton({ }: { data: OptimizeTableState; }) { - const csvDelimeter = useMemo( - () => ({ - fieldSeparator: ",", - lineTerminator: "\\n", - encloser: '"', - }), - [] - ); - const excelDiliemter = { - fieldSeparator: "\\t", - lineTerminator: "\\r\\n", - encloser: '"', - }; - - const saveSetting = (settings: ExportSettings) => { + const getDefaultOption = useCallback((format: ExportFormat) => { + switch (format) { + case "csv": + return csvDelimeter; + case "xlsx": + return excelDelimeter; + case "delimited": + return textDelimeter; + default: + return null; + } + }, []); + const saveSettingToStorage = (settings: ExportSettings) => { + settings.formatTemplate = { + ...settings.formatTemplate, + ...(settings.options ? { [settings.format]: settings.options } : {}), + }; localStorage.setItem("export_settings", JSON.stringify(settings)); }; - const exportSettings = useCallback(() => { + const getSettingFromStorage = useCallback(() => { const settings = localStorage.getItem("export_settings"); if (settings) { - return JSON.parse(settings) as ExportSettings; + const settingValue = JSON.parse(settings) as ExportSettings; + return { + ...settingValue, + options: + settingValue.formatTemplate?.[settingValue.format] || csvDelimeter, + }; } return { format: "csv", target: "clipboard", selection: "complete", - options: csvDelimeter, + options: getDefaultOption("csv"), } as ExportSettings; - }, [csvDelimeter]); + }, [getDefaultOption]); - const [format, setFormat] = useState(exportSettings().format); - const [exportTarget, setExportTarget] = useState( - exportSettings().target + const [exportSetting, setExportSetting] = useState( + getSettingFromStorage() ); + const [selectionCount, setSelectionCount] = useState({ rows: 0, cols: 0, @@ -89,30 +118,10 @@ export default function ExportResultButton({ }); const [exportSelection, setExportSelection] = useState( () => { - const savedSelection = exportSettings().selection; + const savedSelection = exportSetting.selection; return validateExportSelection(savedSelection, selectionCount); } ); - const [delimitedOptions, setDelimitedOptions] = useState( - exportSettings().options || { - fieldSeparator: ",", - lineTerminator: "\\n", - encloser: '"', - } - ); - const [exportOptions, setExportOptions] = useState( - () => { - if (format === "csv") { - return csvDelimeter; - } else if (format === "xlsx") { - return excelDiliemter; - } else if (format === "delimited") { - return delimitedOptions; - } else { - return null; - } - } - ); const [selectedRangeIndex, setSelectedRangeIndex] = useState( selectionCount.ranges.length > 0 ? 0 : -1 @@ -120,19 +129,19 @@ export default function ExportResultButton({ const [open, setOpen] = useState(false); const onExportClicked = useCallback(() => { - if (!format) return; + if (!exportSetting.format) return; let content = ""; const formatHandlers = getFormatHandlers( data, - exportTarget, + exportSetting.target, exportSelection, - exportOptions, + exportSetting.options!, selectedRangeIndex ); - const handler = formatHandlers[format]; + const handler = formatHandlers[exportSetting.format]; if (handler) { content = handler(); } @@ -144,15 +153,15 @@ export default function ExportResultButton({ const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `export.${format === "delimited" ? "csv" : format}`; + a.download = `export.${exportSetting.format === "delimited" ? "csv" : exportSetting.format}`; a.click(); URL.revokeObjectURL(url); }, [ - format, + exportSetting.format, + exportSetting.target, + exportSetting.options, data, - exportTarget, exportSelection, - exportOptions, selectedRangeIndex, ]); @@ -173,32 +182,13 @@ export default function ExportResultButton({ useEffect(() => { setExportSelection( - validateExportSelection(exportSettings().selection, selectionCount) + validateExportSelection(exportSetting.selection, selectionCount) ); - }, [exportSettings, selectionCount]); + }, [exportSetting, selectionCount]); useEffect(() => { - saveSetting({ - ...exportSettings(), - format, - selection: exportSelection, - target: exportTarget, - }); - if (format === "delimited") { - saveSetting({ - ...exportSettings(), - options: exportOptions ?? csvDelimeter, - }); - if (exportOptions) setDelimitedOptions(exportOptions); - } - }, [ - csvDelimeter, - exportOptions, - exportSelection, - exportSettings, - exportTarget, - format, - ]); + saveSettingToStorage(exportSetting); + }, [exportSelection, exportSetting]); const SelectedRange = ({ ranges, @@ -244,9 +234,12 @@ export default function ExportResultButton({ { - setExportTarget(e as ExportTarget); + setExportSetting((prev) => ({ + ...prev, + target: e as ExportTarget, + })); }} >
@@ -265,18 +258,17 @@ export default function ExportResultButton({ Output format { - setFormat(e as ExportFormat); - if (e === "csv") { - setExportOptions(csvDelimeter); - } else if (e === "xlsx") { - setExportOptions(excelDiliemter); - } else if (e === "delimited") { - setExportOptions(delimitedOptions); - } else { - setExportOptions(null); - } + setExportSetting((prev) => ({ + ...prev, + format: e as ExportFormat, + options: exportSetting.formatTemplate?.[ + e as ExportFormat + ] || { + ...getDefaultOption(e as ExportFormat), + }, + })); }} >
@@ -415,14 +407,17 @@ export default function ExportResultButton({ Field separator:
{ - setExportOptions({ - ...exportOptions, - fieldSeparator: e.target.value, + setExportSetting({ + ...exportSetting, + options: { + ...exportSetting.options, + fieldSeparator: e.target.value, + }, }); }} /> @@ -432,14 +427,17 @@ export default function ExportResultButton({ Line terminator:
{ - setExportOptions({ - ...exportOptions, - lineTerminator: e.target.value, + setExportSetting({ + ...exportSetting, + options: { + ...exportSetting.options, + lineTerminator: e.target.value, + }, }); }} /> @@ -450,14 +448,36 @@ export default function ExportResultButton({ Encloser:
{ + setExportSetting({ + ...exportSetting, + options: { + ...exportSetting.options, + encloser: e.target.value, + }, + }); + }} + /> +
+
+
+ NULL Value: +
+ { - setExportOptions({ - ...exportOptions, - encloser: e.target.value, + setExportSetting({ + ...exportSetting, + options: { + ...exportSetting.options, + nullValue: e.target.value, + }, }); }} /> diff --git a/src/drivers/sqlite/sql-helper.ts b/src/drivers/sqlite/sql-helper.ts index 1c9ef2a1..eeab792c 100644 --- a/src/drivers/sqlite/sql-helper.ts +++ b/src/drivers/sqlite/sql-helper.ts @@ -1,5 +1,6 @@ import { DatabaseValue } from "@/drivers/base-driver"; import { hex } from "@/lib/bit-operation"; +import { parseUserInput } from "@/lib/export-helper"; import { ColumnType } from "@outerbase/sdk-transform"; export function escapeIdentity(str: string) { @@ -21,9 +22,9 @@ export function escapeSqlBinary(value: ArrayBuffer) { return `x'${hex(value)}'`; } -export function escapeSqlValue(value: unknown) { +export function escapeSqlValue(value: unknown, nullValue: string = "NULL") { if (value === undefined) return "DEFAULT"; - if (value === null) return "NULL"; + if (value === null) return parseUserInput(nullValue); if (typeof value === "string") return escapeSqlString(value); if (typeof value === "number") return value.toString(); if (typeof value === "bigint") return value.toString(); @@ -84,10 +85,11 @@ export function escapeDelimitedValue( value: unknown, fieldSeparator: string, lineTerminator: string, - encloser: string + encloser: string, + nullValue: string = "NULL" ): string { if (value === null || value === undefined) { - return "NULL"; + return nullValue; } const stringValue = value.toString(); diff --git a/src/lib/export-helper.ts b/src/lib/export-helper.ts index c20246a7..fbb1b0e5 100644 --- a/src/lib/export-helper.ts +++ b/src/lib/export-helper.ts @@ -24,14 +24,17 @@ export function exportRowsToSqlInsert( tableName: string, headers: string[], records: unknown[][], - exportTarget?: ExportTarget + exportTarget?: ExportTarget, + nullValue?: string | "NULL" ): string { const result: string[] = []; const headersPart = headers.map(escapeIdentity).join(", "); for (const record of records) { - const valuePart = record.map(escapeSqlValue).join(", "); + const valuePart = record + .map((value) => escapeSqlValue(value, nullValue)) + .join(", "); const line = `INSERT INTO ${escapeIdentity( tableName )}(${headersPart}) VALUES(${valuePart});`; @@ -47,18 +50,23 @@ export function exportRowsToSqlInsert( return content; } -function cellToExcelValue(value: unknown) { +function cellToExcelValue(value: unknown, nullValue: string = "NULL") { if (value === undefined) return ""; - if (value === null) return "NULL"; + if (value === null) return parseUserInput(nullValue); const parsed = Number(value); return isNaN(parsed) ? value : parsed; } -export function exportRowsToExcel(records: unknown[][]) { +export function exportRowsToExcel( + records: unknown[][], + nullValue: string = "NULL" +) { const result: string[] = []; for (const record of records) { - const line = record.map(cellToExcelValue).join("\t"); + const line = record + .map((cell) => cellToExcelValue(cell, nullValue)) + .join("\t"); result.push(line); } @@ -69,16 +77,25 @@ export function exportToExcel( records: unknown[][], headers: string[], tablename: string, - exportTarget: ExportTarget + exportTarget: ExportTarget, + nullValue: string = "NULL" ) { if (exportTarget === "clipboard") { - exportDataAsDelimitedText(headers, records, "\t", "\r\n", '"', "clipboard"); + exportDataAsDelimitedText( + headers, + records, + "\t", + "\r\n", + '"', + "clipboard", + nullValue + ); return ""; } const processedData = records.map((row) => row.map((cell) => { - return cellToExcelValue(cell); + return cellToExcelValue(cell, nullValue); }) ); @@ -98,7 +115,8 @@ export function exportToExcel( export function exportRowsToJson( headers: string[], records: unknown[][], - exportTarget?: ExportTarget + exportTarget?: ExportTarget, + nullValue?: string ): string { const recordsWithBigIntAsString = records.map((record) => record.map((value) => @@ -110,7 +128,8 @@ export function exportRowsToJson( record.reduce>((obj, value, index) => { const header = headers[index]; if (header !== undefined) { - obj[header] = value; + obj[header] = + value === null && nullValue ? parseUserInput(nullValue) : value; } return obj; }, {}) @@ -132,7 +151,8 @@ export function exportDataAsDelimitedText( fieldSeparator: string, lineTerminator: string, textEncloser: string, - exportTarget: ExportTarget + exportTarget: ExportTarget, + nullValue: string = "NULL" ): string { const result: string[] = []; @@ -146,7 +166,13 @@ export function exportDataAsDelimitedText( // Add records for (const record of records) { const escapedRecord = record.map((v) => - escapeDelimitedValue(v, fieldSeparator, lineTerminator, textEncloser) + escapeDelimitedValue( + v, + fieldSeparator, + lineTerminator, + textEncloser, + nullValue + ) ); const recordLine = escapedRecord.join(fieldSeparator); result.push(recordLine); @@ -210,10 +236,38 @@ export function getFormatHandlers( return { csv: () => - exportDataAsDelimitedText(headers, records, ",", "\n", '"', exportTarget), - json: () => exportRowsToJson(headers, records, exportTarget), - sql: () => exportRowsToSqlInsert(tableName, headers, records, exportTarget), - xlsx: () => exportToExcel(records, headers, tableName, exportTarget), + exportDataAsDelimitedText( + headers, + records, + ",", + "\n", + '"', + exportTarget, + exportOptions?.nullValue || "NULL" + ), + json: () => + exportRowsToJson( + headers, + records, + exportTarget, + exportOptions?.nullValue ?? undefined + ), + sql: () => + exportRowsToSqlInsert( + tableName, + headers, + records, + exportTarget, + exportOptions?.nullValue || "NULL" + ), + xlsx: () => + exportToExcel( + records, + headers, + tableName, + exportTarget, + exportOptions?.nullValue || "NULL" + ), delimited: () => exportDataAsDelimitedText( headers, @@ -221,12 +275,13 @@ export function getFormatHandlers( parseUserInput(exportOptions?.fieldSeparator || "") || ",", parseUserInput(exportOptions?.lineTerminator || "") || "\n", parseUserInput(exportOptions?.encloser || "") || '"', - exportTarget + exportTarget, + exportOptions?.nullValue || "NULL" ), }; } -function parseUserInput(input: string): string { +export function parseUserInput(input: string): string { return input .replace(/^"|"$/g, "") .replace(/\\n/g, "\n") @@ -255,7 +310,6 @@ export async function exportTableData( exportTarget: ExportTarget, options?: ExportOptions ): Promise { - console.log("Exporting", schemaName, tableName, format, exportTarget, options); const result = await databaseDriver.query( `SELECT * FROM ${databaseDriver.escapeId(schemaName)}.${databaseDriver.escapeId(tableName)}` ); @@ -265,10 +319,13 @@ export async function exportTableData( } const headers = Object.keys(result.rows[0]); - const records = result.rows.map((row: { [x: string]: string; }) => headers.map(header => row[header])); + const records = result.rows.map((row: { [x: string]: string }) => + headers.map((header) => row[header]) + ); const formatHandlers = { - csv: () => exportDataAsDelimitedText(headers, records, ",", "\n", '"', exportTarget), + csv: () => + exportDataAsDelimitedText(headers, records, ",", "\n", '"', exportTarget), json: () => exportRowsToJson(headers, records, exportTarget), sql: () => exportRowsToSqlInsert(tableName, headers, records, exportTarget), xlsx: () => exportToExcel(records, headers, tableName, exportTarget), From 7f0813e7c4c9a112419fff058e16905f4a653a9c Mon Sep 17 00:00:00 2001 From: keppere <2keppere@gmail.com> Date: Sun, 27 Apr 2025 15:33:11 +0700 Subject: [PATCH 3/3] fix: update escapeSqlValue to return "Invalid Value" for unrecognized types instead of raising error --- src/drivers/sqlite/sql-helper.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/drivers/sqlite/sql-helper.ts b/src/drivers/sqlite/sql-helper.ts index eeab792c..9d3f7698 100644 --- a/src/drivers/sqlite/sql-helper.ts +++ b/src/drivers/sqlite/sql-helper.ts @@ -31,8 +31,7 @@ export function escapeSqlValue(value: unknown, nullValue: string = "NULL") { if (value instanceof ArrayBuffer) return escapeSqlBinary(value); if (Array.isArray(value)) return escapeSqlBinary(Uint8Array.from(value).buffer); - // eslint-disable-next-line @typescript-eslint/no-base-to-string - throw new Error(value.toString() + " is unrecongize type of value"); + return "Invalid Value"; } export function extractInputValue(input: string): string | number {