mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
✨ feat(import-export): 优化数据表导入导出能力
- 修复表格格式导出时空值显示为 null 的问题 - 支持按列导出并保持字段顺序 - 支持导入列与数据库字段映射及冲突校验 - 隔离并发导入任务进度并完善元数据回退 Fixes #646
This commit is contained in:
64
frontend/src/components/DataExportDialog.columns.test.ts
Normal file
64
frontend/src/components/DataExportDialog.columns.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeDataExportDialogValues,
|
||||
resolveDataExportColumns,
|
||||
validateDataExportDialogValues,
|
||||
type DataExportScopeOption,
|
||||
} from './DataExportDialog';
|
||||
|
||||
const scopeOptions: DataExportScopeOption[] = [
|
||||
{ value: 'all', label: 'All rows' },
|
||||
];
|
||||
|
||||
describe('DataExportDialog column selection', () => {
|
||||
it('selects every available column by default in source order', () => {
|
||||
expect(resolveDataExportColumns(undefined, ['id', 'name', 'created_at'])).toEqual([
|
||||
'id',
|
||||
'name',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
expect(normalizeDataExportDialogValues(
|
||||
scopeOptions,
|
||||
{ format: 'csv', scope: 'all' },
|
||||
false,
|
||||
['id', 'name', 'created_at'],
|
||||
).columns).toEqual(['id', 'name', 'created_at']);
|
||||
});
|
||||
|
||||
it('drops unknown and duplicate selections while preserving available column order', () => {
|
||||
expect(resolveDataExportColumns(
|
||||
['created_at', 'missing', 'id', 'created_at'],
|
||||
['id', 'name', 'created_at'],
|
||||
)).toEqual(['id', 'created_at']);
|
||||
});
|
||||
|
||||
it('requires at least one column when columns are available', () => {
|
||||
const values = normalizeDataExportDialogValues(
|
||||
scopeOptions,
|
||||
{ format: 'csv', scope: 'all', columns: [] },
|
||||
false,
|
||||
['id', 'name'],
|
||||
);
|
||||
|
||||
expect(validateDataExportDialogValues(values, scopeOptions, false, ['id', 'name'])).toBeTruthy();
|
||||
expect(validateDataExportDialogValues(
|
||||
{ ...values, columns: ['name'] },
|
||||
scopeOptions,
|
||||
false,
|
||||
['id', 'name'],
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps legacy exports without a column catalog unrestricted', () => {
|
||||
const values = normalizeDataExportDialogValues(
|
||||
scopeOptions,
|
||||
{ format: 'csv', scope: 'all' },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(values.columns).toBeUndefined();
|
||||
expect(validateDataExportDialogValues(values, scopeOptions)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
|
||||
|
||||
export type DataExportFileOptions = {
|
||||
format: DataExportFormat;
|
||||
columns?: string[];
|
||||
xlsxMaxRowsPerSheet?: number;
|
||||
insertSQLDialect?: string;
|
||||
insertSQLTargetTable?: string;
|
||||
@@ -31,6 +32,7 @@ export type DataExportScopeOption = {
|
||||
export type ShowDataExportDialogOptions = {
|
||||
title: string;
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
availableColumns?: string[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
allowInsertSql?: boolean;
|
||||
okText?: string;
|
||||
@@ -66,10 +68,30 @@ const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope
|
||||
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
|
||||
};
|
||||
|
||||
const normalizeDialogValues = (
|
||||
export const resolveDataExportColumns = (
|
||||
requestedColumns: string[] | undefined,
|
||||
availableColumns: string[] | undefined,
|
||||
): string[] | undefined => {
|
||||
if (!Array.isArray(availableColumns)) return undefined;
|
||||
|
||||
const seenAvailable = new Set<string>();
|
||||
const normalizedAvailable = availableColumns.filter((column) => {
|
||||
const value = String(column ?? '');
|
||||
if (!value.trim() || seenAvailable.has(value)) return false;
|
||||
seenAvailable.add(value);
|
||||
return true;
|
||||
});
|
||||
if (requestedColumns === undefined) return normalizedAvailable;
|
||||
|
||||
const requested = new Set(requestedColumns.map((column) => String(column ?? '')));
|
||||
return normalizedAvailable.filter((column) => requested.has(column));
|
||||
};
|
||||
|
||||
export const normalizeDataExportDialogValues = (
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
initialValues?: Partial<DataExportDialogValues>,
|
||||
allowInsertSql = false,
|
||||
availableColumns?: string[],
|
||||
): DataExportDialogValues => {
|
||||
const requestedFormat = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
|
||||
const format = resolveFormatOptions(allowInsertSql).some((item) => item.value === requestedFormat)
|
||||
@@ -79,17 +101,20 @@ const normalizeDialogValues = (
|
||||
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
|
||||
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
|
||||
: DEFAULT_XLSX_ROWS_PER_SHEET;
|
||||
const columns = resolveDataExportColumns(initialValues?.columns, availableColumns);
|
||||
return {
|
||||
format,
|
||||
scope,
|
||||
xlsxMaxRowsPerSheet,
|
||||
...(columns === undefined ? {} : { columns }),
|
||||
};
|
||||
};
|
||||
|
||||
const validateDialogValues = (
|
||||
export const validateDataExportDialogValues = (
|
||||
values: DataExportDialogValues,
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
allowInsertSql = false,
|
||||
availableColumns?: string[],
|
||||
): string | null => {
|
||||
if (!resolveFormatOptions(allowInsertSql).some((item) => item.value === values.format)) {
|
||||
return t('data_export.dialog.validation.format_required');
|
||||
@@ -100,6 +125,9 @@ const validateDialogValues = (
|
||||
return t('data_export.dialog.validation.scope_required');
|
||||
}
|
||||
}
|
||||
if (Array.isArray(availableColumns) && (!Array.isArray(values.columns) || values.columns.length === 0)) {
|
||||
return t('data_export.dialog.validation.columns_required');
|
||||
}
|
||||
if (values.format === 'xlsx') {
|
||||
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
|
||||
if (!Number.isFinite(rows) || rows <= 0) {
|
||||
@@ -116,12 +144,25 @@ const validateDialogValues = (
|
||||
|
||||
const DataExportDialogContent: React.FC<{
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
availableColumns?: string[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
allowInsertSql?: boolean;
|
||||
onChange: (values: DataExportDialogValues) => void;
|
||||
}> = ({ scopeOptions, initialValues, allowInsertSql = false, onChange }) => {
|
||||
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues, allowInsertSql));
|
||||
}> = ({ scopeOptions, availableColumns, initialValues, allowInsertSql = false, onChange }) => {
|
||||
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDataExportDialogValues(
|
||||
scopeOptions,
|
||||
initialValues,
|
||||
allowInsertSql,
|
||||
availableColumns,
|
||||
));
|
||||
const formatOptions = useMemo(() => resolveFormatOptions(allowInsertSql), [allowInsertSql]);
|
||||
const columnOptions = useMemo(
|
||||
() => (resolveDataExportColumns(undefined, availableColumns) || []).map((column) => ({
|
||||
value: column,
|
||||
label: column,
|
||||
})),
|
||||
[availableColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(values);
|
||||
@@ -162,6 +203,25 @@ const DataExportDialogContent: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(availableColumns) && (
|
||||
<Form.Item
|
||||
label={t('data_export.dialog.field.columns')}
|
||||
extra={t('data_export.dialog.field.columns_help')}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={values.columns || []}
|
||||
options={columnOptions}
|
||||
maxTagCount="responsive"
|
||||
onChange={(columns) => setValues((prev) => ({
|
||||
...prev,
|
||||
columns: resolveDataExportColumns(columns, availableColumns) || [],
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{values.format === 'xlsx' && (
|
||||
<Form.Item
|
||||
label={t('data_export.dialog.field.xlsx_max_rows')}
|
||||
@@ -195,7 +255,12 @@ export async function showDataExportDialog(
|
||||
options: ShowDataExportDialogOptions,
|
||||
): Promise<DataExportDialogValues | null> {
|
||||
const allowInsertSql = options.allowInsertSql === true;
|
||||
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues, allowInsertSql);
|
||||
const initialValues = normalizeDataExportDialogValues(
|
||||
options.scopeOptions,
|
||||
options.initialValues,
|
||||
allowInsertSql,
|
||||
options.availableColumns,
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -218,6 +283,7 @@ export async function showDataExportDialog(
|
||||
content: (
|
||||
<DataExportDialogContent
|
||||
scopeOptions={options.scopeOptions}
|
||||
availableColumns={options.availableColumns}
|
||||
initialValues={initialValues}
|
||||
allowInsertSql={allowInsertSql}
|
||||
onChange={(values) => {
|
||||
@@ -226,7 +292,12 @@ export async function showDataExportDialog(
|
||||
/>
|
||||
),
|
||||
onOk: async () => {
|
||||
const errorMessage = validateDialogValues(latestValues, options.scopeOptions, allowInsertSql);
|
||||
const errorMessage = validateDataExportDialogValues(
|
||||
latestValues,
|
||||
options.scopeOptions,
|
||||
allowInsertSql,
|
||||
options.availableColumns,
|
||||
);
|
||||
if (errorMessage) {
|
||||
void message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
|
||||
18
frontend/src/components/DataGrid.export-columns.test.ts
Normal file
18
frontend/src/components/DataGrid.export-columns.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const actionsSource = readFileSync(new URL('./useDataGridV2Actions.ts', import.meta.url), 'utf8');
|
||||
const gridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid export columns', () => {
|
||||
it('offers result columns in the export dialog and forwards the selected order', () => {
|
||||
expect(actionsSource).toContain('availableColumns: displayOutputColumnNames');
|
||||
expect(actionsSource).toContain('columns: values.columns');
|
||||
});
|
||||
|
||||
it('uses selected columns for local row projection and backend ExportData arguments', () => {
|
||||
expect(gridSource).toContain('resolveDataExportColumns(options.columns, displayOutputColumnNames)');
|
||||
expect(gridSource).toContain('pickDataGridOutputRows(rows, exportColumns)');
|
||||
expect(gridSource).toMatch(/ExportDataWithOptions\(\s*cleanRows,\s*exportColumns,/);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,7 @@ import { buildDataGridTransactionLog } from './dataGridTransactionLog';
|
||||
import {
|
||||
DEFAULT_DATA_EXPORT_FORMAT,
|
||||
DEFAULT_XLSX_ROWS_PER_SHEET,
|
||||
resolveDataExportColumns,
|
||||
showDataExportDialog,
|
||||
type DataExportDialogValues,
|
||||
type DataExportFileOptions,
|
||||
@@ -1079,7 +1080,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// Helper to export specific data
|
||||
const exportData = async (rows: any[], options: DataExportFileOptions) => {
|
||||
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
|
||||
const exportColumns = resolveDataExportColumns(options.columns, displayOutputColumnNames)
|
||||
|| displayOutputColumnNames;
|
||||
const cleanRows = pickDataGridOutputRows(rows, exportColumns);
|
||||
const exportTitle = String(tableName || '').trim()
|
||||
? translateDataGrid('file.backend.dialog.export_table', { table: tableName })
|
||||
: translateDataGrid('file.backend.dialog.export_data');
|
||||
@@ -1090,7 +1093,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
totalRows: cleanRows.length,
|
||||
run: (jobId) => ExportDataWithOptions(
|
||||
cleanRows,
|
||||
displayOutputColumnNames,
|
||||
exportColumns,
|
||||
tableName || 'export',
|
||||
{
|
||||
...buildBackendExportOptions(options),
|
||||
|
||||
@@ -8,7 +8,8 @@ import ImportPreviewModal from "./ImportPreviewModal";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
previewImportFile: vi.fn(),
|
||||
importDataWithProgress: vi.fn(),
|
||||
dbGetColumns: vi.fn(),
|
||||
importDataWithProgressOptions: vi.fn(),
|
||||
progressHandler: null as ((data: any) => void) | null,
|
||||
eventsOn: vi.fn((_event: string, handler: (data: any) => void) => {
|
||||
mocks.progressHandler = handler;
|
||||
@@ -44,7 +45,8 @@ vi.mock("../i18n/runtime", () => ({
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
PreviewImportFile: mocks.previewImportFile,
|
||||
ImportDataWithProgress: mocks.importDataWithProgress,
|
||||
DBGetColumns: mocks.dbGetColumns,
|
||||
ImportDataWithProgressOptions: mocks.importDataWithProgressOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../../wailsjs/runtime/runtime", () => ({
|
||||
@@ -108,10 +110,29 @@ vi.mock("antd", async () => {
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => React.createElement("button", { onClick }, children),
|
||||
disabled?: boolean;
|
||||
}) => React.createElement("button", { onClick, disabled }, children),
|
||||
Select: ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
options?: Array<{ value: string; label: React.ReactNode; disabled?: boolean }>;
|
||||
onChange?: (value: string) => void;
|
||||
}) => React.createElement(
|
||||
"select",
|
||||
{ value, onChange: (event: any) => onChange?.(event.target.value) },
|
||||
options?.map((option) => React.createElement(
|
||||
"option",
|
||||
{ key: option.value, value: option.value, disabled: option.disabled },
|
||||
option.label,
|
||||
)),
|
||||
),
|
||||
Space: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
};
|
||||
@@ -134,22 +155,24 @@ const textContent = (node: any): string => {
|
||||
return textContent(node.children || []);
|
||||
};
|
||||
|
||||
const renderImportPreview = async () => {
|
||||
const createImportPreviewTree = (filePath = "D:/imports/users.csv") => (
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<ImportPreviewModal
|
||||
visible
|
||||
filePath={filePath}
|
||||
connectionId="conn-1"
|
||||
dbName="app"
|
||||
tableName="users"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const renderImportPreview = async (filePath = "D:/imports/users.csv") => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<ImportPreviewModal
|
||||
visible
|
||||
filePath="D:/imports/users.csv"
|
||||
connectionId="conn-1"
|
||||
dbName="app"
|
||||
tableName="users"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
renderer = create(createImportPreviewTree(filePath));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -158,6 +181,20 @@ const renderImportPreview = async () => {
|
||||
|
||||
describe("ImportPreviewModal i18n", () => {
|
||||
beforeEach(() => {
|
||||
mocks.storeState.connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
config: {
|
||||
type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "app",
|
||||
},
|
||||
},
|
||||
];
|
||||
mocks.previewImportFile.mockReset();
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -166,7 +203,17 @@ describe("ImportPreviewModal i18n", () => {
|
||||
previewRows: [{ id: 1, user_name: "alice" }],
|
||||
},
|
||||
});
|
||||
mocks.importDataWithProgress.mockReset();
|
||||
mocks.dbGetColumns.mockReset();
|
||||
mocks.dbGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ name: "ID", type: "bigint" },
|
||||
{ name: "username", type: "varchar" },
|
||||
{ name: "email", type: "varchar" },
|
||||
],
|
||||
});
|
||||
mocks.importDataWithProgressOptions.mockReset();
|
||||
mocks.progressHandler = null;
|
||||
mocks.eventsOn.mockClear();
|
||||
mocks.eventsOff.mockClear();
|
||||
});
|
||||
@@ -205,7 +252,7 @@ describe("ImportPreviewModal i18n", () => {
|
||||
|
||||
it("keeps preview total when progress events omit total rows", async () => {
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgress.mockImplementation(
|
||||
mocks.importDataWithProgressOptions.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
@@ -224,9 +271,18 @@ describe("ImportPreviewModal i18n", () => {
|
||||
});
|
||||
|
||||
expect(mocks.progressHandler).toBeTypeOf("function");
|
||||
const importJobId = mocks.importDataWithProgressOptions.mock.calls[0][4].jobId;
|
||||
|
||||
await act(async () => {
|
||||
mocks.progressHandler?.({
|
||||
jobId: "another-import-job",
|
||||
current: 9,
|
||||
total: 12,
|
||||
success: 9,
|
||||
errors: 0,
|
||||
});
|
||||
mocks.progressHandler?.({
|
||||
jobId: importJobId,
|
||||
current: 3,
|
||||
total: 0,
|
||||
success: 3,
|
||||
@@ -247,4 +303,221 @@ describe("ImportPreviewModal i18n", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("maps file headers to database fields and submits only selected mappings", async () => {
|
||||
mocks.importDataWithProgressOptions.mockResolvedValue({
|
||||
success: true,
|
||||
data: { success: 12, failed: 0, total: 12, errorLogs: [] },
|
||||
});
|
||||
const renderer = await renderImportPreview();
|
||||
|
||||
const selects = renderer.root.findAllByType("select");
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(selects[0].props.value).toBe("ID");
|
||||
expect(selects[1].props.value).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
selects[1].props.onChange({ target: { value: "username" } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
expect(button?.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
button?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "mysql" }),
|
||||
"app",
|
||||
"users",
|
||||
"D:/imports/users.csv",
|
||||
{
|
||||
columnMappings: { id: "ID", user_name: "username" },
|
||||
jobId: expect.stringMatching(/^import-/),
|
||||
},
|
||||
);
|
||||
expect(mocks.dbGetColumns).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "mysql" }),
|
||||
"app",
|
||||
"users",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables import until at least one source column is mapped", async () => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["legacy_name"],
|
||||
totalRows: 1,
|
||||
previewRows: [{ legacy_name: "alice" }],
|
||||
},
|
||||
});
|
||||
const renderer = await renderImportPreview();
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
expect(button?.props.disabled).toBe(true);
|
||||
expect(textContent(renderer.toJSON())).toContain("Map at least one file column");
|
||||
});
|
||||
|
||||
it("ignores stale preview responses after switching files", async () => {
|
||||
let resolveFirstPreview!: (value: any) => void;
|
||||
mocks.previewImportFile
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFirstPreview = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["email"],
|
||||
totalRows: 1,
|
||||
previewRows: [{ email: "new@example.com" }],
|
||||
},
|
||||
});
|
||||
|
||||
const renderer = await renderImportPreview("D:/imports/old.csv");
|
||||
await act(async () => {
|
||||
renderer.update(createImportPreviewTree("D:/imports/new.csv"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(textContent(renderer.toJSON())).toContain("new@example.com");
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstPreview({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["user_name"],
|
||||
totalRows: 1,
|
||||
previewRows: [{ user_name: "stale-user" }],
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
expect(renderedText).toContain("new@example.com");
|
||||
expect(renderedText).not.toContain("stale-user");
|
||||
});
|
||||
|
||||
it("ignores blank file headers when building column mappings", async () => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["", "id", " "],
|
||||
totalRows: 1,
|
||||
previewRows: [{ id: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
const renderer = await renderImportPreview();
|
||||
const selects = renderer.root.findAllByType("select");
|
||||
expect(selects).toHaveLength(1);
|
||||
expect(selects[0].props.value).toBe("ID");
|
||||
});
|
||||
|
||||
it("keeps a pending import locked and preserves partial failures when connection state changes", async () => {
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
);
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.storeState.connections = mocks.storeState.connections.map((item) => ({
|
||||
...item,
|
||||
config: { ...item.config, host: "changed-host" },
|
||||
}));
|
||||
await act(async () => {
|
||||
renderer.update(createImportPreviewTree());
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importDataWithProgressOptions).toHaveBeenCalledTimes(1);
|
||||
expect(textContent(renderer.toJSON())).toContain("Importing data");
|
||||
expect(textContent(renderer.toJSON())).not.toContain("Start import");
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({
|
||||
success: true,
|
||||
data: {
|
||||
success: 11,
|
||||
failed: 1,
|
||||
total: 12,
|
||||
errorLogs: ["Row 12: duplicate key"],
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mocks.eventsOff).not.toHaveBeenCalled();
|
||||
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
|
||||
expect(textContent(renderer.toJSON())).toContain("Failed 1 rows");
|
||||
expect(textContent(renderer.toJSON())).toContain("Row 12: duplicate key");
|
||||
});
|
||||
|
||||
it("preserves an RPC failure when connection state changes during import", async () => {
|
||||
let resolveImport!: (value: any) => void;
|
||||
mocks.importDataWithProgressOptions.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
);
|
||||
const renderer = await renderImportPreview();
|
||||
const startButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((node) => textContent(node.props.children) === "Start import");
|
||||
|
||||
await act(async () => {
|
||||
startButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
mocks.storeState.connections = mocks.storeState.connections.map((item) => ({
|
||||
...item,
|
||||
config: { ...item.config, host: "changed-host" },
|
||||
}));
|
||||
await act(async () => {
|
||||
renderer.update(createImportPreviewTree());
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
resolveImport({ success: false, message: "database rejected import" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.previewImportFile).toHaveBeenCalledTimes(1);
|
||||
expect(textContent(renderer.toJSON())).toContain("database rejected import");
|
||||
});
|
||||
|
||||
it("keeps large column mapping lists independently scrollable", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./ImportPreviewModal.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain('data-import-column-mapping-list="true"');
|
||||
expect(source).toContain('maxHeight: 240, overflowY: "auto"');
|
||||
expect(source).toContain('closable={!importing}');
|
||||
expect(source).toContain('maskClosable={!importing}');
|
||||
expect(source).toContain('keyboard={!importing}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Table, Alert, Progress, Button, Space } from 'antd';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Table, Alert, Progress, Button, Space, Select } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
DBGetColumns,
|
||||
PreviewImportFile,
|
||||
ImportDataWithProgress,
|
||||
ImportDataWithProgressOptions,
|
||||
} from "../../wailsjs/go/app/App";
|
||||
import { EventsOn, EventsOff } from "../../wailsjs/runtime/runtime";
|
||||
import { EventsOn } from "../../wailsjs/runtime/runtime";
|
||||
import { useStore } from "../store";
|
||||
import { t as defaultTranslate } from "../i18n";
|
||||
import { useOptionalI18n } from "../i18n/provider";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { getColumnDefinitionName } from "../utils/columnDefinition";
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
filePath: string;
|
||||
@@ -28,6 +30,7 @@ interface PreviewData {
|
||||
}
|
||||
|
||||
interface ImportProgress {
|
||||
jobId?: string;
|
||||
current: number;
|
||||
total: number;
|
||||
success: number;
|
||||
@@ -35,6 +38,13 @@ interface ImportProgress {
|
||||
totalRowsKnown?: boolean;
|
||||
}
|
||||
|
||||
const createImportJobId = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return `import-${globalThis.crypto.randomUUID()}`;
|
||||
}
|
||||
return `import-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
visible,
|
||||
filePath,
|
||||
@@ -47,24 +57,44 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
const i18n = useOptionalI18n();
|
||||
const t = i18n?.t ?? defaultTranslate;
|
||||
const connections = useStore((state) => state.connections);
|
||||
const darkMode = useStore((state) => state.theme === "dark");
|
||||
const connection = connections.find((item) => item.id === connectionId);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
const [targetColumns, setTargetColumns] = useState<string[]>([]);
|
||||
const [columnMappings, setColumnMappings] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
const previewRequestRef = useRef(0);
|
||||
const importRequestRef = useRef(0);
|
||||
const importingRef = useRef(false);
|
||||
const activeImportJobIdRef = useRef("");
|
||||
const previewConnectionConfigRef = useRef<any>(null);
|
||||
const secondaryTextColor = darkMode ? "rgba(255,255,255,0.65)" : "rgba(0,0,0,0.45)";
|
||||
const mappingFieldBackground = darkMode ? "rgba(255,255,255,0.06)" : "#f5f5f5";
|
||||
|
||||
useEffect(() => {
|
||||
if (importingRef.current) return undefined;
|
||||
const requestId = previewRequestRef.current + 1;
|
||||
previewRequestRef.current = requestId;
|
||||
if (visible && filePath) {
|
||||
loadPreview();
|
||||
void loadPreview(requestId);
|
||||
}
|
||||
}, [visible, filePath]);
|
||||
return () => {
|
||||
if (previewRequestRef.current === requestId) {
|
||||
previewRequestRef.current += 1;
|
||||
}
|
||||
};
|
||||
}, [visible, filePath, connectionId, dbName, tableName, connection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (importing) {
|
||||
const unsubscribe = EventsOn(
|
||||
"import:progress",
|
||||
(data: ImportProgress) => {
|
||||
if (!data || data.jobId !== activeImportJobIdRef.current) return;
|
||||
setProgress((prev) => {
|
||||
const fallbackTotal = prev?.total || previewData?.totalRows || 0;
|
||||
const nextTotal =
|
||||
@@ -83,53 +113,27 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
EventsOff("import:progress");
|
||||
};
|
||||
}
|
||||
}, [importing, previewData?.totalRows]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
const loadPreview = async (requestId: number) => {
|
||||
importRequestRef.current += 1;
|
||||
importingRef.current = false;
|
||||
activeImportJobIdRef.current = "";
|
||||
previewConnectionConfigRef.current = null;
|
||||
setImporting(false);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await PreviewImportFile(filePath);
|
||||
if (res.success && res.data) {
|
||||
setPreviewData({
|
||||
columns: res.data.columns || [],
|
||||
totalRows: res.data.totalRows || 0,
|
||||
previewRows: res.data.previewRows || [],
|
||||
});
|
||||
} else {
|
||||
setError(res.message || t("import_preview.error.preview_failed"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
t("import_preview.error.preview_failed_detail", {
|
||||
detail: String(e?.message || e),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewData) return;
|
||||
|
||||
setImporting(true);
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: previewData.totalRows,
|
||||
success: 0,
|
||||
errors: 0,
|
||||
});
|
||||
setPreviewData(null);
|
||||
setTargetColumns([]);
|
||||
setColumnMappings({});
|
||||
setImportResult(null);
|
||||
|
||||
setProgress(null);
|
||||
try {
|
||||
const conn = connections.find((c) => c.id === connectionId);
|
||||
const conn = connection;
|
||||
if (!conn) {
|
||||
setError(t("import_preview.error.connection_config_not_found"));
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,13 +151,112 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
keyPath: "",
|
||||
},
|
||||
};
|
||||
const rpcConfig = buildRpcConnectionConfig(config) as any;
|
||||
const [previewRes, columnsRes] = await Promise.all([
|
||||
PreviewImportFile(filePath),
|
||||
DBGetColumns(rpcConfig, dbName, tableName),
|
||||
]);
|
||||
if (previewRequestRef.current !== requestId) return;
|
||||
if (!previewRes.success || !previewRes.data) {
|
||||
setError(previewRes.message || t("import_preview.error.preview_failed"));
|
||||
return;
|
||||
}
|
||||
if (!columnsRes.success || !Array.isArray(columnsRes.data)) {
|
||||
setError(columnsRes.message || t("import_preview.error.target_columns_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await ImportDataWithProgress(
|
||||
previewConnectionConfigRef.current = config;
|
||||
|
||||
const sourceColumns: string[] = Array.isArray(previewRes.data.columns)
|
||||
? previewRes.data.columns
|
||||
.map((column: unknown) => String(column))
|
||||
.filter((column: string) => column.trim().length > 0)
|
||||
: [];
|
||||
const nextTargetColumns = Array.from(new Set(
|
||||
columnsRes.data.map(getColumnDefinitionName).filter(Boolean),
|
||||
));
|
||||
const targetsByLowerName = new Map<string, string[]>();
|
||||
nextTargetColumns.forEach((column) => {
|
||||
const key = column.toLowerCase();
|
||||
targetsByLowerName.set(key, [...(targetsByLowerName.get(key) || []), column]);
|
||||
});
|
||||
const nextMappings: Record<string, string> = {};
|
||||
sourceColumns.forEach((sourceColumn) => {
|
||||
const exactTarget = nextTargetColumns.find((targetColumn) => targetColumn === sourceColumn);
|
||||
const insensitiveTargets = targetsByLowerName.get(sourceColumn.toLowerCase()) || [];
|
||||
nextMappings[sourceColumn] = exactTarget || (insensitiveTargets.length === 1 ? insensitiveTargets[0] : "");
|
||||
});
|
||||
|
||||
setPreviewData({
|
||||
columns: sourceColumns,
|
||||
totalRows: previewRes.data.totalRows || 0,
|
||||
previewRows: previewRes.data.previewRows || [],
|
||||
});
|
||||
setTargetColumns(nextTargetColumns);
|
||||
setColumnMappings(nextMappings);
|
||||
} catch (e: any) {
|
||||
if (previewRequestRef.current !== requestId) return;
|
||||
setError(
|
||||
t("import_preview.error.preview_failed_detail", {
|
||||
detail: String(e?.message || e),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previewRequestRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mappedTargetColumns = Object.values(columnMappings).filter(Boolean);
|
||||
const hasDuplicateSourceColumns = previewData
|
||||
? new Set(previewData.columns).size !== previewData.columns.length
|
||||
: false;
|
||||
const hasDuplicateTargetColumns = new Set(mappedTargetColumns).size !== mappedTargetColumns.length;
|
||||
const mappingValidationError = hasDuplicateSourceColumns
|
||||
? t("import_preview.mapping.validation.duplicate_source")
|
||||
: hasDuplicateTargetColumns
|
||||
? t("import_preview.mapping.validation.duplicate_target")
|
||||
: mappedTargetColumns.length === 0
|
||||
? t("import_preview.mapping.validation.required")
|
||||
: null;
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewData || mappingValidationError) return;
|
||||
|
||||
const importRequestId = importRequestRef.current + 1;
|
||||
const importJobId = createImportJobId();
|
||||
importRequestRef.current = importRequestId;
|
||||
importingRef.current = true;
|
||||
activeImportJobIdRef.current = importJobId;
|
||||
setImporting(true);
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: previewData.totalRows,
|
||||
success: 0,
|
||||
errors: 0,
|
||||
});
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
const config = previewConnectionConfigRef.current;
|
||||
if (!config) {
|
||||
setError(t("import_preview.error.connection_config_not_found"));
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMappings = Object.fromEntries(
|
||||
Object.entries(columnMappings).filter(([, targetColumn]) => Boolean(targetColumn)),
|
||||
);
|
||||
const res = await ImportDataWithProgressOptions(
|
||||
buildRpcConnectionConfig(config) as any,
|
||||
dbName,
|
||||
tableName,
|
||||
filePath,
|
||||
{ columnMappings: selectedMappings, jobId: importJobId },
|
||||
);
|
||||
if (importRequestRef.current !== importRequestId) return;
|
||||
|
||||
if (res.success && res.data) {
|
||||
setImportResult(res.data);
|
||||
@@ -164,13 +267,18 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
setError(res.message || t("import_preview.error.import_failed"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (importRequestRef.current !== importRequestId) return;
|
||||
setError(
|
||||
t("import_preview.error.import_failed_detail", {
|
||||
detail: String(e?.message || e),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (importRequestRef.current === importRequestId) {
|
||||
importingRef.current = false;
|
||||
activeImportJobIdRef.current = "";
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,7 +300,12 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
<Modal
|
||||
title={t("import_preview.title")}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onCancel={() => {
|
||||
if (!importing) onClose();
|
||||
}}
|
||||
closable={!importing}
|
||||
maskClosable={!importing}
|
||||
keyboard={!importing}
|
||||
width={900}
|
||||
footer={
|
||||
importResult ? (
|
||||
@@ -205,7 +318,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
disabled={!previewData || loading}
|
||||
disabled={!previewData || loading || Boolean(mappingValidationError)}
|
||||
>
|
||||
{t("import_preview.action.start")}
|
||||
</Button>
|
||||
@@ -247,12 +360,75 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 8,
|
||||
background: "#f5f5f5",
|
||||
background: mappingFieldBackground,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{previewData.columns.join(", ")}
|
||||
</div>
|
||||
<div data-import-column-mapping="true" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>
|
||||
{t("import_preview.mapping.title")}
|
||||
</div>
|
||||
<div style={{ marginBottom: 10, color: secondaryTextColor, fontSize: 12 }}>
|
||||
{t("import_preview.mapping.description")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
|
||||
gap: 8,
|
||||
marginBottom: 6,
|
||||
color: darkMode ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.65)",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>{t("import_preview.mapping.source_column")}</span>
|
||||
<span>{t("import_preview.mapping.target_column")}</span>
|
||||
</div>
|
||||
<div
|
||||
data-import-column-mapping-list="true"
|
||||
style={{ maxHeight: 240, overflowY: "auto", paddingRight: 4 }}
|
||||
>
|
||||
{previewData.columns.map((sourceColumn) => (
|
||||
<div
|
||||
key={sourceColumn}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div title={sourceColumn} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{sourceColumn}
|
||||
</div>
|
||||
<Select
|
||||
value={columnMappings[sourceColumn] || ""}
|
||||
options={[
|
||||
{ value: "", label: t("import_preview.mapping.ignore") },
|
||||
...targetColumns.map((targetColumn) => ({
|
||||
value: targetColumn,
|
||||
label: targetColumn,
|
||||
disabled: mappedTargetColumns.includes(targetColumn)
|
||||
&& columnMappings[sourceColumn] !== targetColumn,
|
||||
})),
|
||||
]}
|
||||
onChange={(targetColumn) => setColumnMappings((current) => ({
|
||||
...current,
|
||||
[sourceColumn]: targetColumn,
|
||||
}))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{mappingValidationError && (
|
||||
<Alert type="warning" showIcon message={mappingValidationError} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>
|
||||
{t("import_preview.preview.table_title")}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,10 @@ import { readFileSync } from 'node:fs';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench';
|
||||
import TableExportWorkbench, {
|
||||
buildTableExportHistoryEntry,
|
||||
resolveTableExportColumnNames,
|
||||
} from './TableExportWorkbench';
|
||||
import { setCurrentLanguage } from '../i18n';
|
||||
import type { ExportProgressState } from './useExportProgressRunner';
|
||||
|
||||
@@ -283,6 +286,27 @@ describe('TableExportWorkbench', () => {
|
||||
expect(source).toContain("t('data_export.label.elapsed')");
|
||||
});
|
||||
|
||||
it('normalizes table column metadata without changing database order', () => {
|
||||
expect(resolveTableExportColumnNames([
|
||||
{ Name: 'id' },
|
||||
{ name: 'display_name' },
|
||||
{ COLUMN_NAME: 'created_at' },
|
||||
{ name: 'display_name' },
|
||||
{ name: '' },
|
||||
])).toEqual(['id', 'display_name', 'created_at']);
|
||||
});
|
||||
|
||||
it('loads selectable columns and sends the selection through both single-table export paths', () => {
|
||||
const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('DBGetColumns(');
|
||||
expect(source).toContain('mode="multiple"');
|
||||
expect(source).toContain('columns: selectedColumns');
|
||||
expect(source).toContain('selectedColumns.length > 0');
|
||||
expect(source.match(/ExportQueryWithOptions\(/g)).toHaveLength(2);
|
||||
expect(source).toContain('ExportTableWithOptions(');
|
||||
});
|
||||
|
||||
it('prefers backend startedAt over a placeholder history timestamp for the same job', () => {
|
||||
const entry = buildTableExportHistoryEntry({
|
||||
progressState: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Empty, InputNumber, Select, Tooltip, Typography } from 'antd';
|
||||
import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
DBGetColumns,
|
||||
DBGetDatabases,
|
||||
DBGetTables,
|
||||
ExportDatabasesSQLWithOptions,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
TableExportScopeOption,
|
||||
} from '../types';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { getColumnDefinitionName } from '../utils/columnDefinition';
|
||||
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
|
||||
import { buildExportWorkbenchHistoryKey } from '../utils/tableExportTab';
|
||||
import {
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
DEFAULT_DATA_EXPORT_FORMAT,
|
||||
DEFAULT_XLSX_ROWS_PER_SHEET,
|
||||
MAX_XLSX_ROWS_PER_SHEET,
|
||||
resolveDataExportColumns,
|
||||
type DataExportFormat,
|
||||
} from './DataExportDialog';
|
||||
import ExportProgressBar from './ExportProgressBar';
|
||||
@@ -111,6 +114,19 @@ const resolveInitialScope = (
|
||||
return scopeOptions.find((item) => !item.disabled)?.value || 'all';
|
||||
};
|
||||
|
||||
export const resolveTableExportColumnNames = (definitions: unknown): string[] => {
|
||||
if (!Array.isArray(definitions)) return [];
|
||||
const seen = new Set<string>();
|
||||
const columns: string[] = [];
|
||||
definitions.forEach((definition) => {
|
||||
const column = getColumnDefinitionName(definition);
|
||||
if (!column || seen.has(column)) return;
|
||||
seen.add(column);
|
||||
columns.push(column);
|
||||
});
|
||||
return columns;
|
||||
};
|
||||
|
||||
const normalizeConnectionConfig = (connection: SavedConnection) => ({
|
||||
...connection.config,
|
||||
port: Number(connection.config.port),
|
||||
@@ -286,14 +302,18 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [selectedDbName, setSelectedDbName] = useState(() => String(tab.dbName || '').trim());
|
||||
const [availableDatabases, setAvailableDatabases] = useState<SelectOption[]>([]);
|
||||
const [availableObjects, setAvailableObjects] = useState<SelectOption[]>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||
const [selectedObjectNames, setSelectedObjectNames] = useState<string[]>([]);
|
||||
const [selectedDatabaseNames, setSelectedDatabaseNames] = useState<string[]>([]);
|
||||
const [batchTableMode, setBatchTableMode] = useState<BatchTableExportMode>('schema');
|
||||
const [batchDatabaseMode, setBatchDatabaseMode] = useState<BatchDatabaseExportMode>('schema');
|
||||
const [loadingDatabases, setLoadingDatabases] = useState(false);
|
||||
const [loadingObjects, setLoadingObjects] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [databaseLoadError, setDatabaseLoadError] = useState('');
|
||||
const [objectLoadError, setObjectLoadError] = useState('');
|
||||
const [columnLoadError, setColumnLoadError] = useState('');
|
||||
|
||||
const effectiveConnectionId = isSingleWorkbench ? String(tab.connectionId || '').trim() : selectedConnectionId;
|
||||
const effectiveDbName = isSingleWorkbench ? String(tab.dbName || '').trim() : selectedDbName;
|
||||
@@ -360,6 +380,50 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}, [scopeOptions, tab.tableExportInitialScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const objectName = String(tab.tableName || '').trim();
|
||||
if (!isSingleWorkbench || !connectionConfig || !objectName) {
|
||||
setAvailableColumns([]);
|
||||
setSelectedColumns([]);
|
||||
setColumnLoadError('');
|
||||
setLoadingColumns(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let alive = true;
|
||||
setLoadingColumns(true);
|
||||
setColumnLoadError('');
|
||||
DBGetColumns(buildRpcConnectionConfig(connectionConfig) as any, effectiveDbName, objectName)
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
if (!res.success) {
|
||||
setAvailableColumns([]);
|
||||
setSelectedColumns([]);
|
||||
setColumnLoadError(res.message || t('data_export.message.load_columns_failed'));
|
||||
return;
|
||||
}
|
||||
const nextColumns = resolveTableExportColumnNames(res.data);
|
||||
setAvailableColumns(nextColumns);
|
||||
setSelectedColumns(nextColumns);
|
||||
if (nextColumns.length === 0) {
|
||||
setColumnLoadError(t('data_export.message.load_columns_failed'));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (!alive) return;
|
||||
setAvailableColumns([]);
|
||||
setSelectedColumns([]);
|
||||
setColumnLoadError(error?.message || t('data_export.message.load_columns_failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoadingColumns(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [connectionConfig, effectiveDbName, isSingleWorkbench, tab.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!progressState.startedAt || progressState.finishedAt > 0) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -588,7 +652,12 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return false;
|
||||
}
|
||||
if (isSingleWorkbench) {
|
||||
return !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery);
|
||||
return !!tab.tableName
|
||||
&& !!scope
|
||||
&& !activeScopeOption?.disabled
|
||||
&& !loadingColumns
|
||||
&& selectedColumns.length > 0
|
||||
&& (scope === 'all' || !!activeScopeQuery);
|
||||
}
|
||||
if (isBatchTablesWorkbench) {
|
||||
return !!selectedDbName && selectedObjectNames.length > 0;
|
||||
@@ -601,9 +670,11 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
isBatchTablesWorkbench,
|
||||
isRunning,
|
||||
isSingleWorkbench,
|
||||
loadingColumns,
|
||||
scope,
|
||||
selectedDatabaseNames.length,
|
||||
selectedDbName,
|
||||
selectedColumns.length,
|
||||
selectedObjectNames.length,
|
||||
tab.tableName,
|
||||
]);
|
||||
@@ -624,6 +695,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
run: (jobId) => {
|
||||
const options = {
|
||||
format,
|
||||
columns: selectedColumns,
|
||||
xlsxMaxRowsPerSheet,
|
||||
jobId,
|
||||
totalRowsHint: singleTotalRowsKnown ? singleScopeRowCount : 0,
|
||||
@@ -910,6 +982,15 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{columnLoadError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('data_export.dialog.field.columns')}
|
||||
description={columnLoadError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{isSingleWorkbench ? (
|
||||
<>
|
||||
@@ -942,6 +1023,24 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.dialog.field.columns')}</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
mode="multiple"
|
||||
value={selectedColumns}
|
||||
loading={loadingColumns}
|
||||
options={availableColumns.map((column) => ({ value: column, label: column }))}
|
||||
maxTagCount="responsive"
|
||||
onChange={(columns) => setSelectedColumns(
|
||||
resolveDataExportColumns(columns, availableColumns) || [],
|
||||
)}
|
||||
/>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
|
||||
{t('data_export.dialog.field.columns_help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{format === 'xlsx' ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: secondaryTextColor }}>{t('data_export.label.xlsx_max_rows')}</div>
|
||||
|
||||
@@ -863,6 +863,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
const values = await showDataExportDialog(modal, {
|
||||
title: translateDataGrid('file.backend.dialog.export_query_result'),
|
||||
scopeOptions,
|
||||
availableColumns: displayOutputColumnNames,
|
||||
allowInsertSql: canExportInsertSQL,
|
||||
initialValues: {
|
||||
...commonInitialValues,
|
||||
@@ -870,7 +871,10 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
},
|
||||
});
|
||||
if (!values) return;
|
||||
await exportQueryResultRows(values, values.scope as Exclude<DataGridExportScope, 'filteredAll'>);
|
||||
await exportQueryResultRows(
|
||||
{ ...values, columns: values.columns },
|
||||
values.scope as Exclude<DataGridExportScope, 'filteredAll'>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -937,6 +941,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
connectionId,
|
||||
dbName,
|
||||
displayData.length,
|
||||
displayOutputColumnNames,
|
||||
exportQueryResultRows,
|
||||
hasFilteredExportSql,
|
||||
objectType,
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -208,6 +208,8 @@ export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:str
|
||||
|
||||
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportDataWithProgressOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ImportFileOptions):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
|
||||
|
||||
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
@@ -402,6 +402,10 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ImportDataWithProgressOptions(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['app']['App']['ImportDataWithProgressOptions'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function ImportLegacyConnections(arg1) {
|
||||
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
|
||||
}
|
||||
|
||||
@@ -494,6 +494,7 @@ export namespace app {
|
||||
}
|
||||
export class ExportFileOptions {
|
||||
format: string;
|
||||
columns?: string[];
|
||||
xlsxMaxRowsPerSheet?: number;
|
||||
jobId?: string;
|
||||
totalRowsHint?: number;
|
||||
@@ -511,6 +512,7 @@ export namespace app {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.format = source["format"];
|
||||
this.columns = source["columns"];
|
||||
this.xlsxMaxRowsPerSheet = source["xlsxMaxRowsPerSheet"];
|
||||
this.jobId = source["jobId"];
|
||||
this.totalRowsHint = source["totalRowsHint"];
|
||||
@@ -522,6 +524,20 @@ export namespace app {
|
||||
this.insertSQLAllowEmptyTargetTable = source["insertSQLAllowEmptyTargetTable"];
|
||||
}
|
||||
}
|
||||
export class ImportFileOptions {
|
||||
columnMappings?: Record<string, string>;
|
||||
jobId?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ImportFileOptions(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.columnMappings = source["columnMappings"];
|
||||
this.jobId = source["jobId"];
|
||||
}
|
||||
}
|
||||
export class RedisExportKeysOptions {
|
||||
scope?: string;
|
||||
keys?: string[];
|
||||
|
||||
Reference in New Issue
Block a user