mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:55:51 +00:00
feat(export): support XLSX comment headers
This commit is contained in:
@@ -2012,13 +2012,11 @@ async function exportDataXlsx(row: ObjectBrowserRow) {
|
||||
|
||||
try {
|
||||
columnInfos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
|
||||
const hasComments = hasXlsxHeaderComments(columnInfos.map((column) => column.comment));
|
||||
const result = await showObjectBrowserXlsxHeaderDialog(hasComments);
|
||||
const result = await showObjectBrowserXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos.map((column) => column.comment)));
|
||||
if (result === null) return;
|
||||
headerMode = result;
|
||||
} catch {
|
||||
// Column fetch failed, fallback to export without comments
|
||||
columnInfos = undefined;
|
||||
// Export still works with field-name headers when column metadata is unavailable.
|
||||
}
|
||||
|
||||
await exportTableData(row, "xlsx", columnInfos, headerMode);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
describe("ObjectBrowser XLSX export", () => {
|
||||
it("asks for a header mode before opening the save dialog when comments exist", () => {
|
||||
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)'));
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
});
|
||||
+162
-2
@@ -1,19 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { shallowRef } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import type { ColumnInfo, TreeNode } from "@/types/database";
|
||||
|
||||
const source = readFileSync(new URL("../useSidebarTreeExportRuntime.ts", import.meta.url), "utf8");
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
const addExportTaskMock = vi.hoisted(() => vi.fn());
|
||||
const updateTableExportTaskMock = vi.hoisted(() => vi.fn());
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
buildTableSelectSql: vi.fn(async () => 'SELECT * FROM "main"."users" LIMIT 10000'),
|
||||
executeQuery: vi.fn(),
|
||||
exportQueryResultCsv: vi.fn(),
|
||||
exportQueryResultJson: vi.fn(),
|
||||
exportQueryResultXlsx: vi.fn(),
|
||||
getColumns: vi.fn(),
|
||||
getTableDdl: vi.fn(),
|
||||
startTableExport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) }));
|
||||
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addTask: vi.fn() }) }));
|
||||
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addTask: addExportTaskMock, updateTableExportTask: updateTableExportTaskMock }) }));
|
||||
vi.mock("@/i18n", () => ({ default: { install() {} } }));
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
@@ -27,9 +36,64 @@ vi.mock("vue-i18n", () => ({
|
||||
import { useSidebarTreeExportRuntime } from "@/composables/useSidebarTreeExportRuntime";
|
||||
import { showStructurePreviewDialog, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source);
|
||||
if (!signature) throw new Error(`Missing function ${name}`);
|
||||
const bodyStart = signature.index + signature[0].length;
|
||||
let depth = 1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") depth -= 1;
|
||||
if (depth === 0) return source.slice(bodyStart, index);
|
||||
}
|
||||
throw new Error(`Unclosed function ${name}`);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function column(name: string, isPrimaryKey = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: "text",
|
||||
is_nullable: !isPrimaryKey,
|
||||
column_default: null,
|
||||
is_primary_key: isPrimaryKey,
|
||||
extra: null,
|
||||
comment: null,
|
||||
};
|
||||
}
|
||||
|
||||
function exportSettings() {
|
||||
return {
|
||||
editorSettings: {
|
||||
exportBatchSize: 128,
|
||||
exportRowLimit: 500,
|
||||
exportRowLimitEnabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSidebarTreeExportRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
addExportTaskMock.mockImplementation((tableName: string, format: string, filePath: string) => ({
|
||||
exportId: "export-1",
|
||||
tableName,
|
||||
format,
|
||||
filePath,
|
||||
status: "Running",
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
}));
|
||||
apiMock.startTableExport.mockResolvedValue({});
|
||||
showStructurePreviewDialog.value = false;
|
||||
structurePreviewSql.value = "";
|
||||
structurePreviewTitle.value = "";
|
||||
@@ -86,4 +150,100 @@ describe("useSidebarTreeExportRuntime", () => {
|
||||
expect(structurePreviewTitle.value).toBe("contextMenu.exportStructurePreviewTitleMultiple");
|
||||
expect(showStructurePreviewDialog.value).toBe(true);
|
||||
});
|
||||
|
||||
it("prompts for the header mode before it opens the save dialog when comments exist", () => {
|
||||
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)'));
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
it("sends the selected mode's header overrides to both XLSX export paths", () => {
|
||||
const exportTableData = functionBody("exportTableData");
|
||||
|
||||
expect(exportTableData).toContain("buildXlsxHeaderOverrides(result.columns, comments, headerMode)");
|
||||
expect(exportTableData).toContain("columnComments,");
|
||||
});
|
||||
|
||||
it("keeps the original table and export options when selection changes during XLSX preparation", async () => {
|
||||
const metadata = deferred<ColumnInfo[]>();
|
||||
apiMock.getColumns.mockReturnValueOnce(metadata.promise);
|
||||
const originalNode = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db-a", schema: "public", catalog: "catalog-a", children: [] } as TreeNode;
|
||||
const activeNode = shallowRef(originalNode);
|
||||
const settingsStore = exportSettings();
|
||||
const connectionStore = {
|
||||
ensureConnected: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "postgres" })),
|
||||
connectionIdentifierQuote: vi.fn((connectionId: string) => (connectionId === "conn-1" ? '"' : "`")),
|
||||
treeNodes: [],
|
||||
selectedTreeNodeIds: [],
|
||||
};
|
||||
const runtime = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore: connectionStore as never,
|
||||
settingsStore: settingsStore as never,
|
||||
acceptedSelectionIds: () => null,
|
||||
});
|
||||
|
||||
const exportPromise = runtime.exportDataXlsx();
|
||||
await vi.waitFor(() => expect(apiMock.getColumns).toHaveBeenCalledWith("conn-1", "db-a", "public", "users", "catalog-a"));
|
||||
activeNode.value = { id: "table-2", type: "table", label: "orders", connectionId: "conn-2", database: "db-b", schema: "sales", children: [] } as TreeNode;
|
||||
settingsStore.editorSettings.exportBatchSize = 16;
|
||||
settingsStore.editorSettings.exportRowLimit = 10;
|
||||
metadata.resolve([column("name")]);
|
||||
|
||||
await exportPromise;
|
||||
|
||||
expect(apiMock.startTableExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: "conn-1",
|
||||
database: "db-a",
|
||||
schema: "public",
|
||||
identifierQuote: '"',
|
||||
tableName: "users",
|
||||
filePath: "users.xlsx",
|
||||
columns: ["name"],
|
||||
batchSize: 128,
|
||||
rowLimit: 500,
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit column order and primary keys for keyset XLSX export", async () => {
|
||||
apiMock.getColumns.mockResolvedValueOnce([column("display_name"), column("id", true), column("created_at")]);
|
||||
const activeNode = shallowRef({ id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode);
|
||||
const connectionStore = {
|
||||
ensureConnected: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "postgres" })),
|
||||
connectionIdentifierQuote: vi.fn(() => '"'),
|
||||
treeNodes: [],
|
||||
selectedTreeNodeIds: [],
|
||||
};
|
||||
const runtime = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore: connectionStore as never,
|
||||
settingsStore: exportSettings() as never,
|
||||
acceptedSelectionIds: () => null,
|
||||
});
|
||||
|
||||
await runtime.exportDataXlsx();
|
||||
|
||||
expect(apiMock.startTableExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
columns: ["display_name", "id", "created_at"],
|
||||
primaryKeys: ["id"],
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ShallowRef } from "vue";
|
||||
import { createApp, type ShallowRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import i18n from "@/i18n";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -14,6 +15,8 @@ import { joinExportedDdls } from "@/lib/export/ddlExport";
|
||||
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 { isLoadingStructurePreview, showStructureDocCopyDialog, showStructurePreviewDialog, structureDocCopyText, structureDocCopyTitle, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState";
|
||||
|
||||
type StructureCopyFormat = "tsv" | "markdown";
|
||||
@@ -25,6 +28,21 @@ interface SidebarTreeExportRuntimeOptions {
|
||||
acceptedSelectionIds: () => readonly string[] | null;
|
||||
}
|
||||
|
||||
interface SidebarTableExportTarget {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
metadataSchema: string;
|
||||
catalog?: string;
|
||||
tableName: string;
|
||||
tableType?: string;
|
||||
databaseType: ReturnType<typeof effectiveDatabaseTypeForConnection>;
|
||||
loadColumnMetadata: boolean;
|
||||
identifierQuote?: string;
|
||||
batchSize: number;
|
||||
rowLimit: number | null;
|
||||
}
|
||||
|
||||
export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOptions) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -237,18 +255,62 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTableData(format: "csv" | "xlsx" | "sql") {
|
||||
function showSidebarTreeXlsxHeaderDialog(hasComments: boolean): Promise<XlsxHeaderMode | null> {
|
||||
if (!hasComments) return Promise.resolve("name");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(XlsxHeaderDialog, {
|
||||
open: true,
|
||||
onConfirm: (mode: XlsxHeaderMode) => {
|
||||
resolve(mode);
|
||||
app.unmount();
|
||||
document.body.removeChild(container);
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve(null);
|
||||
app.unmount();
|
||||
document.body.removeChild(container);
|
||||
},
|
||||
});
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
});
|
||||
}
|
||||
|
||||
function currentTableExportTarget(): SidebarTableExportTarget | null {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
if (!node.connectionId || !node.database) return null;
|
||||
const connectionId = node.connectionId;
|
||||
const database = node.database;
|
||||
const config = connectionStore.getConfig(connectionId);
|
||||
if (!config) return;
|
||||
if (!config) return null;
|
||||
const editorSettings = settingsStore.editorSettings;
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
database,
|
||||
schema: node.schema || undefined,
|
||||
metadataSchema: node.schema || database,
|
||||
catalog: node.catalog,
|
||||
tableName: node.label,
|
||||
tableType: node.tableType,
|
||||
databaseType: effectiveDatabaseTypeForConnection(config),
|
||||
loadColumnMetadata: config.db_type === "neo4j",
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote(connectionId),
|
||||
batchSize: editorSettings.exportBatchSize,
|
||||
rowLimit: editorSettings.exportRowLimitEnabled ? editorSettings.exportRowLimit : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name") {
|
||||
const { connectionId, database } = target;
|
||||
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
// Choose the destination before registering a background task so cancellation creates no orphan tracker entry.
|
||||
let outputPath = `${node.label}.${format}`;
|
||||
let outputPath = `${target.tableName}.${format}`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filterName = format === "csv" ? "CSV" : format === "xlsx" ? "Excel" : "SQL";
|
||||
@@ -261,21 +323,25 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
}
|
||||
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
task = addExportTask(node.label, format, outputPath);
|
||||
task = addExportTask(target.tableName, format, outputPath);
|
||||
const currentTask = task;
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||
if (effectiveDbType === "victoriametrics") {
|
||||
const exportColumnInfos = columnInfos ?? (target.loadColumnMetadata ? await api.getColumns(connectionId, database, target.metadataSchema, target.tableName, target.catalog) : undefined);
|
||||
const queryColumns = exportColumnInfos?.map((column) => column.name);
|
||||
const primaryKeys = exportColumnInfos?.filter((column) => column.is_primary_key).map((column) => column.name);
|
||||
if (target.databaseType === "victoriametrics") {
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: effectiveDbType,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
tableType: node.tableType,
|
||||
databaseType: target.databaseType,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
executePage: (sql) => api.executeQuery(connectionId, database, sql),
|
||||
});
|
||||
if (format === "csv") {
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, result.rows);
|
||||
} else {
|
||||
await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.column_types ?? result.columns.map(() => ""), undefined, result.rows);
|
||||
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);
|
||||
}
|
||||
currentTask.status = "Done";
|
||||
currentTask.rowsExported = result.rows.length;
|
||||
@@ -283,21 +349,29 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
toast(t("grid.exported"));
|
||||
return;
|
||||
}
|
||||
const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined;
|
||||
const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null;
|
||||
const columnComments =
|
||||
format === "xlsx" && exportColumnInfos
|
||||
? buildXlsxHeaderOverrides(
|
||||
exportColumnInfos.map((column) => column.name),
|
||||
exportColumnInfos.map((column) => column.comment),
|
||||
headerMode,
|
||||
)
|
||||
: undefined;
|
||||
const request: api.TableExportRequest = {
|
||||
exportId: currentTask.exportId,
|
||||
connectionId,
|
||||
database,
|
||||
schema: node.schema || undefined,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote(connectionId),
|
||||
tableName: node.label,
|
||||
schema: target.schema,
|
||||
identifierQuote: target.identifierQuote,
|
||||
tableName: target.tableName,
|
||||
filePath: outputPath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
batchSize: settingsStore.editorSettings.exportBatchSize,
|
||||
columnComments,
|
||||
primaryKeys,
|
||||
batchSize: target.batchSize,
|
||||
skipCount: format === "sql",
|
||||
rowLimit,
|
||||
rowLimit: target.rowLimit,
|
||||
};
|
||||
|
||||
await api.startTableExport(request, (progress) => {
|
||||
@@ -316,11 +390,27 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
|
||||
async function exportData(format: "csv" | "json" | "sql") {
|
||||
if (format === "json") await exportDataLegacy(format);
|
||||
else await exportTableData(format);
|
||||
else {
|
||||
const target = currentTableExportTarget();
|
||||
if (target) await exportTableData(target, format);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDataXlsx() {
|
||||
await exportTableData("xlsx");
|
||||
const target = currentTableExportTarget();
|
||||
if (!target) return;
|
||||
|
||||
let columnInfos: ColumnInfo[] | undefined;
|
||||
try {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
columnInfos = await api.getColumns(target.connectionId, target.database, target.metadataSchema, target.tableName, target.catalog);
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user