feat(data-transfer): 完善数据库 SQL 备份与恢复

- 数据导入工作台新增数据库模式,支持 SQL 文件选择、目标库、进度、取消与重试
- 新增受连接保护约束的 ImportDatabaseSQL 接口并复用流式执行链路
- SQL 导出支持切换数据库上下文,兼顾原库备份和跨库导入
- 调整侧栏与表概览入口,备份先进入工作台确认配置再执行
- 同步 Wails 绑定、Web 运行时限制、六语文案和回归测试
This commit is contained in:
Syngnat
2026-07-23 16:40:44 +08:00
parent 1f17b5cfad
commit d618ef381e
38 changed files with 2183 additions and 182 deletions

View File

@@ -6,26 +6,54 @@ const requiredKeys = [
'sidebar.action.data_import',
'data_import.workbench.title',
'data_import.workbench.description',
'data_import.workbench.description.database',
'data_import.workbench.section.target',
'data_import.workbench.mode.table',
'data_import.workbench.mode.database',
'data_import.workbench.label.connection',
'data_import.workbench.label.database',
'data_import.workbench.label.default_database',
'data_import.workbench.label.table',
'data_import.workbench.label.file',
'data_import.workbench.label.sql_file',
'data_import.workbench.placeholder.select_connection',
'data_import.workbench.placeholder.loading_databases',
'data_import.workbench.placeholder.select_database',
'data_import.workbench.placeholder.select_default_database',
'data_import.workbench.placeholder.select_database_first',
'data_import.workbench.placeholder.loading_tables',
'data_import.workbench.placeholder.select_table',
'data_import.workbench.action.select_file',
'data_import.workbench.action.change_file',
'data_import.workbench.action.select_sql_file',
'data_import.workbench.action.change_sql_file',
'data_import.workbench.action.start_database_import',
'data_import.workbench.action.retry_database_import',
'data_import.workbench.action.cancel_database_import',
'data_import.workbench.helper.file_formats',
'data_import.workbench.helper.sql_file',
'data_import.workbench.notice.partial_execution',
'data_import.workbench.notice.gonavi_mysql_restore',
'data_import.workbench.state.awaiting_file_title',
'data_import.workbench.state.awaiting_file_description',
'data_import.workbench.state.awaiting_sql_title',
'data_import.workbench.state.awaiting_sql_description',
'data_import.workbench.state.ready_sql_title',
'data_import.workbench.state.ready_sql_description',
'data_import.workbench.state.running',
'data_import.workbench.state.cancelling',
'data_import.workbench.state.completed',
'data_import.workbench.state.failed',
'data_import.workbench.state.cancelled',
'data_import.workbench.progress.statements',
'data_import.workbench.progress.bytes',
'data_import.workbench.message.load_databases_failed',
'data_import.workbench.message.load_tables_failed',
'data_import.workbench.message.select_file_failed',
'data_import.workbench.message.import_done',
'data_import.workbench.message.database_import_done',
'data_import.workbench.message.database_import_failed',
'data_import.workbench.message.database_import_cancelled',
'tab_manager.kind_badge.data_import',
'tab_manager.hover.kind.data_import',
];

View File

