feat(xlsx): add optional auto filter to xlsx export

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-27 12:18:42 +08:00
1 parent 185db5e8c6
commit d70a1231b0
29 files changed
+285 -119

No files matched your search

@@ -3,21 +3,25 @@ import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import type { XlsxExportOptions, XlsxHeaderMode } from "@/lib/export/xlsxHeader";
const { t } = useI18n();
const props = withDefaults(defineProps<{ showHeaderOptions?: boolean }>(), {
showHeaderOptions: true,
});
const open = defineModel<boolean>("open", { default: false });
const selected = ref<XlsxHeaderMode>("name");
const autoFilter = ref(false);
const emit = defineEmits<{
confirm: [mode: XlsxHeaderMode];
confirm: [options: XlsxExportOptions];
cancel: [];
}>();
function onConfirm() {
open.value = false;
emit("confirm", selected.value);
emit("confirm", { headerMode: selected.value, autoFilter: autoFilter.value });
}
function onCancel() {
@@ -34,22 +38,31 @@ function onOpenChange(value: boolean) {
<Dialog v-model:open="open" @update:open="onOpenChange">
<DialogContent class="sm:max-w-sm" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("grid.xlsxHeaderTitle") }}</DialogTitle>
<DialogTitle>{{ t("grid.xlsxExportTitle") }}</DialogTitle>
</DialogHeader>
<div class="py-2">
<p class="text-sm text-muted-foreground mb-3">{{ t("grid.xlsxHeaderPrompt") }}</p>
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="name" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderOriginal") }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="comment" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderComment") }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="name-comment" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderNameAndComment") }}</span>
<div class="space-y-5 py-2">
<div v-if="props.showHeaderOptions">
<p class="mb-3 text-sm text-muted-foreground">{{ t("grid.xlsxHeaderPrompt") }}</p>
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="name" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderOriginal") }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="comment" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderComment") }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="name-comment" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderNameAndComment") }}</span>
</label>
</div>
</div>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">{{ t("grid.xlsxFilterPrompt") }}</p>
<label class="flex cursor-pointer items-center gap-2">
<input v-model="autoFilter" type="checkbox" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxIncludeAutoFilter") }}</span>
</label>
</div>
</div>
@@ -31,14 +31,14 @@ describe("XlsxHeaderDialog", () => {
app.unmount();
});
it("emits the combined name and comment mode", async () => {
it("emits the selected header mode and filter option", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
let selectedMode = "";
let selectedOptions: { headerMode: string; autoFilter: boolean } | undefined;
const app = createApp(XlsxHeaderDialog, {
open: true,
onConfirm: (mode: string) => {
selectedMode = mode;
onConfirm: (options: { headerMode: string; autoFilter: boolean }) => {
selectedOptions = options;
},
});
app.use(i18n);
@@ -50,7 +50,7 @@ describe("XlsxHeaderDialog", () => {
document.querySelector<HTMLButtonElement>("[data-xlsx-header-confirm]")!.click();
await nextTick();
expect(selectedMode).toBe("name-comment");
expect(selectedOptions).toEqual({ headerMode: "name-comment", autoFilter: false });
app.unmount();
});
});
@@ -85,7 +85,7 @@ import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceSt
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
import {
defaultPasteTableMode,
@@ -1999,16 +1999,17 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
else await exportTableData(row, format);
}
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxHeaderMode | null> {
if (!hasComments) return Promise.resolve("name");
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
if (typeof document === "undefined") return Promise.resolve({ headerMode: "name", autoFilter: false });
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(XlsxHeaderDialog, {
open: true,
onConfirm: (mode: XlsxHeaderMode) => {
resolve(mode);
showHeaderOptions: hasComments,
onConfirm: (exportOptions: XlsxExportOptions) => {
resolve(exportOptions);
app.unmount();
document.body.removeChild(container);
},
@@ -2025,22 +2026,20 @@ function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxHe
async function exportDataXlsx(row: ObjectBrowserRow) {
const schema = row.schema || selectedSchema.value;
let headerMode: XlsxHeaderMode = "name";
let columnInfos: ColumnInfo[] | undefined;
try {
columnInfos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
const result = await showObjectBrowserXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos.map((column) => column.comment)));
if (result === null) return;
headerMode = result;
} catch {
// Export still works with field-name headers when column metadata is unavailable.
}
await exportTableData(row, "xlsx", columnInfos, headerMode);
const exportOptions = await showObjectBrowserXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment)));
if (exportOptions === null) return;
await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name") {
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true) {
const schema = row.schema || selectedSchema.value;
// Save dialog first
@@ -2080,7 +2079,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
} else {
const comments = result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment);
const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode);
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows);
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter);
}
toast(t("grid.exported"));
return;
@@ -2116,6 +2115,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
format,
columns,
columnComments: format === "xlsx" ? columnComments : undefined,
autoFilter: format === "xlsx" ? autoFilter : undefined,
batchSize: settingsStore.editorSettings.exportBatchSize,
skipCount: format === "sql",
rowLimit,
@@ -17,19 +17,19 @@ function functionBody(name: string): string {
}
describe("ObjectBrowser XLSX export", () => {
it("asks for a header mode before opening the save dialog when comments exist", () => {
it("asks for export options before opening the save dialog", () => {
const dialog = functionBody("showObjectBrowserXlsxHeaderDialog");
const exportDataXlsx = functionBody("exportDataXlsx");
expect(dialog).toContain('if (!hasComments) return Promise.resolve("name")');
expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos.map((column) => column.comment))");
expect(exportDataXlsx.indexOf("await showObjectBrowserXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(row, "xlsx", columnInfos, headerMode)'));
expect(dialog).toContain("showHeaderOptions: hasComments");
expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))");
expect(exportDataXlsx.indexOf("await showObjectBrowserXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)'));
});
it("falls back to field-name headers when column metadata is unavailable", () => {
const exportDataXlsx = functionBody("exportDataXlsx");
expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }");
expect(exportDataXlsx).toContain('await exportTableData(row, "xlsx", columnInfos, headerMode)');
expect(exportDataXlsx).toContain('await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)');
});
});
@@ -13,8 +13,8 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({
default: {
emits: ["confirm"],
mounted(this: { $emit: (event: string, value: string) => void }) {
queueMicrotask(() => this.$emit("confirm", "name-comment"));
mounted(this: { $emit: (event: string, value: { headerMode: string; autoFilter: boolean }) => void }) {
queueMicrotask(() => this.$emit("confirm", { headerMode: "name-comment", autoFilter: false }));
},
render() {
return null;
@@ -125,7 +125,11 @@ describe("useDataGridExport XLSX headers", () => {
await state.exportAllResultsXlsx();
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Identifier)"] }), expect.objectContaining({ sheetName: "Names", columnComments: ["name (Display name)"] })]));
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Identifier)"], autoFilter: false }), expect.objectContaining({ sheetName: "Names", columnComments: ["name (Display name)"], autoFilter: false })]),
false,
);
});
it("uses ordinal result comments when single-table metadata is unavailable", async () => {
@@ -137,6 +141,6 @@ describe("useDataGridExport XLSX headers", () => {
await state.exportAllResultsXlsx();
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"] })]));
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"], autoFilter: false })]), false);
});
});
@@ -151,20 +151,20 @@ describe("useSidebarTreeExportRuntime", () => {
expect(showStructurePreviewDialog.value).toBe(true);
});
it("prompts for the header mode before it opens the save dialog when comments exist", () => {
it("prompts for export options before it opens the save dialog", () => {
const exportDataXlsx = functionBody("exportDataXlsx");
expect(source).toContain('import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"');
expect(exportDataXlsx).toContain("await api.getColumns(");
expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))");
expect(exportDataXlsx.indexOf("await showSidebarTreeXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(target, "xlsx", columnInfos, headerMode)'));
expect(exportDataXlsx.indexOf("await showSidebarTreeXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)'));
});
it("falls back to field-name headers when column metadata is unavailable", () => {
const exportDataXlsx = functionBody("exportDataXlsx");
expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }");
expect(exportDataXlsx).toContain('await exportTableData(target, "xlsx", columnInfos, headerMode)');
expect(exportDataXlsx).toContain('await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter)');
});
it("sends the selected mode's header overrides to both XLSX export paths", () => {
@@ -27,7 +27,7 @@ import { formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter";
import { translateBackendError } from "@/i18n/backend-errors";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import i18n from "@/i18n";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
/**
* Format metadata for backend table exports. Each entry maps a format key
@@ -292,16 +292,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return targetColumns.map((column) => commentsByName.get(column.toLocaleLowerCase()));
}
function showXlsxHeaderDialog(): Promise<XlsxHeaderMode | null> {
if (!hasXlsxHeaderComments(allXlsxColumnComments.value) && !hasXlsxHeaderComments(visibleXlsxColumnComments.value)) return Promise.resolve("name");
function showXlsxHeaderDialog(): Promise<XlsxExportOptions | null> {
if (typeof document === "undefined") return Promise.resolve({ headerMode: "name", autoFilter: false });
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(XlsxHeaderDialog, {
open: true,
onConfirm: (mode: XlsxHeaderMode) => {
resolve(mode);
showHeaderOptions: hasXlsxHeaderComments(allXlsxColumnComments.value) || hasXlsxHeaderComments(visibleXlsxColumnComments.value),
onConfirm: (exportOptions: XlsxExportOptions) => {
resolve(exportOptions);
app.unmount();
document.body.removeChild(container);
},
@@ -402,24 +403,29 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return resultExportSql?.value || sql.value;
}
async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }, includeSqlSheet: boolean) {
async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }, includeSqlSheet: boolean, autoFilter: boolean) {
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet([{ sql: currentExportSql() || "" }]) : undefined;
const rightAlign = useSettingsStore().editorSettings.numericColumnRightAlign;
if (!sqlWorksheet) {
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.columnComments, result.rows, rightAlign);
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.columnComments, result.rows, rightAlign, autoFilter);
return;
}
await api.exportQueryResultsXlsx(outputPath, [
{
sheetName: currentXlsxSheetName(),
columns: result.columns,
columnTypes: result.columnTypes,
columnComments: result.columnComments,
rows: result.rows,
numericColumnRightAlign: rightAlign,
},
sqlWorksheet,
]);
await api.exportQueryResultsXlsx(
outputPath,
[
{
sheetName: currentXlsxSheetName(),
columns: result.columns,
columnTypes: result.columnTypes,
columnComments: result.columnComments,
rows: result.rows,
numericColumnRightAlign: rightAlign,
autoFilter,
},
{ ...sqlWorksheet, autoFilter },
],
autoFilter,
);
}
function targetedRows(): RowItem[] {
@@ -910,13 +916,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportXlsxResult(rowIds: number[] | undefined, includeSqlSheet: boolean) {
const headerMode = await showXlsxHeaderDialog();
if (headerMode === null) return;
const exportOptions = await showXlsxHeaderDialog();
if (exportOptions === null) return;
await runExclusiveExport(async () => {
try {
if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet, headerMode)) return;
if (await exportFullTableDataViaBackend("xlsx", rowIds, headerMode)) return;
if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet, exportOptions.headerMode, exportOptions.autoFilter)) return;
if (await exportFullTableDataViaBackend("xlsx", rowIds, exportOptions.headerMode, exportOptions.autoFilter)) return;
let outputPath = exportFileName("export", "xlsx");
if (isTauriRuntime()) {
@@ -958,7 +964,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
},
true,
true,
headerMode,
exportOptions.headerMode,
);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
@@ -968,7 +974,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
totalRows: result.rows.length,
};
}
await writeXlsxResult(outputPath, result, includeSqlSheet);
await writeXlsxResult(outputPath, result, includeSqlSheet, exportOptions.autoFilter);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
@@ -1001,8 +1007,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportCurrentPageXlsxResult(includeSqlSheet: boolean) {
const headerMode = await showXlsxHeaderDialog();
if (headerMode === null) return;
const exportOptions = await showXlsxHeaderDialog();
if (exportOptions === null) return;
await runExclusiveExport(async () => {
try {
@@ -1016,8 +1022,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!path) return;
outputPath = path as string;
}
const result = await resultToExport(undefined, undefined, false, true, headerMode);
await writeXlsxResult(outputPath, result, includeSqlSheet);
const result = await resultToExport(undefined, undefined, false, true, exportOptions.headerMode);
await writeXlsxResult(outputPath, result, includeSqlSheet, exportOptions.autoFilter);
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
@@ -1034,8 +1040,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportAllResultsXlsxResult(includeSqlSheet: boolean) {
const headerMode = await showXlsxHeaderDialog();
if (headerMode === null) return;
const exportOptions = await showXlsxHeaderDialog();
if (exportOptions === null) return;
await runExclusiveExport(async () => {
try {
@@ -1059,12 +1065,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
sheetName: sheet.sheetName,
columns: sheet.result.columns,
columnTypes: sheet.result.column_types ?? [],
columnComments: buildXlsxHeaderOverrides(sheet.result.columns, commentsForExportColumns(sheet.result.columns), headerMode),
columnComments: buildXlsxHeaderOverrides(sheet.result.columns, commentsForExportColumns(sheet.result.columns), exportOptions.headerMode),
rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern),
numericColumnRightAlign: rightAlign,
autoFilter: exportOptions.autoFilter,
}));
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet(sheets.map((sheet) => ({ resultName: sheet.sheetName, sql: sheet.sql || sheet.result.sourceStatement || "" }))) : undefined;
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, sqlWorksheet] : worksheets);
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, { ...sqlWorksheet, autoFilter: exportOptions.autoFilter }] : worksheets, exportOptions.autoFilter);
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
@@ -1080,7 +1087,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await exportAllResultsXlsxResult(true);
}
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name"): Promise<boolean> {
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name", autoFilter = true): Promise<boolean> {
const meta = tableMeta.value;
// The backend table exporter currently builds two-part table names. External
// Doris/StarRocks catalogs need the data-tab paginator's three-part SQL.
@@ -1150,6 +1157,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
rowLimit,
dateTimeFormat: editorSettings.globalDateTimeExportFormat || undefined,
numericColumnRightAlign: editorSettings.numericColumnRightAlign ?? true,
autoFilter: format === "xlsx" ? autoFilter : undefined,
},
(progress) => {
if (exportProgressState) {
@@ -1177,7 +1185,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return true;
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name"): Promise<boolean> {
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name", autoFilter = true): Promise<boolean> {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}
@@ -1210,7 +1218,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined,
});
const columnComments = format === "xlsx" ? buildXlsxHeaderOverrides(allColumns.value, allXlsxColumnComments.value, headerMode) : undefined;
const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments } : undefined;
const request = baseRequest
? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments, autoFilter: format === "xlsx" ? autoFilter : undefined }
: undefined;
if (!request) throw new Error("Unable to build query result export request");
if (exportProgressState) {
@@ -16,7 +16,7 @@ import { translateBackendError } from "@/i18n/backend-errors";
import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { isLoadingStructurePreview, showStructureDocCopyDialog, showStructurePreviewDialog, structureDocCopyText, structureDocCopyTitle, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
type StructureCopyFormat = "tsv" | "markdown";
@@ -255,16 +255,17 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
}
}
function showSidebarTreeXlsxHeaderDialog(hasComments: boolean): Promise<XlsxHeaderMode | null> {
if (!hasComments) return Promise.resolve("name");
function showSidebarTreeXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
if (typeof document === "undefined") return Promise.resolve({ headerMode: "name", autoFilter: false });
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(XlsxHeaderDialog, {
open: true,
onConfirm: (mode: XlsxHeaderMode) => {
resolve(mode);
showHeaderOptions: hasComments,
onConfirm: (exportOptions: XlsxExportOptions) => {
resolve(exportOptions);
app.unmount();
document.body.removeChild(container);
},
@@ -304,7 +305,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
};
}
async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name") {
async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true) {
const { connectionId, database } = target;
let task: ExportTask | null = null;
@@ -341,7 +342,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
} else {
const comments = result.columns.map((name) => exportColumnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment);
const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode);
await api.exportQueryResultXlsx(outputPath, target.tableName, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows);
await api.exportQueryResultXlsx(outputPath, target.tableName, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter);
}
currentTask.status = "Done";
currentTask.rowsExported = result.rows.length;
@@ -368,6 +369,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
format,
columns: queryColumns,
columnComments,
autoFilter: format === "xlsx" ? autoFilter : undefined,
primaryKeys,
batchSize: target.batchSize,
skipCount: format === "sql",
@@ -408,9 +410,9 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
// Export still works with field-name headers when column metadata is unavailable.
}
const headerMode = await showSidebarTreeXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment)));
if (headerMode === null) return;
await exportTableData(target, "xlsx", columnInfos, headerMode);
const exportOptions = await showSidebarTreeXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment)));
if (exportOptions === null) return;
await exportTableData(target, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
}
return {
+3
View File
@@ -1651,10 +1651,13 @@ export default {
exported: "Exported",
exportFailed: "Export failed: {message}",
xlsxHeaderTitle: "Header Format",
xlsxExportTitle: "Export Options",
xlsxHeaderPrompt: "Choose the header format for the exported Excel file:",
xlsxHeaderOriginal: "Use column names as headers",
xlsxHeaderComment: "Use column comments as headers",
xlsxHeaderNameAndComment: "Use column names and comments as headers",
xlsxFilterPrompt: "Choose whether the exported Excel file includes filters:",
xlsxIncludeAutoFilter: "Include filters",
copied: "Copied",
copyFailed: "Copy failed: {message}",
previewSqlEmpty: "No pending SQL changes to preview",
+3
View File
@@ -2046,10 +2046,13 @@ export default withEnglishFallback({
batchAppendPasteEmpty: "No hay filas nuevas en el portapapeles.",
batchAppendPasteReadonlyColumn: "Los datos pegados contienen columnas no editables.",
xlsxHeaderTitle: "Formato de encabezado",
xlsxExportTitle: "Opciones de exportacion",
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
xlsxHeaderComment: "Encabezado usando comentarios",
xlsxHeaderNameAndComment: "Encabezado usando nombres de campos y comentarios",
xlsxFilterPrompt: "Seleccione si el Excel exportado incluye filtros:",
xlsxIncludeAutoFilter: "Incluir filtros",
jumpToPage: "Ir a la página",
importBinaryValue: "Importar desde archivo",
binaryImportApplied: "Se importaron {count} bytes, que se escribirán en la base de datos al guardar los cambios.",
+3
View File
@@ -2044,10 +2044,13 @@ export default withEnglishFallback({
batchAppendPasteEmpty: "Negli appunti non ci sono righe da aggiungere.",
batchAppendPasteReadonlyColumn: "I dati incollati contengono colonne non modificabili.",
xlsxHeaderTitle: "Formato intestazione",
xlsxExportTitle: "Opzioni di esportazione",
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
xlsxHeaderOriginal: "Intestazione con nome campo",
xlsxHeaderComment: "Intestazione con commento",
xlsxHeaderNameAndComment: "Intestazione con nome campo e commento",
xlsxFilterPrompt: "Scegli se il file Excel esportato include filtri:",
xlsxIncludeAutoFilter: "Includi filtri",
jumpToPage: "Vai alla pagina",
importBinaryValue: "Importa da file",
binaryImportApplied: "Importati {count} byte, scritti nel database dopo il salvataggio delle modifiche.",
+3
View File
@@ -2066,10 +2066,13 @@ export default withEnglishFallback({
batchAppendPasteEmpty: "クリップボードに追加可能な行がありません。",
batchAppendPasteReadonlyColumn: "貼り付けたデータに編集不可能な列が含まれています。",
xlsxHeaderTitle: "ヘッダー形式",
xlsxExportTitle: "エクスポートオプション",
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
xlsxHeaderOriginal: "ヘッダーにフィールド名を使用",
xlsxHeaderComment: "ヘッダーにコメントを使用",
xlsxHeaderNameAndComment: "ヘッダーにフィールド名とコメントを使用",
xlsxFilterPrompt: "エクスポートする Excel にフィルターを含めるか選択してください:",
xlsxIncludeAutoFilter: "フィルターを含める",
searchMode: "検索モード",
searchModeHint: "Ctrl+F 検索時に一致する行をフィルタリングするか、全行を表示したまま一致箇所をハイライトするかを選択します。",
searchModeFilter: "フィルター",
+3
View File
@@ -1955,6 +1955,9 @@ export default withEnglishFallback({
sortUnsupported: "이 SQL은 전체 결과 정렬을 지원하지 않습니다. 단일 SELECT 쿼리로 다시 시도하세요.",
truncatedHint: "{count}행 로드 후 결과가 잘렸습니다. 로드된 데이터를 탐색하려면 푸터 페이지 매김을 사용하세요. 전체 결과를 내보내면 데이터베이스 쿼리를 다시 실행합니다.",
victoriaMetricsTruncatedHint: "결과가 로드 한도를 초과하여 처음 {count}행만 로드했습니다. 페이지당 행 수는 로드된 데이터만 나눠 표시합니다. 전체 결과를 가져오려면 시간 범위를 줄이거나 필터를 추가하세요.",
xlsxExportTitle: "내보내기 옵션",
xlsxFilterPrompt: "내보낸 Excel에 필터를 포함할지 선택하십시오:",
xlsxIncludeAutoFilter: "필터 포함",
},
exportProgress: {
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
+3
View File
@@ -2046,10 +2046,13 @@ export default withEnglishFallback({
batchAppendPasteEmpty: "Não há linhas para adicionar na área de transferência.",
batchAppendPasteReadonlyColumn: "Os dados colados contêm colunas não editáveis.",
xlsxHeaderTitle: "Formato do cabeçalho",
xlsxExportTitle: "Opcoes de exportacao",
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
xlsxHeaderComment: "Cabeçalho usa comentário",
xlsxHeaderNameAndComment: "Cabeçalho usa nome do campo e comentário",
xlsxFilterPrompt: "Selecione se o Excel exportado inclui filtros:",
xlsxIncludeAutoFilter: "Incluir filtros",
jumpToPage: "Ir para a página",
importBinaryValue: "Importar do arquivo",
binaryImportApplied: "{count} bytes importados, gravados no banco de dados após salvar as alterações.",
+3
View File
@@ -1575,10 +1575,13 @@ export default withEnglishFallback({
exported: "导出完成",
exportFailed: "导出失败:{message}",
xlsxHeaderTitle: "表头格式",
xlsxExportTitle: "导出选项",
xlsxHeaderPrompt: "请选择导出 Excel 时使用的表头格式:",
xlsxHeaderOriginal: "表头使用字段名称",
xlsxHeaderComment: "表头使用注释",
xlsxHeaderNameAndComment: "表头使用字段名称 + 注释",
xlsxFilterPrompt: "请选择是否在导出的 Excel 中包含筛选:",
xlsxIncludeAutoFilter: "包含筛选",
copied: "已复制",
cut: "已剪切",
copyFailed: "复制失败:{message}",
+3
View File
@@ -2050,10 +2050,13 @@ export default withEnglishFallback({
batchAppendPasteEmpty: "剪貼簿中沒有可新增的行。",
batchAppendPasteReadonlyColumn: "貼上的資料包含不可編輯的列。",
xlsxHeaderTitle: "表頭格式",
xlsxExportTitle: "匯出選項",
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
xlsxHeaderOriginal: "表頭使用欄位名稱",
xlsxHeaderComment: "表頭使用註解",
xlsxHeaderNameAndComment: "表頭使用欄位名稱 + 註解",
xlsxFilterPrompt: "請選擇匯出的 Excel 是否包含篩選:",
xlsxIncludeAutoFilter: "包含篩選",
cut: "已剪下",
},
exportProgress: {
+14 -2
View File
@@ -2535,7 +2535,16 @@ function downloadTextFile(filePath: string, fallbackFileName: string, content: s
URL.revokeObjectURL(url);
}
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], columnComments: readonly (string | null)[] | undefined, rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
export async function exportQueryResultXlsx(
filePath: string,
sheetName: string | undefined,
columns: string[],
columnTypes: string[],
columnComments: readonly (string | null)[] | undefined,
rows: readonly (readonly XlsxCellValue[])[],
numericColumnRightAlign?: boolean,
autoFilter?: boolean,
): Promise<void> {
const { buildXlsxWorkbook } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbook({
sheetName: sheetName || "Export",
@@ -2544,6 +2553,7 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string
columnComments,
rows,
numericColumnRightAlign,
autoFilter,
});
const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx";
const blob = new Blob([new Uint8Array(workbook)], {
@@ -2566,10 +2576,12 @@ export async function exportQueryResultsXlsx(
columnComments?: readonly (string | null)[];
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
autoFilter?: boolean;
}[],
autoFilter?: boolean,
): Promise<void> {
const { buildXlsxWorkbookMulti } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbookMulti(worksheets);
const workbook = buildXlsxWorkbookMulti(autoFilter === undefined ? worksheets : worksheets.map((worksheet) => ({ ...worksheet, autoFilter })));
const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx";
const blob = new Blob([new Uint8Array(workbook)], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+16 -1
View File
@@ -4659,6 +4659,7 @@ export interface TableExportRequest {
rowLimit?: number | null;
dateTimeFormat?: string;
numericColumnRightAlign?: boolean;
autoFilter?: boolean;
}
export interface TableCsvExportOptions {
@@ -4707,6 +4708,7 @@ export interface QueryResultExportRequest {
exportColumnTypes?: Array<string | null | undefined>;
numericColumnRightAlign?: boolean;
columnComments?: Array<string | null> | null;
autoFilter?: boolean;
identifierQuote?: string;
}
@@ -4846,7 +4848,16 @@ export async function exportTableDataCsv(options: TableCsvExportOptions): Promis
return invoke("export_table_data_csv", { request: options });
}
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], columnComments: readonly (string | null)[] | undefined, rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
export async function exportQueryResultXlsx(
filePath: string,
sheetName: string | undefined,
columns: string[],
columnTypes: string[],
columnComments: readonly (string | null)[] | undefined,
rows: readonly (readonly XlsxCellValue[])[],
numericColumnRightAlign?: boolean,
autoFilter?: boolean,
): Promise<void> {
return invoke("export_query_result_xlsx", {
request: {
filePath,
@@ -4856,6 +4867,7 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string
columnComments,
rows,
numericColumnRightAlign,
autoFilter,
},
});
}
@@ -4869,12 +4881,15 @@ export async function exportQueryResultsXlsx(
columnComments?: readonly (string | null)[];
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
autoFilter?: boolean;
}[],
autoFilter?: boolean,
): Promise<void> {
return invoke("export_query_results_xlsx", {
request: {
filePath,
worksheets,
autoFilter,
},
});
}
+4 -2
View File
@@ -9,6 +9,7 @@ export interface XlsxWorksheetData {
columnComments?: readonly (string | null)[] | undefined;
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
autoFilter?: boolean;
}
interface XlsxWorksheetSegment {
@@ -174,14 +175,15 @@ function worksheetXml(segment: XlsxWorksheetSegment): string {
return `<row r="${excelRowIndex}">${cells}</row>`;
}).join("");
const autoFilterXml = data.autoFilter === false ? "" : `\n <autoFilter ref="${range}"/>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="${range}"/>
<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>
<sheetFormatPr defaultRowHeight="15"/>
<cols>${colsXml}</cols>
<sheetData>${headerXml}${bodyXml}</sheetData>
<autoFilter ref="${range}"/>
<sheetData>${headerXml}${bodyXml}</sheetData>${autoFilterXml}
</worksheet>`;
}
@@ -1,5 +1,10 @@
export type XlsxHeaderMode = "name" | "comment" | "name-comment";
export interface XlsxExportOptions {
headerMode: XlsxHeaderMode;
autoFilter: boolean;
}
export function hasXlsxHeaderComments(comments: readonly (string | null | undefined)[] | undefined): boolean {
return comments?.some((comment) => !!comment?.trim()) ?? false;
}