mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(data-transfer): 完善数据库 SQL 备份与恢复
- 数据导入工作台新增数据库模式,支持 SQL 文件选择、目标库、进度、取消与重试 - 新增受连接保护约束的 ImportDatabaseSQL 接口并复用流式执行链路 - SQL 导出支持切换数据库上下文,兼顾原库备份和跨库导入 - 调整侧栏与表概览入口,备份先进入工作台确认配置再执行 - 同步 Wails 绑定、Web 运行时限制、六语文案和回归测试
This commit is contained in:
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
219
frontend/src/components/DatabaseImportExecutionPanel.test.tsx
Normal file
219
frontend/src/components/DatabaseImportExecutionPanel.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
283
frontend/src/components/DatabaseImportExecutionPanel.tsx
Normal file
283
frontend/src/components/DatabaseImportExecutionPanel.tsx
Normal 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;
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(');
|
||||
});
|
||||
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({');
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user