@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
dbGetDatabases: vi.fn(),
dbGetTables: vi.fn(),
importData: vi.fn(),
selectSQLFileForExecution: vi.fn(),
messageError: vi.fn(),
messageSuccess: vi.fn(),
addTab: vi.fn(),
@@ -26,6 +27,14 @@ vi.mock('../../wailsjs/go/app/App', () => ({
DBGetDatabases: mocks.dbGetDatabases,
DBGetTables: mocks.dbGetTables,
ImportData: mocks.importData,
SelectSQLFileForExecution: mocks.selectSQLFileForExecution,
}));
vi.mock('./DatabaseImportExecutionPanel', () => ({
default: (props: Record<string, unknown>) => React.createElement(
'mock-database-import-execution-panel',
{ 'data-database-import-execution-panel-mock': 'true', ...props },
),
}));
vi.mock('./ImportPreviewModal', () => ({
@@ -38,6 +47,7 @@ vi.mock('./ImportPreviewModal', () => ({
vi.mock('antd', async () => {
const React = await import('react');
const Select = (props: Record<string, unknown>) => React.createElement('mock-select', props);
const Segmented = (props: Record<string, unknown>) => React.createElement('mock-segmented', props);
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
const Empty = ({ description, ...props }: any) => React.createElement('mock-empty', props, description);
@@ -48,6 +58,7 @@ vi.mock('antd', async () => {
Alert,
Button,
Empty,
Segmented,
Select,
Typography: { Text, Title },
message: {
@@ -58,8 +69,10 @@ vi.mock('antd', async () => {
});
vi.mock('@ant-design/icons', () => ({
DatabaseOutlined: () => React.createElement('mock-icon', { 'data-icon': 'database' }),
FileAddOutlined: () => React.createElement('mock-icon', { 'data-icon': 'file-add' }),
ImportOutlined: () => React.createElement('mock-icon', { 'data-icon': 'import' }),
TableOutlined: () => React.createElement('mock-icon', { 'data-icon': 'table' }),
}));
const createTab = (overrides: Record<string, unknown> = {}) => ({
@@ -135,6 +148,11 @@ describe('DataImportWorkbench', () => {
success: true,
data: { filePath: '/tmp/users.csv' },
});
mocks.selectSQLFileForExecution.mockReset();
mocks.selectSQLFileForExecution.mockResolvedValue({
success: true,
data: { filePath: '/tmp/full-backup.sql', fileSizeMB: '1.25' },
});
mocks.messageError.mockReset();
mocks.messageSuccess.mockReset();
mocks.addTab.mockReset();
@@ -159,6 +177,55 @@ describe('DataImportWorkbench', () => {
expect(mocks.dbGetTables).toHaveBeenCalledWith(expect.anything(), 'app');
});
it('filters every SQL import protection from database mode connections', async () => {
const primaryConnection = mocks.storeState.connections[0];
mocks.storeState.connections = [
primaryConnection,
{
id: 'data-import-protected',
name: 'Data import protected',
config: {
...primaryConnection.config,
protection: { restrictDataImport: true },
},
},
{
id: 'structure-protected',
name: 'Structure protected',
config: {
...primaryConnection.config,
protection: { restrictStructureEdit: true },
},
},
{
id: 'script-protected',
name: 'Script protected',
config: {
...primaryConnection.config,
protection: { restrictScriptExecution: true },
},
},
{
id: 'redis-1',
name: 'Redis',
config: { type: 'redis', host: 'localhost', port: 6379 },
},
];
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const connectionSelect = renderer.root.findByProps({
'data-import-target-field': 'connection',
});
expect(connectionSelect.props.options.map((option: any) => option.value)).toEqual(['conn-1']);
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(mocks.dbGetTables).not.toHaveBeenCalled();
});
it('syncs the automatic connection fallback back to the stable workbench tab', async () => {
const renderer = await renderWorkbench({
connectionId: '',
@@ -219,6 +286,84 @@ describe('DataImportWorkbench', () => {
}).props.disabled).toBe(true);
});
it('selects a SQL file without running it and renders the database execution panel', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const modeSelector = renderer.root.findByProps({
'data-import-mode-selector': 'true',
});
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(modeSelector.props.value).toBe('database');
expect(databaseSelect.props.allowClear).toBe(true);
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(mocks.dbGetTables).not.toHaveBeenCalled();
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.selectSQLFileForExecution).toHaveBeenCalledTimes(1);
expect(mocks.importData).not.toHaveBeenCalled();
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
expect(executionPanel.props).toMatchObject({
dbName: 'app',
filePath: '/tmp/full-backup.sql',
fileSizeMB: '1.25',
darkMode: false,
});
expect(executionPanel.props.connectionConfig).toEqual(expect.objectContaining({ type: 'mysql' }));
});
it('allows selecting a database SQL file without a default database', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
await act(async () => {
databaseSelect.props.onChange(undefined);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-target-field': 'database',
}).props.value).toBeUndefined();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(selectFileButton.props.disabled).toBe(false);
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props.dbName).toBe('');
expect(mocks.dbGetTables).not.toHaveBeenCalled();
expect(mocks.importData).not.toHaveBeenCalled();
});
it('clears downstream target state when the database changes', async () => {
const renderer = await renderWorkbench();
const databaseSelect = renderer.root.findByProps({
@@ -252,6 +397,80 @@ describe('DataImportWorkbench', () => {
}));
});
it('clears the selected table file and table target when switching to database mode', async () => {
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(1);
const modeSelector = renderer.root.findByProps({
'data-import-mode-selector': 'true',
});
expect(modeSelector.props.disabled).toBe(false);
await act(async () => {
modeSelector.props.onChange('database');
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.value).toBe('database');
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ title: '/tmp/users.csv' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
expect(mocks.addTab).toHaveBeenLastCalledWith(expect.objectContaining({
id: 'data-import-workbench',
dataImportMode: 'database',
tableName: undefined,
}));
});
it('ignores a pending table file selection result after switching modes', async () => {
let resolveTableFile!: (result: any) => void;
mocks.importData.mockReturnValueOnce(new Promise<any>((resolve) => {
resolveTableFile = resolve;
}));
const renderer = await renderWorkbench();
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.onChange('database');
await Promise.resolve();
});
await act(async () => {
resolveTableFile({ success: true, data: { filePath: '/tmp/stale-users.csv' } });
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.value).toBe('database');
expect(renderer.root.findAllByProps({ title: '/tmp/stale-users.csv' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
});
it('does not report the native file-picker cancellation as an error', async () => {
mocks.importData.mockResolvedValueOnce({ success: false, message: '已取消' });
const renderer = await renderWorkbench();
@@ -269,6 +488,90 @@ describe('DataImportWorkbench', () => {
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
});
it('resets a selected SQL file when the same target gets a new launch key', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(1);
await act(async () => {
renderer.update(<DataImportWorkbench tab={createTab({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-2',
tableName: undefined,
})} />);
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({ title: '/tmp/full-backup.sql' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
expect(mocks.selectSQLFileForExecution).toHaveBeenCalledTimes(1);
});
it('does not replace a running database import target when the stable tab is reopened', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
await act(async () => {
executionPanel.props.onRunningChange(true);
await Promise.resolve();
});
await act(async () => {
renderer.update(<DataImportWorkbench tab={createTab({
dataImportMode: 'table',
dataImportLaunchKey: 'table-launch-2',
dbName: 'analytics',
tableName: 'events',
})} />);
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props).toMatchObject({ value: 'database', disabled: true });
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props).toMatchObject({
dbName: 'app',
filePath: '/tmp/full-backup.sql',
});
expect(mocks.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'data-import-workbench',
connectionId: 'conn-1',
dbName: 'app',
tableName: undefined,
dataImportMode: 'database',
dataImportRunning: true,
}));
});
it('does not replace an active import target when the stable tab is reopened', async () => {
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({

View File

@@ -1,17 +1,33 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Empty, Select, Typography, message } from 'antd';
import { FileAddOutlined, ImportOutlined } from '@ant-design/icons';
import { Alert, Button, Empty, Segmented, Select, Typography, message } from 'antd';
import {
DatabaseOutlined,
FileAddOutlined,
ImportOutlined,
TableOutlined,
} from '@ant-design/icons';
import { DBGetDatabases, DBGetTables, ImportData } from '../../wailsjs/go/app/App';
import {
DBGetDatabases,
DBGetTables,
ImportData,
SelectSQLFileForExecution,
} from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import type { SavedConnection, TabData } from '../types';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { BACKEND_CANCELLED_MESSAGE } from '../utils/connectionExport';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
import {
isConnectionDataImportRestricted,
isConnectionScriptExecutionRestricted,
isConnectionStructureEditRestricted,
} from '../utils/connectionReadOnly';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { normalizeTableNamesFromMetadataRows } from '../utils/tableMetadataRows';
import type { DataImportMode } from '../utils/dataImportTab';
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
import ImportPreviewModal from './ImportPreviewModal';
import './DataImportWorkbench.css';
@@ -56,10 +72,20 @@ const getFileName = (filePath: string): string => {
return parts[parts.length - 1] || filePath;
};
const isEligibleImportConnection = (connection: SavedConnection): boolean => (
getDataSourceCapabilities(connection.config).supportsCopyInsert
&& !isConnectionDataImportRestricted(connection.config)
);
const isEligibleImportConnection = (
connection: SavedConnection,
mode: DataImportMode,
): boolean => {
const capabilities = getDataSourceCapabilities(connection.config);
if (mode === 'table') {
return capabilities.supportsCopyInsert
&& !isConnectionDataImportRestricted(connection.config);
}
return capabilities.supportsSqlQueryExport
&& !isConnectionDataImportRestricted(connection.config)
&& !isConnectionStructureEditRestricted(connection.config)
&& !isConnectionScriptExecutionRestricted(connection.config);
};
const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const i18n = useOptionalI18n();
@@ -67,9 +93,12 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore((state) => state.connections);
const darkMode = useStore((state) => state.theme === 'dark');
const addTab = useStore((state) => state.addTab);
const [importMode, setImportMode] = useState<DataImportMode>(
() => (tab.dataImportMode === 'database' ? 'database' : 'table'),
);
const eligibleConnections = useMemo(
() => connections.filter(isEligibleImportConnection),
[connections],
() => connections.filter((connection) => isEligibleImportConnection(connection, importMode)),
[connections, importMode],
);
const connectionOptions = useMemo<SelectOption[]>(
() => eligibleConnections.map((connection) => ({
@@ -86,6 +115,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const [databaseOptions, setDatabaseOptions] = useState<SelectOption[]>([]);
const [tableOptions, setTableOptions] = useState<SelectOption[]>([]);
const [filePath, setFilePath] = useState('');
const [fileSizeMB, setFileSizeMB] = useState('');
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [loadingTables, setLoadingTables] = useState(false);
const [selectingFile, setSelectingFile] = useState(false);
@@ -116,18 +146,43 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}, [addTab]);
const invalidateFileSelection = useCallback(() => {
fileSelectionRequestRef.current += 1;
setSelectingFile(false);
setFilePath('');
setFileSizeMB('');
}, []);
useEffect(() => {
if (importing) return;
const prefillKey = [tab.connectionId, tab.dbName, tab.tableName]
const nextMode: DataImportMode = tab.dataImportMode === 'database' ? 'database' : 'table';
const prefillKey = [
tab.dataImportLaunchKey,
nextMode,
tab.connectionId,
tab.dbName,
nextMode === 'table' ? tab.tableName : '',
]
.map((value) => String(value || '').trim())
.join('::');
if (appliedPrefillRef.current === prefillKey) return;
appliedPrefillRef.current = prefillKey;
setImportMode(nextMode);
setSelectedConnectionId(String(tab.connectionId || '').trim());
setSelectedDbName(String(tab.dbName || '').trim());
setSelectedTableName(String(tab.tableName || '').trim());
setFilePath('');
}, [importing, tab.connectionId, tab.dbName, tab.tableName]);
setSelectedTableName(nextMode === 'table' ? String(tab.tableName || '').trim() : '');
setDatabaseError('');
setTableError('');
invalidateFileSelection();
}, [
importing,
invalidateFileSelection,
tab.connectionId,
tab.dataImportLaunchKey,
tab.dataImportMode,
tab.dbName,
tab.tableName,
]);
useEffect(() => {
if (importing) return;
@@ -137,14 +192,23 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
setSelectedConnectionId(nextConnectionId);
setSelectedDbName('');
setSelectedTableName('');
setFilePath('');
invalidateFileSelection();
syncWorkbenchTab({
connectionId: nextConnectionId,
dbName: undefined,
tableName: undefined,
dataImportMode: importMode,
dataImportRunning: false,
});
}, [connections.length, eligibleConnections, importing, selectedConnectionId, syncWorkbenchTab]);
}, [
connections.length,
eligibleConnections,
importMode,
importing,
invalidateFileSelection,
selectedConnectionId,
syncWorkbenchTab,
]);
useEffect(() => {
if (!selectedConnectionConfig || !selectedConnection) {
@@ -165,7 +229,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
setSelectedDbName('');
setSelectedTableName('');
setTableOptions([]);
setFilePath('');
invalidateFileSelection();
setDatabaseError(t('data_import.workbench.message.load_databases_failed', {
detail: res.message || '',
}));
@@ -191,7 +255,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
setSelectedDbName('');
setSelectedTableName('');
setTableOptions([]);
setFilePath('');
invalidateFileSelection();
setDatabaseError(t('data_import.workbench.message.load_databases_failed', {
detail: error?.message || String(error),
}));
@@ -203,10 +267,10 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
return () => {
alive = false;
};
}, [selectedConnection, selectedConnectionConfig, t]);
}, [invalidateFileSelection, selectedConnection, selectedConnectionConfig, t]);
useEffect(() => {
if (!selectedConnectionConfig || !selectedDbName) {
if (importMode !== 'table' || !selectedConnectionConfig || !selectedDbName) {
setTableOptions([]);
setLoadingTables(false);
setTableError('');
@@ -222,7 +286,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!res.success) {
setTableOptions([]);
setSelectedTableName('');
setFilePath('');
invalidateFileSelection();
setTableError(t('data_import.workbench.message.load_tables_failed', {
detail: res.message || '',
}));
@@ -237,7 +301,7 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!alive) return;
setTableOptions([]);
setSelectedTableName('');
setFilePath('');
invalidateFileSelection();
setTableError(t('data_import.workbench.message.load_tables_failed', {
detail: error?.message || String(error),
}));
@@ -249,54 +313,71 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
return () => {
alive = false;
};
}, [selectedConnectionConfig, selectedDbName, t]);
}, [importMode, invalidateFileSelection, selectedConnectionConfig, selectedDbName, t]);
const clearSelectedFile = () => {
fileSelectionRequestRef.current += 1;
setFilePath('');
invalidateFileSelection();
};
const handleModeChange = (value: string | number) => {
const nextMode: DataImportMode = value === 'database' ? 'database' : 'table';
if (nextMode === importMode || importing) return;
invalidateFileSelection();
setImportMode(nextMode);
setSelectedTableName('');
setTableOptions([]);
setTableError('');
syncWorkbenchTab({
connectionId: selectedConnectionId,
dbName: selectedDbName || undefined,
tableName: undefined,
dataImportMode: nextMode,
dataImportRunning: false,
});
};
const handleConnectionChange = (connectionId: string) => {
fileSelectionRequestRef.current += 1;
invalidateFileSelection();
setSelectedConnectionId(connectionId);
setSelectedDbName('');
setSelectedTableName('');
setDatabaseOptions([]);
setTableOptions([]);
setFilePath('');
setDatabaseError('');
setTableError('');
syncWorkbenchTab({
connectionId,
dbName: undefined,
tableName: undefined,
dataImportMode: importMode,
dataImportRunning: false,
});
};
const handleDatabaseChange = (dbName: string) => {
fileSelectionRequestRef.current += 1;
const handleDatabaseChange = (value?: string) => {
const dbName = String(value || '').trim();
invalidateFileSelection();
setSelectedDbName(dbName);
setSelectedTableName('');
setTableOptions([]);
setFilePath('');
setTableError('');
syncWorkbenchTab({
connectionId: selectedConnectionId,
dbName,
tableName: undefined,
dataImportMode: importMode,
dataImportRunning: false,
});
};
const handleTableChange = (tableName: string) => {
fileSelectionRequestRef.current += 1;
invalidateFileSelection();
setSelectedTableName(tableName);
setFilePath('');
syncWorkbenchTab({
connectionId: selectedConnectionId,
dbName: selectedDbName,
tableName,
dataImportMode: importMode,
dataImportRunning: false,
});
};
@@ -306,26 +387,33 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
syncWorkbenchTab({
connectionId: selectedConnectionId,
dbName: selectedDbName || undefined,
tableName: selectedTableName || undefined,
tableName: importMode === 'table' ? selectedTableName || undefined : undefined,
dataImportMode: importMode,
dataImportRunning: nextImporting,
});
}, [selectedConnectionId, selectedDbName, selectedTableName, syncWorkbenchTab]);
}, [importMode, selectedConnectionId, selectedDbName, selectedTableName, syncWorkbenchTab]);
const handleSelectFile = async () => {
if (!selectedConnectionConfig || !selectedDbName || !selectedTableName) return;
if (!selectedConnectionConfig) return;
if (importMode === 'table' && (!selectedDbName || !selectedTableName)) return;
const requestId = fileSelectionRequestRef.current + 1;
fileSelectionRequestRef.current = requestId;
setSelectingFile(true);
try {
const res = await ImportData(
buildRpcConnectionConfig(selectedConnectionConfig) as any,
selectedDbName,
selectedTableName,
);
const res = importMode === 'database'
? await SelectSQLFileForExecution()
: await ImportData(
buildRpcConnectionConfig(selectedConnectionConfig) as any,
selectedDbName,
selectedTableName,
);
if (fileSelectionRequestRef.current !== requestId) return;
const nextFilePath = String(res?.data?.filePath || '').trim();
if (res.success && nextFilePath) {
setFilePath(nextFilePath);
setFileSizeMB(importMode === 'database'
? String(res?.data?.fileSizeMB || '').trim()
: '');
return;
}
if (String(res?.message || '').trim() !== BACKEND_CANCELLED_MESSAGE) {
@@ -363,11 +451,46 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
background: shellBackground,
}}
>
<header style={{ padding: '20px 24px 16px', background: panelBackground, borderBottom: panelBorder }}>
<Title level={4} style={{ margin: 0, letterSpacing: 0 }}>
{t('data_import.workbench.title')}
</Title>
<Text type="secondary">{t('data_import.workbench.description')}</Text>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
padding: '20px 24px 16px',
background: panelBackground,
borderBottom: panelBorder,
}}
>
<div>
<Title level={4} style={{ margin: 0, letterSpacing: 0 }}>
{t('data_import.workbench.title')}
</Title>
<Text type="secondary">
{importMode === 'database'
? t('data_import.workbench.description.database')
: t('data_import.workbench.description')}
</Text>
</div>
<Segmented
data-import-mode-selector="true"
value={importMode}
disabled={importing}
options={[
{
value: 'table',
label: t('data_import.workbench.mode.table'),
icon: <TableOutlined />,
},
{
value: 'database',
label: t('data_import.workbench.mode.database'),
icon: <DatabaseOutlined />,
},
]}
onChange={handleModeChange}
/>
</header>
<div
@@ -408,46 +531,59 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
</label>
<label style={{ display: 'grid', gap: 6 }}>
<Text type="secondary">{t('data_import.workbench.label.database')}</Text>
<Text type="secondary">
{importMode === 'database'
? t('data_import.workbench.label.default_database')
: t('data_import.workbench.label.database')}
</Text>
<Select
data-import-target-field="database"
value={selectedDbName || undefined}
options={databaseOptions}
placeholder={loadingDatabases
? t('data_import.workbench.placeholder.loading_databases')
: t('data_import.workbench.placeholder.select_database')}
: importMode === 'database'
? t('data_import.workbench.placeholder.select_default_database')
: t('data_import.workbench.placeholder.select_database')}
loading={loadingDatabases}
showSearch
allowClear={importMode === 'database'}
optionFilterProp="title"
disabled={targetLocked || !selectedConnectionId || loadingDatabases}
onChange={handleDatabaseChange}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<Text type="secondary">{t('data_import.workbench.label.table')}</Text>
<Select
data-import-target-field="table"
value={selectedTableName || undefined}
options={tableOptions}
placeholder={!selectedDbName
? t('data_import.workbench.placeholder.select_database_first')
: loadingTables
? t('data_import.workbench.placeholder.loading_tables')
: t('data_import.workbench.placeholder.select_table')}
loading={loadingTables}
showSearch
optionFilterProp="title"
disabled={targetLocked || !selectedDbName || loadingTables}
onChange={handleTableChange}
/>
</label>
{importMode === 'table' ? (
<label style={{ display: 'grid', gap: 6 }}>
<Text type="secondary">{t('data_import.workbench.label.table')}</Text>
<Select
data-import-target-field="table"
value={selectedTableName || undefined}
options={tableOptions}
placeholder={!selectedDbName
? t('data_import.workbench.placeholder.select_database_first')
: loadingTables
? t('data_import.workbench.placeholder.loading_tables')
: t('data_import.workbench.placeholder.select_table')}
loading={loadingTables}
showSearch
optionFilterProp="title"
disabled={targetLocked || !selectedDbName || loadingTables}
onChange={handleTableChange}
/>
</label>
) : null}
{databaseError && <Alert type="error" showIcon message={databaseError} />}
{tableError && <Alert type="error" showIcon message={tableError} />}
{importMode === 'table' && tableError && <Alert type="error" showIcon message={tableError} />}
<div style={{ display: 'grid', gap: 6 }}>
<Text type="secondary">{t('data_import.workbench.label.file')}</Text>
<Text type="secondary">
{importMode === 'database'
? t('data_import.workbench.label.sql_file')
: t('data_import.workbench.label.file')}
</Text>
{filePath && (
<div
title={filePath}
@@ -471,15 +607,25 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
type="primary"
icon={filePath ? <FileAddOutlined /> : <ImportOutlined />}
loading={selectingFile}
disabled={importing || !selectedConnectionConfig || !selectedDbName || !selectedTableName}
disabled={
importing
|| !selectedConnectionConfig
|| (importMode === 'table' && (!selectedDbName || !selectedTableName))
}
onClick={() => void handleSelectFile()}
>
{filePath
? t('data_import.workbench.action.change_file')
: t('data_import.workbench.action.select_file')}
{importMode === 'database'
? filePath
? t('data_import.workbench.action.change_sql_file')
: t('data_import.workbench.action.select_sql_file')
: filePath
? t('data_import.workbench.action.change_file')
: t('data_import.workbench.action.select_file')}
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
{t('data_import.workbench.helper.file_formats')}
{importMode === 'database'
? t('data_import.workbench.helper.sql_file')
: t('data_import.workbench.helper.file_formats')}
</Text>
</div>
</div>
@@ -497,26 +643,45 @@ const DataImportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
}}
>
{filePath ? (
<ImportPreviewModal
visible
presentation="embedded"
filePath={filePath}
connectionId={selectedConnectionId}
dbName={selectedDbName}
tableName={selectedTableName}
onClose={clearSelectedFile}
onImportingChange={handleImportingChange}
onSuccess={() => {
void message.success(t('data_import.workbench.message.import_done'));
}}
/>
importMode === 'database' ? (
<DatabaseImportExecutionPanel
connectionConfig={selectedConnectionConfig}
dbName={selectedDbName}
filePath={filePath}
fileSizeMB={fileSizeMB}
darkMode={darkMode}
onRunningChange={handleImportingChange}
/>
) : (
<ImportPreviewModal
visible
presentation="embedded"
filePath={filePath}
connectionId={selectedConnectionId}
dbName={selectedDbName}
tableName={selectedTableName}
onClose={clearSelectedFile}
onImportingChange={handleImportingChange}
onSuccess={() => {
void message.success(t('data_import.workbench.message.import_done'));
}}
/>
)
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={(
<div style={{ display: 'grid', gap: 4 }}>
<Text strong>{t('data_import.workbench.state.awaiting_file_title')}</Text>
<Text type="secondary">{t('data_import.workbench.state.awaiting_file_description')}</Text>
<Text strong>
{importMode === 'database'
? t('data_import.workbench.state.awaiting_sql_title')
: t('data_import.workbench.state.awaiting_file_title')}
</Text>
<Text type="secondary">
{importMode === 'database'
? t('data_import.workbench.state.awaiting_sql_description')
: t('data_import.workbench.state.awaiting_file_description')}
</Text>
</div>
)}
/>

View File

@@ -0,0 +1,219 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SQLFileExecutionState } from './useSQLFileExecutionRunner';
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
const createRunnerState = (
overrides: Partial<SQLFileExecutionState> = {},
): SQLFileExecutionState => ({
jobId: '',
title: '',
filePath: '',
fileSizeMB: '',
startedAt: 0,
finishedAt: 0,
status: 'idle',
stage: '',
executed: 0,
failed: 0,
total: 0,
percent: 0,
currentSQL: '',
message: '',
...overrides,
});
const mocks = vi.hoisted(() => ({
importDatabaseSQL: vi.fn(),
cancelSQLFileExecution: vi.fn(),
run: vi.fn(),
cancel: vi.fn(),
reset: vi.fn(),
state: null as SQLFileExecutionState | null,
isRunning: false,
lastRunOptions: null as null | {
run: (jobId: string) => Promise<any>;
cancel?: (jobId: string) => void | Promise<void>;
},
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ImportDatabaseSQL: mocks.importDatabaseSQL,
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
}));
vi.mock('./useSQLFileExecutionRunner', async (importOriginal) => {
const actual = await importOriginal<typeof import('./useSQLFileExecutionRunner')>();
return {
...actual,
useSQLFileExecutionRunner: () => ({
state: mocks.state,
reset: mocks.reset,
cancelExecution: mocks.cancel,
runSQLFileExecutionWithProgress: mocks.run,
isRunning: mocks.isRunning,
}),
};
});
vi.mock('antd', async () => {
const React = await import('react');
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
const Progress = (props: Record<string, unknown>) => React.createElement('mock-progress', props);
const Paragraph = ({ children, ...props }: any) => <p {...props}>{children}</p>;
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
const Title = ({ children, ...props }: any) => <h3 {...props}>{children}</h3>;
return {
Alert,
Button,
Progress,
Typography: { Paragraph, Text, Title },
};
});
vi.mock('@ant-design/icons', () => ({
PlayCircleOutlined: () => React.createElement('mock-icon', { name: 'play' }),
ReloadOutlined: () => React.createElement('mock-icon', { name: 'reload' }),
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
}));
const renderPanel = async (onRunningChange = vi.fn()) => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<DatabaseImportExecutionPanel
connectionConfig={{ type: 'mysql', host: 'localhost', port: 3306 }}
dbName="app"
filePath="/tmp/database.sql"
fileSizeMB="12.5"
darkMode={false}
onRunningChange={onRunningChange}
/>,
);
await Promise.resolve();
});
return renderer;
};
describe('DatabaseImportExecutionPanel', () => {
beforeEach(() => {
mocks.state = createRunnerState();
mocks.isRunning = false;
mocks.lastRunOptions = null;
mocks.importDatabaseSQL.mockReset();
mocks.cancelSQLFileExecution.mockReset();
mocks.reset.mockReset();
mocks.run.mockReset();
mocks.cancel.mockReset();
mocks.run.mockImplementation(async (options: any) => {
mocks.lastRunOptions = options;
return options.run('database-import-job-1');
});
mocks.cancel.mockImplementation(async () => {
await mocks.lastRunOptions?.cancel?.('database-import-job-1');
});
});
it('waits for an explicit start action and reports the full RPC lifetime as running', async () => {
let resolveImport!: (value: { success: boolean; message: string }) => void;
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
resolveImport = resolve;
}));
const onRunningChange = vi.fn();
const renderer = await renderPanel(onRunningChange);
expect(mocks.importDatabaseSQL).not.toHaveBeenCalled();
const startButton = renderer.root.findByProps({
'data-database-import-start-action': 'true',
});
await act(async () => {
startButton.props.onClick();
await Promise.resolve();
});
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'/tmp/database.sql',
'database-import-job-1',
);
expect(onRunningChange).toHaveBeenLastCalledWith(true);
expect(renderer.root.findAllByProps({
'data-database-import-cancel-action': 'true',
}).length).toBeGreaterThan(0);
await act(async () => {
resolveImport({ success: true, message: 'done' });
await Promise.resolve();
await Promise.resolve();
});
expect(onRunningChange).toHaveBeenLastCalledWith(false);
});
it('cancels the active SQL import with the runner job id', async () => {
let resolveImport!: (value: { success: boolean; message: string }) => void;
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
resolveImport = resolve;
}));
mocks.cancelSQLFileExecution.mockResolvedValue({ success: true });
const renderer = await renderPanel();
await act(async () => {
renderer.root.findByProps({
'data-database-import-start-action': 'true',
}).props.onClick();
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-database-import-cancel-action': 'true',
}).props.onClick();
await Promise.resolve();
});
expect(mocks.cancelSQLFileExecution).toHaveBeenCalledWith('database-import-job-1');
await act(async () => {
resolveImport({ success: false, message: 'cancelled' });
await Promise.resolve();
await Promise.resolve();
});
});
it.each([
['done', 'success', 'completed'],
['error', 'error', 'failed'],
['cancelled', 'warning', 'cancelled'],
] as const)('renders %s progress and result state', async (status, alertType, message) => {
mocks.state = createRunnerState({
jobId: 'database-import-job-1',
status,
stage: status,
filePath: '/tmp/database.sql',
executed: 24,
failed: status === 'error' ? 1 : 0,
total: 25,
percent: status === 'done' ? 100 : 96,
currentSQL: 'CREATE TABLE demo(id INT)',
message,
});
const renderer = await renderPanel();
expect(renderer.root.findByProps({
'data-database-import-progress': 'true',
}).props.percent).toBe(status === 'done' ? 100 : 96);
expect(renderer.root.findByProps({
'data-database-import-result': 'true',
}).props).toMatchObject({
type: alertType,
message,
});
expect(renderer.root.findAllByProps({
'data-database-import-current-sql': 'true',
})).toHaveLength(1);
});
});

