Skip to content

Commit a1e9e04

Browse files
committed
feat: enhance export functionality with null value handling and refactor export options
1 parent fb3fa94 commit a1e9e04

File tree

3 files changed

+208
-129
lines changed

3 files changed

+208
-129
lines changed

src/components/gui/export/export-result-button.tsx

Lines changed: 123 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
SelectValue,
1111
} from "@/components/ui/select";
1212
import { getFormatHandlers } from "@/lib/export-helper";
13-
import { useCallback, useEffect, useMemo, useState } from "react";
13+
import { useCallback, useEffect, useState } from "react";
1414
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
1515
import OptimizeTableState, {
1616
TableSelectionRange,
@@ -23,10 +23,31 @@ export type ExportSelection =
2323
| "selected_row"
2424
| "selected_col"
2525
| "selected_range";
26+
27+
const csvDelimeter = {
28+
fieldSeparator: ",",
29+
lineTerminator: "\\n",
30+
encloser: '"',
31+
nullValue: "NULL",
32+
};
33+
const excelDelimeter = {
34+
fieldSeparator: "\\t",
35+
lineTerminator: "\\r\\n",
36+
encloser: '"',
37+
nullValue: "NULL",
38+
};
39+
40+
const textDelimeter = {
41+
fieldSeparator: "\\t",
42+
lineTerminator: "\\n",
43+
encloser: '"',
44+
nullValue: "NULL",
45+
};
2646
export interface ExportOptions {
2747
fieldSeparator?: string;
2848
lineTerminator?: string;
2949
encloser?: string;
50+
nullValue?: string;
3051
}
3152

3253
interface selectionCount {
@@ -40,99 +61,87 @@ interface ExportSettings {
4061
target: ExportTarget;
4162
selection: ExportSelection;
4263
options?: ExportOptions;
64+
formatTemplate?: Record<string, ExportOptions>;
4365
}
4466

4567
export default function ExportResultButton({
4668
data,
4769
}: {
4870
data: OptimizeTableState;
4971
}) {
50-
const csvDelimeter = useMemo(
51-
() => ({
52-
fieldSeparator: ",",
53-
lineTerminator: "\\n",
54-
encloser: '"',
55-
}),
56-
[]
57-
);
58-
const excelDiliemter = {
59-
fieldSeparator: "\\t",
60-
lineTerminator: "\\r\\n",
61-
encloser: '"',
62-
};
63-
64-
const saveSetting = (settings: ExportSettings) => {
72+
const getDefaultOption = useCallback((format: ExportFormat) => {
73+
switch (format) {
74+
case "csv":
75+
return csvDelimeter;
76+
case "xlsx":
77+
return excelDelimeter;
78+
case "delimited":
79+
return textDelimeter;
80+
default:
81+
return null;
82+
}
83+
}, []);
84+
const saveSettingToStorage = (settings: ExportSettings) => {
85+
settings.formatTemplate = {
86+
...settings.formatTemplate,
87+
...(settings.options ? { [settings.format]: settings.options } : {}),
88+
};
6589
localStorage.setItem("export_settings", JSON.stringify(settings));
6690
};
6791

68-
const exportSettings = useCallback(() => {
92+
const getSettingFromStorage = useCallback(() => {
6993
const settings = localStorage.getItem("export_settings");
7094
if (settings) {
71-
return JSON.parse(settings) as ExportSettings;
95+
const settingValue = JSON.parse(settings) as ExportSettings;
96+
return {
97+
...settingValue,
98+
options:
99+
settingValue.formatTemplate?.[settingValue.format] || csvDelimeter,
100+
};
72101
}
73102
return {
74103
format: "csv",
75104
target: "clipboard",
76105
selection: "complete",
77-
options: csvDelimeter,
106+
options: getDefaultOption("csv"),
78107
} as ExportSettings;
79-
}, [csvDelimeter]);
108+
}, [getDefaultOption]);
80109

81-
const [format, setFormat] = useState<ExportFormat>(exportSettings().format);
82-
const [exportTarget, setExportTarget] = useState<ExportTarget>(
83-
exportSettings().target
110+
const [exportSetting, setExportSetting] = useState<ExportSettings>(
111+
getSettingFromStorage()
84112
);
113+
85114
const [selectionCount, setSelectionCount] = useState<selectionCount>({
86115
rows: 0,
87116
cols: 0,
88117
ranges: [],
89118
});
90119
const [exportSelection, setExportSelection] = useState<ExportSelection>(
91120
() => {
92-
const savedSelection = exportSettings().selection;
121+
const savedSelection = exportSetting.selection;
93122
return validateExportSelection(savedSelection, selectionCount);
94123
}
95124
);
96-
const [delimitedOptions, setDelimitedOptions] = useState<ExportOptions>(
97-
exportSettings().options || {
98-
fieldSeparator: ",",
99-
lineTerminator: "\\n",
100-
encloser: '"',
101-
}
102-
);
103-
const [exportOptions, setExportOptions] = useState<ExportOptions | null>(
104-
() => {
105-
if (format === "csv") {
106-
return csvDelimeter;
107-
} else if (format === "xlsx") {
108-
return excelDiliemter;
109-
} else if (format === "delimited") {
110-
return delimitedOptions;
111-
} else {
112-
return null;
113-
}
114-
}
115-
);
116125

117126
const [selectedRangeIndex, setSelectedRangeIndex] = useState<number>(
118127
selectionCount.ranges.length > 0 ? 0 : -1
119128
);
120129
const [open, setOpen] = useState(false);
121130

122131
const onExportClicked = useCallback(() => {
123-
if (!format) return;
132+
if (!exportSetting.format) return;
124133

125134
let content = "";
126135

127136
const formatHandlers = getFormatHandlers(
128137
data,
129-
exportTarget,
138+
exportSetting.target,
130139
exportSelection,
131-
exportOptions,
140+
exportSetting.options!,
132141
selectedRangeIndex
133142
);
134143

135-
const handler = formatHandlers[format];
144+
const handler = formatHandlers[exportSetting.format];
136145
if (handler) {
137146
content = handler();
138147
}
@@ -144,15 +153,15 @@ export default function ExportResultButton({
144153
const url = URL.createObjectURL(blob);
145154
const a = document.createElement("a");
146155
a.href = url;
147-
a.download = `export.${format === "delimited" ? "csv" : format}`;
156+
a.download = `export.${exportSetting.format === "delimited" ? "csv" : exportSetting.format}`;
148157
a.click();
149158
URL.revokeObjectURL(url);
150159
}, [
151-
format,
160+
exportSetting.format,
161+
exportSetting.target,
162+
exportSetting.options,
152163
data,
153-
exportTarget,
154164
exportSelection,
155-
exportOptions,
156165
selectedRangeIndex,
157166
]);
158167

@@ -173,32 +182,13 @@ export default function ExportResultButton({
173182

174183
useEffect(() => {
175184
setExportSelection(
176-
validateExportSelection(exportSettings().selection, selectionCount)
185+
validateExportSelection(exportSetting.selection, selectionCount)
177186
);
178-
}, [exportSettings, selectionCount]);
187+
}, [exportSetting, selectionCount]);
179188

180189
useEffect(() => {
181-
saveSetting({
182-
...exportSettings(),
183-
format,
184-
selection: exportSelection,
185-
target: exportTarget,
186-
});
187-
if (format === "delimited") {
188-
saveSetting({
189-
...exportSettings(),
190-
options: exportOptions ?? csvDelimeter,
191-
});
192-
if (exportOptions) setDelimitedOptions(exportOptions);
193-
}
194-
}, [
195-
csvDelimeter,
196-
exportOptions,
197-
exportSelection,
198-
exportSettings,
199-
exportTarget,
200-
format,
201-
]);
190+
saveSettingToStorage(exportSetting);
191+
}, [exportSelection, exportSetting]);
202192

203193
const SelectedRange = ({
204194
ranges,
@@ -244,9 +234,12 @@ export default function ExportResultButton({
244234

245235
<RadioGroup
246236
className="flex gap-4"
247-
defaultValue={exportTarget}
237+
defaultValue={exportSetting.target}
248238
onValueChange={(e) => {
249-
setExportTarget(e as ExportTarget);
239+
setExportSetting((prev) => ({
240+
...prev,
241+
target: e as ExportTarget,
242+
}));
250243
}}
251244
>
252245
<div className="flex items-center space-x-2">
@@ -265,18 +258,17 @@ export default function ExportResultButton({
265258
<small>Output format</small>
266259
<RadioGroup
267260
className="mt-2 flex flex-col gap-3"
268-
defaultValue={format}
261+
defaultValue={exportSetting.format}
269262
onValueChange={(e) => {
270-
setFormat(e as ExportFormat);
271-
if (e === "csv") {
272-
setExportOptions(csvDelimeter);
273-
} else if (e === "xlsx") {
274-
setExportOptions(excelDiliemter);
275-
} else if (e === "delimited") {
276-
setExportOptions(delimitedOptions);
277-
} else {
278-
setExportOptions(null);
279-
}
263+
setExportSetting((prev) => ({
264+
...prev,
265+
format: e as ExportFormat,
266+
options: exportSetting.formatTemplate?.[
267+
e as ExportFormat
268+
] || {
269+
...getDefaultOption(e as ExportFormat),
270+
},
271+
}));
280272
}}
281273
>
282274
<div className="flex items-center space-x-2">
@@ -415,14 +407,17 @@ export default function ExportResultButton({
415407
<span className="w-[120px] text-sm">Field separator:</span>
416408
<div className="flex h-[28px] w-[120px] items-center rounded-md bg-white px-3 py-2.5 text-base text-neutral-900 outline outline-1 outline-neutral-200 focus:outline-neutral-400/70 dark:bg-neutral-900 dark:text-white dark:outline-neutral-800 dark:focus:outline-neutral-600">
417409
<input
418-
disabled={format !== "delimited"}
410+
disabled={exportSetting.format !== "delimited"}
419411
type="text"
420412
className="flex-1 bg-transparent text-sm font-light outline-hidden"
421-
value={exportOptions?.fieldSeparator || ""}
413+
value={exportSetting.options?.fieldSeparator || ""}
422414
onChange={(e) => {
423-
setExportOptions({
424-
...exportOptions,
425-
fieldSeparator: e.target.value,
415+
setExportSetting({
416+
...exportSetting,
417+
options: {
418+
...exportSetting.options,
419+
fieldSeparator: e.target.value,
420+
},
426421
});
427422
}}
428423
/>
@@ -432,14 +427,17 @@ export default function ExportResultButton({
432427
<span className="w-[120px] text-sm">Line terminator:</span>
433428
<div className="flex h-[28px] w-[120px] items-center rounded-md bg-white px-3 py-2.5 text-base text-neutral-900 outline outline-1 outline-neutral-200 focus:outline-neutral-400/70 dark:bg-neutral-900 dark:text-white dark:outline-neutral-800 dark:focus:outline-neutral-600">
434429
<input
435-
disabled={format !== "delimited"}
430+
disabled={exportSetting.format !== "delimited"}
436431
type="text"
437432
className="flex-1 bg-transparent text-sm font-light outline-hidden"
438-
value={exportOptions?.lineTerminator || ""}
433+
value={exportSetting.options?.lineTerminator || ""}
439434
onChange={(e) => {
440-
setExportOptions({
441-
...exportOptions,
442-
lineTerminator: e.target.value,
435+
setExportSetting({
436+
...exportSetting,
437+
options: {
438+
...exportSetting.options,
439+
lineTerminator: e.target.value,
440+
},
443441
});
444442
}}
445443
/>
@@ -450,14 +448,36 @@ export default function ExportResultButton({
450448
<span className="w-[120px] text-sm">Encloser:</span>
451449
<div className="flex h-[28px] w-[120px] items-center rounded-md bg-white px-3 py-2.5 text-base text-neutral-900 outline outline-1 outline-neutral-200 focus:outline-neutral-400/70 dark:bg-neutral-900 dark:text-white dark:outline-neutral-800 dark:focus:outline-neutral-600">
452450
<input
453-
disabled={format !== "delimited"}
451+
disabled={exportSetting.format !== "delimited"}
452+
type="text"
453+
className="flex-1 bg-transparent text-sm font-light outline-hidden"
454+
value={exportSetting.options?.encloser || ""}
455+
onChange={(e) => {
456+
setExportSetting({
457+
...exportSetting,
458+
options: {
459+
...exportSetting.options,
460+
encloser: e.target.value,
461+
},
462+
});
463+
}}
464+
/>
465+
</div>
466+
</div>
467+
<div className="flex items-center space-x-4">
468+
<span className="w-[120px] text-sm">NULL Value:</span>
469+
<div className="flex h-[28px] w-[120px] items-center rounded-md bg-white px-3 py-2.5 text-base text-neutral-900 outline outline-1 outline-neutral-200 focus:outline-neutral-400/70 dark:bg-neutral-900 dark:text-white dark:outline-neutral-800 dark:focus:outline-neutral-600">
470+
<input
454471
type="text"
455472
className="flex-1 bg-transparent text-sm font-light outline-hidden"
456-
value={exportOptions?.encloser || ""}
473+
value={exportSetting.options?.nullValue || ""}
457474
onChange={(e) => {
458-
setExportOptions({
459-
...exportOptions,
460-
encloser: e.target.value,
475+
setExportSetting({
476+
...exportSetting,
477+
options: {
478+
...exportSetting.options,
479+
nullValue: e.target.value,
480+
},
461481
});
462482
}}
463483
/>

src/drivers/sqlite/sql-helper.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DatabaseValue } from "@/drivers/base-driver";
22
import { hex } from "@/lib/bit-operation";
3+
import { parseUserInput } from "@/lib/export-helper";
34
import { ColumnType } from "@outerbase/sdk-transform";
45

56
export function escapeIdentity(str: string) {
@@ -21,9 +22,9 @@ export function escapeSqlBinary(value: ArrayBuffer) {
2122
return `x'${hex(value)}'`;
2223
}
2324

24-
export function escapeSqlValue(value: unknown) {
25+
export function escapeSqlValue(value: unknown, nullValue: string = "NULL") {
2526
if (value === undefined) return "DEFAULT";
26-
if (value === null) return "NULL";
27+
if (value === null) return parseUserInput(nullValue);
2728
if (typeof value === "string") return escapeSqlString(value);
2829
if (typeof value === "number") return value.toString();
2930
if (typeof value === "bigint") return value.toString();
@@ -84,10 +85,11 @@ export function escapeDelimitedValue(
8485
value: unknown,
8586
fieldSeparator: string,
8687
lineTerminator: string,
87-
encloser: string
88+
encloser: string,
89+
nullValue: string = "NULL"
8890
): string {
8991
if (value === null || value === undefined) {
90-
return "NULL";
92+
return nullValue;
9193
}
9294

9395
const stringValue = value.toString();

0 commit comments

Comments
 (0)