View File

@@ -0,0 +1,283 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Progress, Typography } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { CancelSQLFileExecution, ImportDatabaseSQL } from '../../wailsjs/go/app/App';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
useSQLFileExecutionRunner,
type SQLFileExecutionRunnerStatus,
} from './useSQLFileExecutionRunner';
const { Paragraph, Text, Title } = Typography;
type DatabaseImportExecutionPanelProps = {
connectionConfig: Record<string, unknown> | null;
dbName?: string;
filePath: string;
fileSizeMB?: string;
darkMode: boolean;
onRunningChange?: (running: boolean) => void;
};
const getFileName = (filePath: string): string => {
const parts = String(filePath || '').split(/[\\/]/);
return parts[parts.length - 1] || filePath;
};
const resolveProgressStatus = (
status: SQLFileExecutionRunnerStatus,
): 'active' | 'success' | 'exception' | 'normal' => {
if (status === 'done') return 'success';
if (status === 'error') return 'exception';
if (status === 'start' || status === 'running') return 'active';
return 'normal';
};
const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps> = ({
connectionConfig,
dbName = '',
filePath,
fileSizeMB,
darkMode,
onRunningChange,
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [executionPending, setExecutionPending] = useState(false);
const [cancelRequested, setCancelRequested] = useState(false);
const lastReportedRunningRef = useRef<boolean | null>(null);
const {
state,
reset,
cancelExecution,
runSQLFileExecutionWithProgress,
isRunning,
} = useSQLFileExecutionRunner({ showToast: false });
const taskRunning = isRunning || executionPending;
const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0));
const terminal = state.status === 'done'
|| state.status === 'cancelled'
|| state.status === 'error';
const subtleBackground = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc';
const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
useEffect(() => {
if (lastReportedRunningRef.current === taskRunning) return;
lastReportedRunningRef.current = taskRunning;
onRunningChange?.(taskRunning);
}, [onRunningChange, taskRunning]);
const startImport = useCallback(async () => {
if (!connectionConfig || !String(filePath || '').trim() || taskRunning) return;
setExecutionPending(true);
setCancelRequested(false);
try {
await runSQLFileExecutionWithProgress({
title: getFileName(filePath),
filePath,
fileSizeMB,
run: (jobId) => ImportDatabaseSQL(
connectionConfig as any,
String(dbName || '').trim(),
filePath,
jobId,
),
cancel: async (jobId) => {
await CancelSQLFileExecution(jobId);
},
});
} catch {
// The shared runner already records and displays the RPC error state.
} finally {
setExecutionPending(false);
}
}, [
connectionConfig,
dbName,
filePath,
fileSizeMB,
runSQLFileExecutionWithProgress,
taskRunning,
]);
const requestCancel = useCallback(async () => {
if (!taskRunning || cancelRequested) return;
setCancelRequested(true);
try {
await cancelExecution();
} catch {
setCancelRequested(false);
}
}, [cancelExecution, cancelRequested, taskRunning]);
const resetProgress = useCallback(() => {
if (taskRunning) return;
setCancelRequested(false);
reset();
}, [reset, taskRunning]);
const statusText = useMemo(() => {
if (cancelRequested && taskRunning) return t('data_import.workbench.state.cancelling');
switch (state.status) {
case 'start':
case 'running':
return t('data_import.workbench.state.running');
case 'done':
return t('data_import.workbench.state.completed');
case 'error':
return t('data_import.workbench.state.failed');
case 'cancelled':
return t('data_import.workbench.state.cancelled');
default:
return t('data_import.workbench.state.ready_sql_title');
}
}, [cancelRequested, state.status, t, taskRunning]);
const resultAlertType = state.status === 'error'
? 'error'
: state.status === 'cancelled'
? 'warning'
: 'success';
return (
<div
data-database-import-execution-panel="true"
style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 }}
>
<Alert
type="warning"
showIcon
message={t('data_import.workbench.notice.partial_execution')}
/>
<Alert
type="info"
showIcon
message={t('data_import.workbench.notice.gonavi_mysql_restore')}
/>
<div
style={{
padding: 16,
borderRadius: 8,
border: `1px solid ${dividerColor}`,
background: subtleBackground,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<Title level={5} style={{ margin: 0, letterSpacing: 0 }}>
{statusText}
</Title>
<Paragraph
type="secondary"
title={filePath}
style={{ margin: '6px 0 0', wordBreak: 'break-all' }}
>
{state.status === 'idle'
? t('data_import.workbench.state.ready_sql_description')
: state.filePath || filePath}
</Paragraph>
</div>
{fileSizeMB ? <Text type="secondary">{fileSizeMB} MB</Text> : null}
</div>
{state.status !== 'idle' ? (
<div style={{ marginTop: 16 }}>
<Progress
data-database-import-progress="true"
percent={Math.round(progressPercent)}
status={resolveProgressStatus(state.status)}
strokeColor={state.status === 'cancelled' ? '#faad14' : undefined}
/>
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Text type="secondary">{state.stage || statusText}</Text>
<Text type="secondary">
{t('data_import.workbench.progress.statements', {
executed: state.executed,
failed: state.failed,
total: state.total,
})}
</Text>
</div>
</div>
) : null}
{state.currentSQL ? (
<div
data-database-import-current-sql="true"
style={{
marginTop: 14,
maxHeight: 112,
overflow: 'auto',
padding: '10px 12px',
borderRadius: 6,
border: `1px solid ${dividerColor}`,
fontFamily: 'var(--gn-font-mono)',
fontSize: 12,
wordBreak: 'break-all',
}}
>
{state.currentSQL}
</div>
) : null}
{terminal && state.message ? (
<Alert
data-database-import-result="true"
style={{ marginTop: 14 }}
type={resultAlertType}
showIcon
message={state.message}
/>
) : null}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 16 }}>
{taskRunning ? (
<Button
data-database-import-cancel-action="true"
danger
icon={<StopOutlined />}
loading={cancelRequested}
disabled={cancelRequested}
onClick={() => void requestCancel()}
>
{cancelRequested
? t('data_import.workbench.state.cancelling')
: t('data_import.workbench.action.cancel_database_import')}
</Button>
) : (
<Button
data-database-import-start-action="true"
type="primary"
icon={terminal ? <ReloadOutlined /> : <PlayCircleOutlined />}
disabled={!connectionConfig || !String(filePath || '').trim()}
onClick={() => void startImport()}
>
{terminal
? t('data_import.workbench.action.retry_database_import')
: t('data_import.workbench.action.start_database_import')}
</Button>
)}
{terminal ? (
<Button
data-database-import-clear-progress-action="true"
icon={<ReloadOutlined />}
onClick={resetProgress}
>
{t('data_export.action.clear_progress')}
</Button>
) : null}
</div>
</div>
</div>
);
};
export default DatabaseImportExecutionPanel;

View File

@@ -2931,11 +2931,12 @@ const Sidebar: React.FC<{
? activeTab.tableName || ''
: '',
).trim();
const mode = node?.type === 'database' ? 'database' : 'table';
const existingImportTab = tabs.find((tab) => tab.id === DATA_IMPORT_WORKBENCH_TAB_ID);
addTab(resolveDataImportWorkbenchLaunchTab(
existingImportTab,
{ connectionId, dbName, tableName },
{ connectionId, dbName, tableName, mode },
));
}, [activeContext?.connectionId, activeContext?.dbName, activeTabId, addTab, tabs]);

View File

@@ -23,14 +23,15 @@ const placeholders = (value: string): string[] => [...value.matchAll(/\{\{(\w+)\
.sort();
describe('Sidebar database export feedback i18n', () => {
it('routes database SQL export into the background workbench', () => {
it('opens database SQL exports in the workbench for review', () => {
const block = extractHandleExportDatabaseBlock();
expect(block).toContain('showSQLExportOptionsDialog()');
expect(block).not.toContain('showSQLExportOptionsDialog()');
expect(block).toContain('addTab(buildDatabaseExportWorkbenchTab({');
expect(block).toContain("contentMode: includeData ? 'backup' : 'schema'");
expect(block).toContain('includeDropIfExists: exportOptions.includeDropIfExists');
expect(block).toContain("requestKey: createTableExportRequestKey('database')");
expect(block).toContain('includeDropIfExists: false');
expect(block).toContain("launchKey: createTableExportKey('database')");
expect(block).not.toContain('requestKey:');
expect(block).not.toContain('ExportDatabaseSQLWithOptions(');
expect(block).not.toContain('message.loading(');
});

View File

@@ -9,25 +9,27 @@ const bindingSource = readFileSync(new URL('../../wailsjs/go/app/App.d.ts', impo
const modelSource = readFileSync(new URL('../../wailsjs/go/models.ts', import.meta.url), 'utf8');
describe('Sidebar SQL export options', () => {
it('collects launch options at direct entries and keeps batch options in the workbench', () => {
it('opens database and table backups for review while retaining schema confirmation', () => {
expect(hookSource).toContain('buildDatabaseExportWorkbenchTab({');
expect(hookSource).toContain('buildSchemaExportWorkbenchTab({');
expect(hookSource).toContain('buildBatchTableExportWorkbenchTab({');
expect(hookSource).toContain('buildBatchDatabaseExportWorkbenchTab({');
expect(hookSource.match(/showSQLExportOptionsDialog\(\)/g)).toHaveLength(2);
expect(hookSource.match(/showSQLExportOptionsDialog\(\)/g)).toHaveLength(1);
expect(hookSource).toContain("launchKey: createTableExportKey('database')");
expect(hookSource).toContain('const openBatchTableWorkbench = () =>');
expect(hookSource).toContain('const openBatchDatabaseWorkbench = () =>');
expect(objectActionsSource).toContain("if (options.format === 'sql')");
expect(objectActionsSource).toContain("await openTableSQLExportWorkbench(node, 'backup')");
expect(objectActionsSource).toContain("await openTableSQLExportWorkbench(node, 'dataOnly')");
expect(objectActionsSource).toContain("mode === 'backup'");
expect(objectActionsSource).toContain('{ includeDropIfExists: false }');
expect(objectActionsSource).toContain('includeDropIfExists: exportOptions.includeDropIfExists');
expect(tableOverviewSource).toContain('await showSQLExportOptionsDialog()');
expect(tableOverviewSource).toContain('...resolvedOptions');
expect(objectActionsSource).not.toContain('showSQLExportOptionsDialog');
expect(objectActionsSource).toContain("...(mode === 'backup' ? { launchKey } : { requestKey: launchKey })");
expect(objectActionsSource).toContain('includeDropIfExists: false');
expect(tableOverviewSource).not.toContain('showSQLExportOptionsDialog');
expect(tableOverviewSource).toContain("...(mode === 'backup' ? { launchKey } : { requestKey: launchKey })");
expect(workbenchSource).toContain('const [includeDropIfExists, setIncludeDropIfExists] = useState(');
expect(workbenchSource).toContain('includeDropIfExists: includeSchema && includeDropIfExists');
expect(workbenchSource).toContain('includeDropIfExists,');
expect(workbenchSource).toContain('includeDatabaseContext,');
expect(workbenchSource).toContain('onChange={(event) => setIncludeDropIfExists(event.target.checked)}');
});
@@ -36,5 +38,7 @@ describe('Sidebar SQL export options', () => {
expect(bindingSource).toContain('ExportSchemaSQLWithOptions(');
expect(modelSource).toContain('includeDropIfExists?: boolean;');
expect(modelSource).toContain('this.includeDropIfExists = source["includeDropIfExists"]');
expect(modelSource).toContain('includeDatabaseContext?: boolean;');
expect(modelSource).toContain('this.includeDatabaseContext = source["includeDatabaseContext"]');
});
});

View File

@@ -43,7 +43,7 @@ describe('Sidebar schema export feedback i18n', () => {
expect(block).toContain('schemaName,');
expect(block).toContain("contentMode: includeData ? 'backup' : 'schema'");
expect(block).toContain('includeDropIfExists: exportOptions.includeDropIfExists');
expect(block).toContain("requestKey: createTableExportRequestKey('schema')");
expect(block).toContain("requestKey: createTableExportKey('schema')");
expect(block).not.toContain('ExportSchemaSQLWithOptions(');
expect(block).not.toContain('message.loading(');
expect(executionBlock).toContain('await runExportWithProgress({');

View File

@@ -30,14 +30,14 @@ describe('Sidebar table export feedback i18n', () => {
expect(block).toContain('totalRowsKnown');
});
it('launches table backups and INSERT exports with distinct background modes', () => {
it('opens table backups for review while retaining automatic INSERT exports', () => {
expect(source).toContain("const openTableSQLExportWorkbench = async (node: any, mode: 'backup' | 'dataOnly')");
expect(source).toContain("mode === 'backup'");
expect(source).toContain('showSQLExportOptionsDialog()');
expect(source).not.toContain('showSQLExportOptionsDialog');
expect(source).toContain('addTab(buildBatchTableExportWorkbenchTab({');
expect(source).toContain('initialObjectNames: [tableName]');
expect(source).toContain('contentMode: mode');
expect(source).toContain('includeDropIfExists: exportOptions.includeDropIfExists');
expect(source).toContain('includeDropIfExists: false');
expect(source).toContain("...(mode === 'backup' ? { launchKey } : { requestKey: launchKey })");
expect(source).toContain("await openTableSQLExportWorkbench(node, 'dataOnly')");
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { Button, Select } from 'antd';
import { Button, Checkbox, Select } from 'antd';
import { renderToStaticMarkup } from 'react-dom/server';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -18,6 +18,7 @@ import {
DropDatabase,
DropTable,
ExportDatabaseSQLWithOptions,
ExportDatabasesSQLWithOptions,
ExportQueryWithOptions,
ExportSchemaSQLWithOptions,
ExportTableWithOptions,
@@ -25,6 +26,7 @@ import {
} from '../../wailsjs/go/app/App';
import { loadViews } from './sidebar/sidebarMetadataLoaders';
import { setCurrentLanguage } from '../i18n';
import { buildBatchTableExportWorkbenchTab } from '../utils/tableExportTab';
import type { ExportProgressState } from './useExportProgressRunner';
import type { ExportProgressLogEntry } from './useExportProgressRunner';
import Modal from './common/ResizableDraggableModal';
@@ -192,6 +194,7 @@ describe('TableExportWorkbench', () => {
vi.mocked(loadViews).mockReset();
vi.mocked(loadViews).mockResolvedValue({ views: [], supported: true });
vi.mocked(ExportDatabaseSQLWithOptions).mockReset();
vi.mocked(ExportDatabasesSQLWithOptions).mockReset();
vi.mocked(ExportQueryWithOptions).mockReset();
vi.mocked(ExportSchemaSQLWithOptions).mockReset();
vi.mocked(ExportTableWithOptions).mockReset();
@@ -889,12 +892,77 @@ describe('TableExportWorkbench', () => {
format: 'sql',
jobId: 'database-job-1',
includeDropIfExists: true,
includeDatabaseContext: true,
}),
);
renderer.unmount();
});
it('defaults database context by export mode and preserves a manual override', async () => {
mockProgressRunnerState = createIdleProgressRunnerState();
vi.mocked(ExportDatabaseSQLWithOptions).mockResolvedValue({ success: true } as any);
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<TableExportWorkbench
tab={{
id: 'table-export-database-conn-1-SYS',
title: '导出 SYS',
type: 'table-export',
exportWorkbenchMode: 'database',
connectionId: 'conn-1',
dbName: 'SYS',
tableExportContentMode: 'schema',
}}
/>,
);
});
const findDatabaseModeSelect = () => renderer.root.findAllByType(Select).find((node) => (
Array.isArray(node.props.options)
&& node.props.options.some((option: { value?: string }) => option.value === 'schema')
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
));
const findDatabaseContextCheckbox = () => renderer.root.findByProps({
'data-export-include-database-context': 'true',
});
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
const startButton = renderer.root.findAllByType(Button).find((node) => (
node.props.type === 'primary' && node.props.size === 'large'
));
await act(async () => {
startButton?.props.onClick();
});
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
await run('database-schema-job-1');
expect(ExportDatabaseSQLWithOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'mysql' }),
'SYS',
false,
expect.objectContaining({
includeDropIfExists: false,
includeDatabaseContext: false,
}),
);
await act(async () => {
findDatabaseModeSelect()?.props.onChange('backup');
});
expect(findDatabaseContextCheckbox().props.checked).toBe(true);
await act(async () => {
findDatabaseContextCheckbox().props.onChange({ target: { checked: false } });
});
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
renderer.unmount();
});
it('auto-starts a direct schema export inside the workbench', async () => {
mockProgressRunnerState = createProgressRunnerState({
open: false,
@@ -949,6 +1017,9 @@ describe('TableExportWorkbench', () => {
includeDropIfExists: false,
}),
);
expect(renderer.root.findAllByType(Checkbox).filter((node) => (
node.props['data-export-include-database-context'] === 'true'
))).toHaveLength(0);
renderer.unmount();
});
@@ -1010,6 +1081,149 @@ describe('TableExportWorkbench', () => {
renderer.unmount();
});
it('refreshes launch-only table presets without auto-starting an export', async () => {
mockProgressRunnerState = createIdleProgressRunnerState();
vi.mocked(DBGetDatabases).mockResolvedValue({
success: true,
data: [{ Database: 'SYS' }, { Database: 'audit' }],
} as any);
vi.mocked(DBGetTables).mockResolvedValue({
success: true,
data: [{ name: 'users' }, { name: 'orders' }],
} as any);
const buildTab = (
launchKey: string,
database: string,
tableName: string,
contentMode: 'schema' | 'backup',
includeDropIfExists: boolean,
) => ({
id: 'table-export-batch-tables-conn-1',
title: '导出已选对象',
type: 'table-export' as const,
exportWorkbenchMode: 'batch-tables' as const,
connectionId: 'conn-1',
dbName: database,
tableExportInitialObjectNames: [tableName],
tableExportContentMode: contentMode,
tableExportIncludeDropIfExists: includeDropIfExists,
tableExportLaunchKey: launchKey,
});
const readConfig = (renderer: ReactTestRenderer) => {
const selects = renderer.root.findAllByType(Select);
const objectSelect = selects.find((node) => node.props.mode === 'multiple');
const contentModeSelect = selects.find((node) => (
Array.isArray(node.props.options)
&& node.props.options.some((option: { value?: string }) => option.value === 'dataOnly')
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
));
const dropIfExistsCheckbox = renderer.root.findAllByType(Checkbox)[0];
return { objectSelect, contentModeSelect, dropIfExistsCheckbox };
};
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<TableExportWorkbench tab={buildTab('launch-1', 'SYS', 'users', 'schema', false)} />,
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
let config = readConfig(renderer);
expect(config.objectSelect?.props.value).toEqual(['users']);
expect(config.contentModeSelect?.props.value).toBe('schema');
expect(config.dropIfExistsCheckbox?.props.checked).toBe(false);
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
await act(async () => {
renderer.update(
<TableExportWorkbench tab={buildTab('launch-2', 'audit', 'orders', 'backup', true)} />,
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
config = readConfig(renderer);
expect(config.objectSelect?.props.value).toEqual(['orders']);
expect(config.contentModeSelect?.props.value).toBe('backup');
expect(config.dropIfExistsCheckbox?.props.checked).toBe(true);
expect(mockUseExportProgressRunner).toHaveBeenLastCalledWith({
taskKey: 'table-export-batch-tables-conn-1',
requestKey: undefined,
});
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
expect(ExportTablesSQLWithOptions).not.toHaveBeenCalled();
renderer.unmount();
});
it('switches a reused table workbench from auto-start to review-only without restarting', async () => {
mockProgressRunnerState = createIdleProgressRunnerState();
vi.mocked(DBGetDatabases).mockResolvedValue({
success: true,
data: [{ Database: 'SYS' }],
} as any);
vi.mocked(DBGetTables).mockResolvedValue({
success: true,
data: [{ name: 'users' }, { name: 'orders' }],
} as any);
const autoTab = buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'SYS',
initialObjectNames: ['users'],
contentMode: 'dataOnly',
includeDropIfExists: true,
requestKey: 'request-1',
});
const reviewTab = buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'SYS',
initialObjectNames: ['orders'],
contentMode: 'backup',
includeDropIfExists: false,
launchKey: 'launch-2',
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<TableExportWorkbench tab={autoTab} />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
await act(async () => {
renderer.update(<TableExportWorkbench tab={{ ...autoTab, ...reviewTab }} />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const selects = renderer.root.findAllByType(Select);
const objectSelect = selects.find((node) => node.props.mode === 'multiple');
const contentModeSelect = selects.find((node) => (
Array.isArray(node.props.options)
&& node.props.options.some((option: { value?: string }) => option.value === 'dataOnly')
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
));
expect(objectSelect?.props.value).toEqual(['orders']);
expect(contentModeSelect?.props.value).toBe('backup');
expect(renderer.root.findAllByType(Checkbox)[0]?.props.checked).toBe(false);
expect(mockUseExportProgressRunner).toHaveBeenLastCalledWith({
taskKey: autoTab.id,
requestKey: undefined,
});
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
expect(ExportTablesSQLWithOptions).not.toHaveBeenCalled();
renderer.unmount();
});
it('reuses a stable task key and applies merged launch options before restarting', async () => {
mockProgressRunnerState = createProgressRunnerState({
open: false,
@@ -1064,6 +1278,7 @@ describe('TableExportWorkbench', () => {
tableExportInitialDatabaseNames: [database],
tableExportContentMode: contentMode,
tableExportIncludeDropIfExists: includeDropIfExists,
tableExportLaunchKey: 'stale-launch',
tableExportRequestKey: requestKey,
});
@@ -1072,12 +1287,27 @@ describe('TableExportWorkbench', () => {
renderer = create(<TableExportWorkbench tab={buildTab('request-1', 'SYS', 'schema', false, 'conn-2')} />);
await Promise.resolve();
});
expect(renderer.root.findAllByProps({ 'data-export-include-database-context': 'true' })).toHaveLength(0);
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
const firstRun = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
await firstRun('batch-databases-job-1');
expect(ExportDatabasesSQLWithOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'postgres' }),
['SYS'],
false,
expect.objectContaining({
includeDropIfExists: false,
includeDatabaseContext: false,
}),
);
await act(async () => {
renderer.update(<TableExportWorkbench tab={buildTab('request-2', 'audit', 'backup', true, 'conn-1')} />);
await Promise.resolve();
});
expect(renderer.root.findAllByType(Checkbox).filter((node) => (
node.props['data-export-include-database-context'] === 'true'
))).toHaveLength(1);
expect(mockUseExportProgressRunner).toHaveBeenCalledWith({
taskKey: 'table-export-batch-databases-conn-1',
@@ -1087,17 +1317,59 @@ describe('TableExportWorkbench', () => {
const secondRun = mockRunExportWithProgress.mock.calls[1][0].run as (jobId: string) => Promise<unknown>;
await secondRun('batch-databases-job-2');
const { ExportDatabasesSQLWithOptions } = await import('../../wailsjs/go/app/App');
expect(ExportDatabasesSQLWithOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'mysql' }),
['audit'],
true,
expect.objectContaining({ includeDropIfExists: true }),
expect.objectContaining({
includeDropIfExists: true,
includeDatabaseContext: true,
}),
);
renderer.unmount();
});
it('resets the database context default when a stable database workbench is reopened', async () => {
mockProgressRunnerState = createIdleProgressRunnerState();
vi.mocked(DBGetDatabases).mockResolvedValue({
success: true,
data: [{ Database: 'SYS' }, { Database: 'audit' }],
} as any);
const buildTab = (launchKey: string, contentMode: 'schema' | 'backup') => ({
id: 'table-export-batch-databases-conn-1',
title: '批量导出库',
type: 'table-export' as const,
exportWorkbenchMode: 'batch-databases' as const,
connectionId: 'conn-1',
tableExportInitialDatabaseNames: ['SYS'],
tableExportContentMode: contentMode,
tableExportLaunchKey: launchKey,
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<TableExportWorkbench tab={buildTab('launch-1', 'backup')} />);
await Promise.resolve();
});
const findDatabaseContextCheckbox = () => renderer.root.findByProps({
'data-export-include-database-context': 'true',
});
expect(findDatabaseContextCheckbox().props.checked).toBe(true);
await act(async () => {
renderer.update(<TableExportWorkbench tab={buildTab('launch-2', 'schema')} />);
await Promise.resolve();
});
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
renderer.unmount();
});
it('renders retained task logs in the current task panel', () => {
mockProgressLogs = [
{
@@ -1356,6 +1628,8 @@ describe('TableExportWorkbench', () => {
const keys = [
'data_export.action.restore_backup',
'data_export.label.schema',
'data_export.sql_options.database_context.description',
'data_export.sql_options.database_context.label',
'data_export.workbench.section.logs',
] as const;

View File

@@ -273,6 +273,8 @@ const resolveBatchTableModeMeta = (mode: BatchTableExportMode) =>
const resolveBatchDatabaseModeMeta = (mode: BatchDatabaseExportMode) =>
createBatchDatabaseExportModeOptions().find((item) => item.value === mode) || createBatchDatabaseExportModeOptions()[0];
const shouldIncludeDatabaseContextByDefault = (mode: BatchDatabaseExportMode): boolean => mode === 'backup';
const resolveBatchTablesTargetName = (dbName: string, objectCount: number): string => {
const safeDbName = String(dbName || '').trim() || t('data_export.workbench.target.current_database');
return t('data_export.workbench.target.batch_tables', { database: safeDbName, count: objectCount });
@@ -381,6 +383,9 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
tab.tableExportContentMode === 'backup' ? 'backup' : 'schema'
));
const [includeDropIfExists, setIncludeDropIfExists] = useState(tab.tableExportIncludeDropIfExists === true);
const [includeDatabaseContext, setIncludeDatabaseContext] = useState(() => (
shouldIncludeDatabaseContextByDefault(tab.tableExportContentMode === 'backup' ? 'backup' : 'schema')
));
const [loadingDatabases, setLoadingDatabases] = useState(false);
const [loadingObjects, setLoadingObjects] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
@@ -388,7 +393,9 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const [objectLoadError, setObjectLoadError] = useState('');
const [columnLoadError, setColumnLoadError] = useState('');
const [destructiveOperation, setDestructiveOperation] = useState<BatchDestructiveOperation | null>(null);
const [appliedLaunchRequestKey, setAppliedLaunchRequestKey] = useState(() => String(tab.tableExportRequestKey || '').trim());
const [appliedLaunchKey, setAppliedLaunchKey] = useState(() => (
String(tab.tableExportRequestKey || tab.tableExportLaunchKey || '').trim()
));
const syncBatchWorkbenchTabContext = useCallback((connectionId: string, dbName?: string) => {
if (!isBatchTablesWorkbench && !isBatchDatabasesWorkbench) return;
@@ -433,6 +440,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
() => (connection ? normalizeConnectionConfig(connection) : null),
[connection],
);
const supportsDatabaseContextOption = String(connectionConfig?.type || '').trim().toLowerCase() === 'mysql';
const connectionCapabilities = useMemo(
() => getDataSourceCapabilities(connection?.config),
[connection?.config],
@@ -478,8 +486,8 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
});
useEffect(() => {
const requestKey = String(tab.tableExportRequestKey || '').trim();
if (!requestKey || requestKey === appliedLaunchRequestKey || isRunning) {
const launchKey = String(tab.tableExportRequestKey || tab.tableExportLaunchKey || '').trim();
if (!launchKey || launchKey === appliedLaunchKey || isRunning) {
return;
}
setSelectedConnectionId(String(tab.connectionId || '').trim());
@@ -487,11 +495,13 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
setSelectedObjectNames(tab.tableExportInitialObjectNames || []);
setSelectedDatabaseNames(tab.tableExportInitialDatabaseNames || []);
setBatchTableMode(tab.tableExportContentMode || 'schema');
setBatchDatabaseMode(tab.tableExportContentMode === 'backup' ? 'backup' : 'schema');
const nextDatabaseMode = tab.tableExportContentMode === 'backup' ? 'backup' : 'schema';
setBatchDatabaseMode(nextDatabaseMode);
setIncludeDropIfExists(tab.tableExportIncludeDropIfExists === true);
setAppliedLaunchRequestKey(requestKey);
setIncludeDatabaseContext(shouldIncludeDatabaseContextByDefault(nextDatabaseMode));
setAppliedLaunchKey(launchKey);
}, [
appliedLaunchRequestKey,
appliedLaunchKey,
isRunning,
tab.connectionId,
tab.dbName,
@@ -499,6 +509,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
tab.tableExportIncludeDropIfExists,
tab.tableExportInitialDatabaseNames,
tab.tableExportInitialObjectNames,
tab.tableExportLaunchKey,
tab.tableExportRequestKey,
]);
@@ -1199,6 +1210,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
totalRowsHint: selectedDatabaseNames.length,
totalRowsKnown: true,
includeDropIfExists,
includeDatabaseContext,
} as any,
),
});
@@ -1223,6 +1235,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
totalRowsHint: 0,
totalRowsKnown: false,
includeDropIfExists,
includeDatabaseContext,
} as any,
),
});
@@ -1279,7 +1292,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
const requestKey = String(tab.tableExportRequestKey || '').trim();
if (
!requestKey
|| requestKey !== appliedLaunchRequestKey
|| requestKey !== appliedLaunchKey
|| requestKey === lastAutoStartRequestKeyRef.current
|| requestKey === String(progressState.requestKey || '').trim()
|| !canStart
@@ -1289,7 +1302,7 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
}
lastAutoStartRequestKeyRef.current = requestKey;
void handleStartExport();
}, [appliedLaunchRequestKey, canStart, isRunning, progressState.requestKey, tab.tableExportRequestKey]);
}, [appliedLaunchKey, canStart, isRunning, progressState.requestKey, tab.tableExportRequestKey]);
const headerBadges = useMemo(() => {
if (isSingleWorkbench) {
@@ -1644,13 +1657,36 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
value={batchDatabaseMode}
disabled={isConfigurationLocked}
options={createBatchDatabaseExportModeOptions().map((item) => ({ value: item.value, label: item.label }))}
onChange={(next) => setBatchDatabaseMode(next as BatchDatabaseExportMode)}
onChange={(next) => {
const nextMode = next as BatchDatabaseExportMode;
setBatchDatabaseMode(nextMode);
setIncludeDatabaseContext(shouldIncludeDatabaseContextByDefault(nextMode));
}}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{batchDatabaseModeMeta.description}
</div>
</div>
{isDirectDatabaseWorkbench && supportsDatabaseContextOption ? (
<div>
<Checkbox
data-export-include-database-context="true"
checked={includeDatabaseContext}
disabled={isConfigurationLocked}
onChange={(event) => setIncludeDatabaseContext(event.target.checked)}
>
{t('data_export.sql_options.database_context.label')}
</Checkbox>
<Alert
type="info"
showIcon
style={{ marginTop: 8 }}
message={t('data_export.sql_options.database_context.description')}
/>
</div>
) : null}
<div>
<Checkbox
checked={includeDropIfExists}
@@ -1901,13 +1937,36 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => {
value={batchDatabaseMode}
disabled={isConfigurationLocked}
options={createBatchDatabaseExportModeOptions().map((item) => ({ value: item.value, label: item.label }))}
onChange={(next) => setBatchDatabaseMode(next as BatchDatabaseExportMode)}
onChange={(next) => {
const nextMode = next as BatchDatabaseExportMode;
setBatchDatabaseMode(nextMode);
setIncludeDatabaseContext(shouldIncludeDatabaseContextByDefault(nextMode));
}}
/>
<div style={{ marginTop: 6, fontSize: 12, color: secondaryTextColor }}>
{batchDatabaseModeMeta.description}
</div>
</div>
{supportsDatabaseContextOption ? (
<div>
<Checkbox
data-export-include-database-context="true"
checked={includeDatabaseContext}
disabled={isConfigurationLocked}
onChange={(event) => setIncludeDatabaseContext(event.target.checked)}
>
{t('data_export.sql_options.database_context.label')}
</Checkbox>
<Alert
type="info"
showIcon
style={{ marginTop: 8 }}
message={t('data_export.sql_options.database_context.description')}
/>
</div>
) : null}
<div>
<Checkbox
checked={includeDropIfExists}

View File

@@ -26,15 +26,15 @@ describe('TableOverview v2 context menu', () => {
expect(listSource).not.toContain('popupRender');
});
it('routes table backup and INSERT export entries through the retained export workbench', () => {
it('opens table backups for review while retaining automatic INSERT exports', () => {
const source = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8');
expect(source).toContain('buildBatchTableExportWorkbenchTab({');
expect(source).toContain("const resolvedOptions = mode === 'backup'");
expect(source).toContain('await showSQLExportOptionsDialog()');
expect(source).not.toContain('showSQLExportOptionsDialog');
expect(source).toContain('initialObjectNames: [normalizedTableName]');
expect(source).toContain('contentMode: mode');
expect(source).toContain('...resolvedOptions');
expect(source).toContain('includeDropIfExists: false');
expect(source).toContain("...(mode === 'backup' ? { launchKey } : { requestKey: launchKey })");
expect(source).toContain("await openTableSQLExportWorkbench(tableName, 'dataOnly')");
expect(source).toContain("void openTableSQLExportWorkbench(tableName, 'backup')");
expect(source).toContain("onClick: () => openTableSQLExportWorkbench(table.name, 'backup')");

View File

@@ -30,7 +30,6 @@ import { buildBatchTableExportWorkbenchTab, buildTableExportTab } from '../utils
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
import { showSQLExportOptionsDialog } from './SQLExportOptionsDialog';
import { confirmCopyTable } from './tableCopyAction';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
@@ -612,18 +611,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openTableSQLExportWorkbench = useCallback(async (tableName: string, mode: 'backup' | 'dataOnly') => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) return;
const resolvedOptions = mode === 'backup'
? await showSQLExportOptionsDialog()
: { includeDropIfExists: false };
if (!resolvedOptions) return;
const launchKey = `table-overview-${mode}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
addTab(buildBatchTableExportWorkbenchTab({
connectionId: tab.connectionId,
dbName: tab.dbName,
initialObjectNames: [normalizedTableName],
contentMode: mode,
requestKey: `table-overview-${mode}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
includeDropIfExists: false,
...(mode === 'backup' ? { launchKey } : { requestKey: launchKey }),
title: t('file.backend.dialog.export_table', { table: normalizedTableName }),
...resolvedOptions,
}));
}, [addTab, tab.connectionId, tab.dbName]);

View File

@@ -1,7 +1,12 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedConnection } from '../../types';
import { resolveBatchWorkbenchContext } from './useSidebarBatchExport';
import { showSQLExportOptionsDialog } from '../SQLExportOptionsDialog';
import { resolveBatchWorkbenchContext, useSidebarBatchExport } from './useSidebarBatchExport';
vi.mock('../SQLExportOptionsDialog', () => ({
showSQLExportOptionsDialog: vi.fn(),
}));
const connections = [
{
@@ -32,3 +37,39 @@ describe('resolveBatchWorkbenchContext', () => {
}], connections)).toEqual({ connectionId: 'sql-1', dbName: '' });
});
});
describe('useSidebarBatchExport', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
[false, 'schema'],
[true, 'backup'],
] as const)('opens database export mode %s directly in the workbench', async (includeData, contentMode) => {
const addTab = vi.fn();
const { handleExportDatabaseSQL } = useSidebarBatchExport({
connections,
selectedNodesRef: { current: [] },
addTab,
});
await handleExportDatabaseSQL({
type: 'database',
title: 'app',
dataRef: { id: 'sql-1', dbName: 'app' },
}, includeData);
expect(showSQLExportOptionsDialog).not.toHaveBeenCalled();
expect(addTab).toHaveBeenCalledOnce();
expect(addTab).toHaveBeenCalledWith(expect.objectContaining({
exportWorkbenchMode: 'database',
connectionId: 'sql-1',
dbName: 'app',
tableExportContentMode: contentMode,
tableExportIncludeDropIfExists: false,
tableExportLaunchKey: expect.stringMatching(/^database-/),
tableExportRequestKey: undefined,
}));
});
});

View File

@@ -12,7 +12,7 @@ import {
} from '../../utils/tableExportTab';
import { showSQLExportOptionsDialog } from '../SQLExportOptionsDialog';
const createTableExportRequestKey = (prefix: string): string => (
const createTableExportKey = (prefix: string): string => (
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
@@ -65,14 +65,12 @@ export const useSidebarBatchExport = ({
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
const conn = node.dataRef;
const dbName = conn.dbName || node.title;
const exportOptions = await showSQLExportOptionsDialog();
if (!exportOptions) return;
addTab(buildDatabaseExportWorkbenchTab({
connectionId: String(conn.id || '').trim(),
dbName,
contentMode: includeData ? 'backup' : 'schema',
includeDropIfExists: exportOptions.includeDropIfExists,
requestKey: createTableExportRequestKey('database'),
includeDropIfExists: false,
launchKey: createTableExportKey('database'),
}));
};
@@ -92,7 +90,7 @@ export const useSidebarBatchExport = ({
schemaName,
contentMode: includeData ? 'backup' : 'schema',
includeDropIfExists: exportOptions.includeDropIfExists,
requestKey: createTableExportRequestKey('schema'),
requestKey: createTableExportKey('schema'),
}));
};

View File

@@ -14,7 +14,6 @@ import { buildSqlServerObjectDefinitionQueries } from '../../utils/sqlServerObje
import { buildStarRocksMaterializedViewPreviewSql } from '../tableDesignerSchemaSql';
import type { ExportRunResult, RunExportWithProgressOptions } from '../useExportProgressRunner';
import { getTableDataDangerActionMeta, type TableDataDangerActionKind } from '../tableDataDangerActions';
import { showSQLExportOptionsDialog } from '../SQLExportOptionsDialog';
import { confirmCopyTable } from '../tableCopyAction';
import {
buildDuckDBMacroDDL,
@@ -281,20 +280,17 @@ export const useSidebarObjectActions = ({
message.warning(t('sidebar.message.table_export_target_missing'));
return;
}
const exportOptions = mode === 'backup'
? await showSQLExportOptionsDialog()
: { includeDropIfExists: false };
if (!exportOptions) return;
const connectionId = resolveSidebarNodeConnectionId(node, connectionIds)
|| String(node?.dataRef?.id || '').trim();
const dbName = String(node?.dataRef?.dbName || '').trim();
const launchKey = `table-${Date.now()}-${Math.random().toString(36).slice(2)}`;
addTab(buildBatchTableExportWorkbenchTab({
connectionId,
dbName,
initialObjectNames: [tableName],
contentMode: mode,
includeDropIfExists: exportOptions.includeDropIfExists,
requestKey: `table-${Date.now()}-${Math.random().toString(36).slice(2)}`,
includeDropIfExists: false,
...(mode === 'backup' ? { launchKey } : { requestKey: launchKey }),
title: t('file.backend.dialog.export_table', { table: tableName }),
}));
};

View File

@@ -6,6 +6,7 @@ import {
buildTableAccessCountKey,
MAX_TABLE_ACCESS_COUNT_ENTRIES,
} from './utils/tableAccessCount';
import { buildBatchTableExportWorkbenchTab } from './utils/tableExportTab';
class MemoryStorage implements Storage {
private data = new Map<string, string>();
@@ -2630,6 +2631,36 @@ describe('store appearance persistence', () => {
expect(useStore.getState().activeTabId).toBe('table-export-conn-1-main-users');
});
it('clears an auto-start request when a stable export workbench is reopened for review', async () => {
const { useStore } = await importStore();
useStore.getState().addTab(buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'main',
initialObjectNames: ['users'],
contentMode: 'dataOnly',
includeDropIfExists: true,
requestKey: 'request-1',
}));
useStore.getState().addTab(buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'main',
initialObjectNames: ['orders'],
contentMode: 'backup',
includeDropIfExists: false,
launchKey: 'launch-2',
}));
expect(useStore.getState().tabs).toHaveLength(1);
expect(useStore.getState().tabs[0]).toEqual(expect.objectContaining({
tableExportInitialObjectNames: ['orders'],
tableExportContentMode: 'backup',
tableExportIncludeDropIfExists: false,
tableExportLaunchKey: 'launch-2',
tableExportRequestKey: undefined,
}));
});
it('keeps a running data import tab until the foreground import finishes', async () => {
const { useStore } = await importStore();

View File

@@ -523,7 +523,10 @@ export interface TabData {
tableExportInitialDatabaseNames?: string[];
tableExportContentMode?: TableExportContentMode;
tableExportIncludeDropIfExists?: boolean;
tableExportLaunchKey?: string;
tableExportRequestKey?: string;
dataImportMode?: "table" | "database";
dataImportLaunchKey?: string;
dataImportRunning?: boolean;
sqlFileExecutionRequestKey?: string;
sqlFileExecutionFileSizeMB?: string;

View File

@@ -12,6 +12,7 @@ describe('dataImportTab', () => {
connectionId: ' conn-1 ',
dbName: ' app ',
tableName: ' public.users ',
launchKey: ' launch-table-1 ',
title: ' 数据导入 ',
});
@@ -22,6 +23,8 @@ describe('dataImportTab', () => {
connectionId: 'conn-1',
dbName: 'app',
tableName: 'public.users',
dataImportMode: 'table',
dataImportLaunchKey: 'launch-table-1',
initialTab: 'target',
});
});
@@ -33,6 +36,26 @@ describe('dataImportTab', () => {
expect(tab.connectionId).toBe('');
expect(tab.dbName).toBeUndefined();
expect(tab.tableName).toBeUndefined();
expect(tab.dataImportMode).toBe('table');
expect(tab.dataImportLaunchKey).toMatch(/^data-import-/);
});
it('builds a database import launch that clears a stale table target', () => {
const tab = buildDataImportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'app',
tableName: 'users',
mode: 'database',
launchKey: 'database-launch-1',
});
expect(tab).toMatchObject({
connectionId: 'conn-1',
dbName: 'app',
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
});
expect(tab).toHaveProperty('tableName', undefined);
});
it('keeps the active target when the stable workbench is reopened during an import', () => {
@@ -41,6 +64,7 @@ describe('dataImportTab', () => {
connectionId: 'conn-1',
dbName: 'app',
tableName: 'users',
launchKey: 'running-launch',
}),
dataImportRunning: true,
};
@@ -49,6 +73,8 @@ describe('dataImportTab', () => {
connectionId: 'conn-2',
dbName: 'analytics',
tableName: 'events',
mode: 'database',
launchKey: 'ignored-launch',
})).toBe(existing);
});
});

View File

@@ -3,10 +3,14 @@ import type { TabData } from '../types';
export const DATA_IMPORT_WORKBENCH_TAB_ID = 'data-import-workbench';
export type DataImportMode = 'table' | 'database';
export type BuildDataImportWorkbenchTabInput = {
connectionId?: string;
dbName?: string;
tableName?: string;
mode?: DataImportMode;
launchKey?: string;
title?: string;
};
@@ -17,16 +21,23 @@ const normalizeOptionalText = (value: string | undefined): string | undefined =>
export const buildDataImportWorkbenchTab = (
input: BuildDataImportWorkbenchTabInput = {},
): TabData => ({
id: DATA_IMPORT_WORKBENCH_TAB_ID,
title: String(input.title || t('data_import.workbench.title')).trim()
|| t('data_import.workbench.title'),
type: 'data-import' as TabData['type'],
connectionId: normalizeOptionalText(input.connectionId) || '',
dbName: normalizeOptionalText(input.dbName),
tableName: normalizeOptionalText(input.tableName),
initialTab: 'target',
});
): TabData => {
const mode: DataImportMode = input.mode === 'database' ? 'database' : 'table';
const launchKey = normalizeOptionalText(input.launchKey)
|| `data-import-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: DATA_IMPORT_WORKBENCH_TAB_ID,
title: String(input.title || t('data_import.workbench.title')).trim()
|| t('data_import.workbench.title'),
type: 'data-import' as TabData['type'],
connectionId: normalizeOptionalText(input.connectionId) || '',
dbName: normalizeOptionalText(input.dbName),
tableName: mode === 'table' ? normalizeOptionalText(input.tableName) : undefined,
dataImportMode: mode,
dataImportLaunchKey: launchKey,
initialTab: 'target',
};
};
export const resolveDataImportWorkbenchLaunchTab = (
existingTab: TabData | undefined,

View File

@@ -126,10 +126,43 @@ describe('tableExportTab', () => {
tableExportInitialObjectNames: ['users', 'orders'],
tableExportContentMode: 'backup',
tableExportIncludeDropIfExists: true,
tableExportLaunchKey: 'batch-tables-1',
tableExportRequestKey: 'batch-tables-1',
}));
});
it('carries launch-only refresh keys without creating auto-start requests', () => {
const tableTab = buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'SYS',
initialObjectNames: [' users '],
contentMode: 'backup',
includeDropIfExists: false,
launchKey: ' table-launch-1 ',
});
const databaseTab = buildDatabaseExportWorkbenchTab({
connectionId: 'conn-1',
dbName: ' app ',
contentMode: 'schema',
launchKey: ' database-launch-1 ',
});
expect(tableTab).toEqual(expect.objectContaining({
tableExportInitialObjectNames: ['users'],
tableExportContentMode: 'backup',
tableExportIncludeDropIfExists: false,
tableExportLaunchKey: 'table-launch-1',
}));
expect(databaseTab).toEqual(expect.objectContaining({
exportWorkbenchMode: 'database',
dbName: 'app',
tableExportContentMode: 'schema',
tableExportLaunchKey: 'database-launch-1',
}));
expect(tableTab).toHaveProperty('tableExportRequestKey', undefined);
expect(databaseTab).toHaveProperty('tableExportRequestKey', undefined);
});
it('builds batch database export workbench tabs with stable ids', () => {
setCurrentLanguage('zh-CN');
const tab = buildBatchDatabaseExportWorkbenchTab({
@@ -155,6 +188,7 @@ describe('tableExportTab', () => {
tableExportInitialDatabaseNames: ['app', 'audit'],
tableExportContentMode: 'schema',
tableExportIncludeDropIfExists: true,
tableExportLaunchKey: 'batch-databases-1',
tableExportRequestKey: 'batch-databases-1',
}));
});
@@ -180,6 +214,7 @@ describe('tableExportTab', () => {
exportWorkbenchMode: 'database',
dbName: 'app',
tableExportContentMode: 'backup',
tableExportLaunchKey: 'database-1',
tableExportRequestKey: 'database-1',
}));
expect(schemaTab).toEqual(expect.objectContaining({
@@ -189,6 +224,7 @@ describe('tableExportTab', () => {
dbName: 'app',
schemaName: 'sales',
tableExportContentMode: 'schema',
tableExportLaunchKey: 'schema-1',
tableExportRequestKey: 'schema-1',
}));
});

View File

@@ -47,6 +47,7 @@ export const buildExportWorkbenchHistoryKey = (
type ExportWorkbenchLaunchOptions = {
contentMode?: TableExportContentMode;
includeDropIfExists?: boolean;
launchKey?: string;
requestKey?: string;
};
@@ -103,11 +104,18 @@ const normalizeNameList = (values: string[] | undefined): string[] | undefined =
return result.length > 0 ? result : undefined;
};
const buildLaunchMetadata = (input: ExportWorkbenchLaunchOptions): Partial<TabData> => ({
...(input.contentMode ? { tableExportContentMode: input.contentMode } : {}),
...(input.includeDropIfExists === true ? { tableExportIncludeDropIfExists: true } : {}),
...(String(input.requestKey || '').trim() ? { tableExportRequestKey: String(input.requestKey).trim() } : {}),
});
const buildLaunchMetadata = (input: ExportWorkbenchLaunchOptions): Partial<TabData> => {
const requestKey = String(input.requestKey || '').trim();
const launchKey = String(input.launchKey || '').trim() || requestKey;
return {
...(input.contentMode ? { tableExportContentMode: input.contentMode } : {}),
...(input.includeDropIfExists !== undefined
? { tableExportIncludeDropIfExists: input.includeDropIfExists === true }
: {}),
tableExportLaunchKey: launchKey || undefined,
tableExportRequestKey: requestKey || undefined,
};
};
const normalizeScopeOptions = (
scopeOptions: TableExportScopeOption[] | undefined,

View File

@@ -218,6 +218,8 @@ export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:str
export function ImportDataWithProgressOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ImportFileOptions):Promise<connection.QueryResult>;
export function ImportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;

View File

@@ -422,6 +422,10 @@ export function ImportDataWithProgressOptions(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ImportDataWithProgressOptions'](arg1, arg2, arg3, arg4, arg5);
}
export function ImportDatabaseSQL(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ImportDatabaseSQL'](arg1, arg2, arg3, arg4);
}
export function ImportLegacyConnections(arg1) {
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
}

View File

@@ -500,6 +500,7 @@ export namespace app {
totalRowsHint?: number;
totalRowsKnown?: boolean;
includeDropIfExists?: boolean;
includeDatabaseContext?: boolean;
insertSQLDialect?: string;
insertSQLTargetTable?: string;
insertSQLColumnTypes?: Record<string, string>;
@@ -519,6 +520,7 @@ export namespace app {
this.totalRowsHint = source["totalRowsHint"];
this.totalRowsKnown = source["totalRowsKnown"];
this.includeDropIfExists = source["includeDropIfExists"];
this.includeDatabaseContext = source["includeDatabaseContext"];
this.insertSQLDialect = source["insertSQLDialect"];
this.insertSQLTargetTable = source["insertSQLTargetTable"];
this.insertSQLColumnTypes = source["insertSQLColumnTypes"];