mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
🐛 fix(explorer): 补齐 Redis 部分导入与库节点加载态
- 新增 Redis 导入预览与按 key 勾选导入能力 - 后端支持导入文件预读、选中 key 过滤与本地化错误提示 - 打开数据库节点时同步展示加载态并补齐前后端回归测试 Fixes #607 Fixes #609
This commit is contained in:
@@ -17,6 +17,9 @@ describe('RedisViewer i18n', () => {
|
||||
'模糊',
|
||||
'精确',
|
||||
'新建 Key',
|
||||
'导出全部',
|
||||
'导出已选',
|
||||
'导入',
|
||||
'重命名 Key',
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
@@ -26,8 +29,19 @@ describe('RedisViewer i18n', () => {
|
||||
expect(source).toContain("tr('redis_viewer.message.load_keys_failed'");
|
||||
expect(source).toContain("tr('redis_viewer.message.value_load_failed'");
|
||||
expect(source).toContain("tr('redis_viewer.message.rename_success'");
|
||||
expect(source).toContain("tr('redis_viewer.message.export_success'");
|
||||
expect(source).toContain("tr('redis_viewer.message.import_summary'");
|
||||
expect(source).toContain("tr('redis_viewer.message.import_file_required'");
|
||||
expect(source).toContain("tr('redis_viewer.message.import_selection_required'");
|
||||
expect(source).toContain("tr('redis_viewer.state.empty_selection'");
|
||||
expect(source).toContain("tr('redis_viewer.state.import_preview_empty'");
|
||||
expect(source).toContain("tr('redis_viewer.action.copy_key_name'");
|
||||
expect(source).toContain("tr('redis_viewer.action.export_all'");
|
||||
expect(source).toContain("tr('redis_viewer.action.export_selected'");
|
||||
expect(source).toContain("tr('redis_viewer.action.import'");
|
||||
expect(source).toContain("tr('redis_viewer.action.select_import_file'");
|
||||
expect(source).toContain("tr('redis_viewer.action.change_import_file'");
|
||||
expect(source).toContain("tr('redis_viewer.action.select_all_import_keys'");
|
||||
});
|
||||
|
||||
it('localizes TTL and table labels with catalog keys', () => {
|
||||
|
||||
@@ -30,6 +30,9 @@ const storeState = vi.hoisted(() => ({
|
||||
const redisBackend = vi.hoisted(() => ({
|
||||
RedisScanKeys: vi.fn(),
|
||||
RedisGetValue: vi.fn(),
|
||||
RedisExportKeys: vi.fn(),
|
||||
RedisPreviewImportKeys: vi.fn(),
|
||||
RedisImportKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
const antdState = vi.hoisted(() => ({
|
||||
@@ -109,7 +112,17 @@ vi.mock('antd', async () => {
|
||||
},
|
||||
Spin: ({ children }: any) => React.createElement(React.Fragment, null, children),
|
||||
message: antdState.message,
|
||||
Modal: Object.assign(passthrough('div'), { confirm: vi.fn() }),
|
||||
Modal: Object.assign(({ children, open, onOk, onCancel, okButtonProps, title, ...props }: any) => {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return React.createElement('div', props, [
|
||||
React.createElement('div', { key: 'title', 'data-modal-title': true }, title),
|
||||
children,
|
||||
onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null,
|
||||
onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null,
|
||||
]);
|
||||
}, { confirm: vi.fn() }),
|
||||
Form: FormComponent,
|
||||
InputNumber: ({ ...props }: any) => React.createElement('input', props),
|
||||
Popconfirm: passthrough('span'),
|
||||
@@ -153,6 +166,24 @@ const countLeafNodes = (nodes: any[]): number => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const findFirstLeafNode = (nodes: any[]): any | null => {
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
continue;
|
||||
}
|
||||
if (node.nodeType === 'leaf') {
|
||||
return node;
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
const nested = findFirstLeafNode(node.children);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('RedisViewer tree interactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -184,6 +215,26 @@ describe('RedisViewer tree interactions', () => {
|
||||
success: true,
|
||||
data: { key: 'app:user:1', type: 'string', ttl: -1, value: 'demo' },
|
||||
});
|
||||
redisBackend.RedisExportKeys.mockResolvedValue({
|
||||
success: true,
|
||||
data: { exported: 2 },
|
||||
});
|
||||
redisBackend.RedisPreviewImportKeys.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
file: 'C:\\tmp\\redis-keys.json',
|
||||
database: 0,
|
||||
total: 2,
|
||||
keys: [
|
||||
{ key: 'app:user:1', type: 'string', ttl: -1 },
|
||||
{ key: 'app:user:2', type: 'string', ttl: 120 },
|
||||
],
|
||||
},
|
||||
});
|
||||
redisBackend.RedisImportKeys.mockResolvedValue({
|
||||
success: true,
|
||||
data: { imported: 1, skipped: 0, total: 1 },
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
@@ -317,4 +368,117 @@ describe('RedisViewer tree interactions', () => {
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('exports the current filtered key set when the export-all action is clicked', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const exportAllButton = findButtonByText(renderer!, 'Export all');
|
||||
expect(exportAllButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
exportAllButton!.props.onClick?.();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
{ scope: 'all', keys: [], pattern: '*' },
|
||||
);
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('exports checked leaf keys when the export-selected action is clicked', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const leafNode = findFirstLeafNode(antdState.treeProps.treeData);
|
||||
expect(leafNode?.rawKey).toBe('app:user:1');
|
||||
|
||||
await act(async () => {
|
||||
antdState.treeProps.onCheck?.(
|
||||
{ checked: [leafNode.key], halfChecked: [] },
|
||||
{ checked: true, node: leafNode },
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const exportSelectedButton = findButtonByText(renderer!, 'Export selected');
|
||||
expect(exportSelectedButton).toBeTruthy();
|
||||
expect(exportSelectedButton?.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
exportSelectedButton!.props.onClick?.();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
{ scope: 'selected', keys: ['app:user:1'], pattern: '*' },
|
||||
);
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('imports only the checked preview keys from the selected file', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const importButton = findButtonByText(renderer!, 'Import');
|
||||
expect(importButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick?.();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const chooseFileButton = findButtonByText(renderer!, 'Select import file');
|
||||
expect(chooseFileButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
chooseFileButton!.props.onClick?.();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(redisBackend.RedisPreviewImportKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
);
|
||||
|
||||
const secondCheckbox = renderer!.root.findByProps({ 'data-import-key': 'app:user:2' });
|
||||
await act(async () => {
|
||||
secondCheckbox.props.onChange?.({ target: { checked: false } });
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const modalOkButton = findButtonByText(renderer!, 'modal-ok');
|
||||
expect(modalOkButton).toBeTruthy();
|
||||
expect(modalOkButton?.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
modalOkButton!.props.onClick?.();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(redisBackend.RedisImportKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
|
||||
{
|
||||
conflictMode: 'overwrite',
|
||||
file: 'C:\\tmp\\redis-keys.json',
|
||||
scope: 'selected',
|
||||
keys: ['app:user:1'],
|
||||
},
|
||||
);
|
||||
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
|
||||
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
|
||||
import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
|
||||
import { t, type I18nParams } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
|
||||
@@ -53,6 +54,19 @@ interface RedisViewerProps {
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
type RedisExportScope = 'all' | 'selected';
|
||||
type RedisImportConflictMode = 'overwrite' | 'skip';
|
||||
type RedisImportPreview = {
|
||||
file: string;
|
||||
exportedAt?: string;
|
||||
database: number;
|
||||
scope?: string;
|
||||
pattern?: string;
|
||||
sourceAppName?: string;
|
||||
total: number;
|
||||
keys: RedisKeyInfo[];
|
||||
};
|
||||
|
||||
// Draggable divider uses direct DOM updates to avoid resize lag.
|
||||
const ResizableDivider: React.FC<{
|
||||
onResizeEnd: (newWidth: number) => void;
|
||||
@@ -149,6 +163,14 @@ const isRedisKeyGoneErrorMessage = (messageText: string): boolean => {
|
||||
};
|
||||
|
||||
const normalizeToolbarText = (value: unknown): string => String(value || '').trim();
|
||||
const extractFilenameFromPath = (value: unknown): string => {
|
||||
const normalized = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const segments = normalized.split('/');
|
||||
return segments[segments.length - 1] || normalized;
|
||||
};
|
||||
|
||||
const mergeRedisKeyInfoLists = (existing: RedisKeyInfo[], incoming: RedisKeyInfo[]): RedisKeyInfo[] => {
|
||||
const keyMap = new Map<string, RedisKeyInfo>();
|
||||
@@ -214,6 +236,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const redisTopology = useMemo(() => resolveRedisTopology(connection), [connection]);
|
||||
const redisSeedAddresses = useMemo(() => buildRedisSeedAddresses(connection), [connection]);
|
||||
const redisSentinelMaster = normalizeToolbarText(connection?.config?.redisSentinelMaster);
|
||||
const importRestricted = isConnectionDataImportRestricted(connection?.config);
|
||||
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -223,6 +246,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const [cursor, setCursor] = useState<string>('0');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingAllKeys, setLoadingAllKeys] = useState(false);
|
||||
const [exportingScope, setExportingScope] = useState<RedisExportScope | null>(null);
|
||||
const [importingKeys, setImportingKeys] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
|
||||
const [valueLoading, setValueLoading] = useState(false);
|
||||
@@ -234,6 +259,11 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const [renameTargetKey, setRenameTargetKey] = useState<string | null>(null);
|
||||
const [ttlModalOpen, setTtlModalOpen] = useState(false);
|
||||
const [ttlForm] = Form.useForm();
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importPreviewLoading, setImportPreviewLoading] = useState(false);
|
||||
const [importConflictMode, setImportConflictMode] = useState<RedisImportConflictMode>('overwrite');
|
||||
const [importPreview, setImportPreview] = useState<RedisImportPreview | null>(null);
|
||||
const [importSelectedKeys, setImportSelectedKeys] = useState<string[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [treeContextMenu, setTreeContextMenu] = useState<{ x: number; y: number; rawKey: string } | null>(null);
|
||||
@@ -502,6 +532,161 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
setSelectedKeys([]);
|
||||
}, []);
|
||||
|
||||
const handleExportKeys = useCallback(async (scope: RedisExportScope) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
if (scope === 'selected' && selectedKeys.length === 0) {
|
||||
message.warning(tr('redis_viewer.message.export_selection_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setExportingScope(scope);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisExportKeys(
|
||||
buildRpcConnectionConfig(config),
|
||||
{
|
||||
scope,
|
||||
keys: scope === 'selected' ? selectedKeys : [],
|
||||
pattern: searchPattern,
|
||||
},
|
||||
);
|
||||
if (res?.success) {
|
||||
const exportedCount = Number(res?.data?.exported ?? (scope === 'selected' ? selectedKeys.length : 0));
|
||||
message.success(tr('redis_viewer.message.export_success', { count: exportedCount }));
|
||||
return;
|
||||
}
|
||||
if (String(res?.message || '').trim() === '已取消') {
|
||||
return;
|
||||
}
|
||||
message.error(tr('redis_viewer.message.export_failed', { detail: res?.message || 'Unknown error' }));
|
||||
} catch (e: any) {
|
||||
message.error(tr('redis_viewer.message.export_failed', { detail: e?.message || String(e) }));
|
||||
} finally {
|
||||
setExportingScope(null);
|
||||
}
|
||||
}, [getConfig, searchPattern, selectedKeys, tr]);
|
||||
|
||||
const resetImportModalState = useCallback(() => {
|
||||
setImportModalOpen(false);
|
||||
setImportPreview(null);
|
||||
setImportSelectedKeys([]);
|
||||
setImportPreviewLoading(false);
|
||||
setImportConflictMode('overwrite');
|
||||
}, []);
|
||||
|
||||
const handleChooseImportFile = useCallback(async () => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
setImportPreviewLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisPreviewImportKeys(
|
||||
buildRpcConnectionConfig(config),
|
||||
);
|
||||
if (res?.success) {
|
||||
const previewData = (res.data || {}) as RedisImportPreview;
|
||||
const previewKeys = Array.isArray(previewData.keys) ? previewData.keys : [];
|
||||
const nextPreview: RedisImportPreview = {
|
||||
file: String(previewData.file || '').trim(),
|
||||
exportedAt: previewData.exportedAt,
|
||||
database: Number(previewData.database ?? redisDB),
|
||||
scope: String(previewData.scope || '').trim(),
|
||||
pattern: String(previewData.pattern || '').trim(),
|
||||
sourceAppName: String(previewData.sourceAppName || '').trim(),
|
||||
total: Number(previewData.total ?? previewKeys.length),
|
||||
keys: previewKeys,
|
||||
};
|
||||
setImportPreview(nextPreview);
|
||||
setImportSelectedKeys(previewKeys.map((item) => item.key));
|
||||
return;
|
||||
}
|
||||
if (String(res?.message || '').trim() === '已取消') {
|
||||
return;
|
||||
}
|
||||
message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' }));
|
||||
} catch (e: any) {
|
||||
message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) }));
|
||||
} finally {
|
||||
setImportPreviewLoading(false);
|
||||
}
|
||||
}, [getConfig, redisDB, tr]);
|
||||
|
||||
const handleOpenImportModal = useCallback(() => {
|
||||
setImportModalOpen(true);
|
||||
setImportPreview(null);
|
||||
setImportSelectedKeys([]);
|
||||
setImportPreviewLoading(false);
|
||||
setImportConflictMode('overwrite');
|
||||
}, []);
|
||||
|
||||
const handleConfirmImportKeys = useCallback(async () => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
if (!importPreview) {
|
||||
message.warning(tr('redis_viewer.message.import_file_required'));
|
||||
return;
|
||||
}
|
||||
if (importSelectedKeys.length === 0) {
|
||||
message.warning(tr('redis_viewer.message.import_selection_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportingKeys(true);
|
||||
try {
|
||||
const scope = importSelectedKeys.length === importPreview.keys.length ? 'all' : 'selected';
|
||||
const res = await (window as any).go.app.App.RedisImportKeys(
|
||||
buildRpcConnectionConfig(config),
|
||||
{
|
||||
conflictMode: importConflictMode,
|
||||
file: importPreview.file,
|
||||
scope,
|
||||
keys: scope === 'selected' ? importSelectedKeys : [],
|
||||
},
|
||||
);
|
||||
if (res?.success) {
|
||||
const imported = Number(res?.data?.imported ?? 0);
|
||||
const skipped = Number(res?.data?.skipped ?? 0);
|
||||
message.success(tr('redis_viewer.message.import_summary', {
|
||||
imported,
|
||||
skipped,
|
||||
}));
|
||||
resetImportModalState();
|
||||
setSelectedKeys([]);
|
||||
setSelectedKey(null);
|
||||
setKeyValue(null);
|
||||
setCursor('0');
|
||||
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
|
||||
return;
|
||||
}
|
||||
if (String(res?.message || '').trim() === '已取消') {
|
||||
return;
|
||||
}
|
||||
message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' }));
|
||||
} catch (e: any) {
|
||||
message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) }));
|
||||
} finally {
|
||||
setImportingKeys(false);
|
||||
}
|
||||
}, [getConfig, importConflictMode, importPreview, importSelectedKeys, loadKeys, resetImportModalState, searchPattern, tr]);
|
||||
|
||||
const importSelectedKeySet = useMemo(() => new Set(importSelectedKeys), [importSelectedKeys]);
|
||||
const handleToggleImportPreviewKey = useCallback((key: string, checked: boolean) => {
|
||||
setImportSelectedKeys((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(key) ? prev : [...prev, key];
|
||||
}
|
||||
return prev.filter((item) => item !== key);
|
||||
});
|
||||
}, []);
|
||||
const handleSelectAllImportPreviewKeys = useCallback(() => {
|
||||
if (!importPreview) return;
|
||||
setImportSelectedKeys(importPreview.keys.map((item) => item.key));
|
||||
}, [importPreview]);
|
||||
const handleClearImportPreviewSelection = useCallback(() => {
|
||||
setImportSelectedKeys([]);
|
||||
}, []);
|
||||
|
||||
const removeMissingKeyFromView = useCallback((missingKey: string) => {
|
||||
setKeys(prev => prev.filter(item => item.key !== missingKey));
|
||||
setSelectedKeys(prev => prev.filter(item => item !== missingKey));
|
||||
@@ -1995,6 +2180,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}>{tr('redis_viewer.action.select_all_loaded')}</Button>
|
||||
<Button size="small" style={actionButtonStyle} onClick={handleLoadAllKeys} disabled={!hasMore || loading} loading={loadingAllKeys}>{tr('redis_viewer.action.load_all')}</Button>
|
||||
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}>{tr('redis_viewer.action.clear_selection')}</Button>
|
||||
<Button
|
||||
size="small"
|
||||
style={actionButtonStyle}
|
||||
onClick={() => void handleExportKeys('all')}
|
||||
loading={exportingScope === 'all'}
|
||||
>
|
||||
{tr('redis_viewer.action.export_all')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
style={actionButtonStyle}
|
||||
onClick={() => void handleExportKeys('selected')}
|
||||
disabled={selectedKeys.length === 0}
|
||||
loading={exportingScope === 'selected'}
|
||||
>
|
||||
{tr('redis_viewer.action.export_selected')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
style={primaryActionButtonStyle}
|
||||
onClick={handleOpenImportModal}
|
||||
disabled={importRestricted}
|
||||
loading={importingKeys}
|
||||
>
|
||||
{tr('redis_viewer.action.import')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Popconfirm
|
||||
title={tr('redis_viewer.confirm.delete_selected', { count: selectedKeys.length })}
|
||||
@@ -2109,6 +2320,114 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={tr('redis_viewer.modal.import_keys')}
|
||||
open={importModalOpen}
|
||||
okButtonProps={{ disabled: !importPreview || importPreview.keys.length === 0 || importSelectedKeys.length === 0 || importPreviewLoading }}
|
||||
confirmLoading={importingKeys}
|
||||
onOk={() => void handleConfirmImportKeys()}
|
||||
onCancel={() => {
|
||||
if (importingKeys || importPreviewLoading) return;
|
||||
resetImportModalState();
|
||||
}}
|
||||
width={760}
|
||||
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={tr('redis_viewer.field.import_file')}>
|
||||
<Space wrap size={8}>
|
||||
<Button
|
||||
style={actionButtonStyle}
|
||||
onClick={() => void handleChooseImportFile()}
|
||||
loading={importPreviewLoading}
|
||||
>
|
||||
{importPreview ? tr('redis_viewer.action.change_import_file') : tr('redis_viewer.action.select_import_file')}
|
||||
</Button>
|
||||
{importPreview && (
|
||||
<>
|
||||
<Tag style={mutedPillTagStyle}>{extractFilenameFromPath(importPreview.file)}</Tag>
|
||||
<Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.import_selection', { selected: importSelectedKeys.length, total: importPreview.total })}</Tag>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
{importPreview ? (
|
||||
<Form.Item label={tr('redis_viewer.field.import_keys')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<Space wrap size={8}>
|
||||
<Button
|
||||
size="small"
|
||||
style={primaryActionButtonStyle}
|
||||
onClick={handleSelectAllImportPreviewKeys}
|
||||
disabled={importPreview.keys.length === 0}
|
||||
>
|
||||
{tr('redis_viewer.action.select_all_import_keys')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
style={actionButtonStyle}
|
||||
onClick={handleClearImportPreviewSelection}
|
||||
disabled={importSelectedKeys.length === 0}
|
||||
>
|
||||
{tr('redis_viewer.action.clear_selection')}
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ color: workbenchTheme.textMuted, fontSize: 12 }}>
|
||||
{tr('redis_viewer.label.import_database', { database: importPreview.database })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', border: workbenchTheme.panelBorder, borderRadius: 12, padding: 8, background: workbenchTheme.panelBg }}>
|
||||
{importPreview.keys.map((item) => {
|
||||
const checked = importSelectedKeySet.has(item.key);
|
||||
return (
|
||||
<label
|
||||
key={item.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 6px',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
data-import-key={item.key}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => handleToggleImportPreviewKey(item.key, event.target.checked)}
|
||||
/>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: workbenchTheme.textPrimary }}>
|
||||
{item.key}
|
||||
</span>
|
||||
<Tag color={getTypeColor(item.type)} style={{ margin: 0 }}>
|
||||
{item.type}
|
||||
</Tag>
|
||||
<span style={{ color: workbenchTheme.textMuted, fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
{formatTTL(item.ttl)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16, color: workbenchTheme.textMuted }}>
|
||||
{tr('redis_viewer.state.import_preview_empty')}
|
||||
</div>
|
||||
)}
|
||||
<Form.Item label={tr('redis_viewer.field.import_conflict_mode')}>
|
||||
<Radio.Group
|
||||
value={importConflictMode}
|
||||
onChange={(event) => setImportConflictMode(event.target.value as RedisImportConflictMode)}
|
||||
>
|
||||
<Radio.Button value="overwrite">{tr('redis_viewer.option.import_overwrite')}</Radio.Button>
|
||||
<Radio.Button value="skip">{tr('redis_viewer.option.import_skip_existing')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* TTL Modal */}
|
||||
<Modal
|
||||
title={tr('redis_viewer.modal.rename_key')}
|
||||
|
||||
@@ -1122,7 +1122,7 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(css).toMatch(/\.gn-v2-explorer-filter-tabs button \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/s);
|
||||
});
|
||||
|
||||
it('shows a pending state while a connection root is loading', () => {
|
||||
it('shows a pending state while a database node is loading', () => {
|
||||
const css = readV2ThemeCss();
|
||||
const source = readSidebarSource();
|
||||
const treeLoaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx');
|
||||
@@ -1131,6 +1131,7 @@ describe('Sidebar locate toolbar', () => {
|
||||
|
||||
expect(source).toContain("export type SidebarConnectionState = 'loading' | 'success' | 'error';");
|
||||
expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));");
|
||||
expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));");
|
||||
expect(titleRenderSource).toContain("let status: 'loading' | 'success' | 'error' | 'default' = 'default';");
|
||||
expect(titleRenderSource).toContain("if (connectionStates[node.key] === 'loading') status = 'loading';");
|
||||
expect(v2ContextMenuSource).toContain("const statusMap = new Map<string, 'loading' | 'live' | 'error' | 'idle'>();");
|
||||
|
||||
@@ -449,6 +449,7 @@ export const useSidebarTreeLoaders = ({
|
||||
const loadKey = `tables-${conn.id}-${dbName}`;
|
||||
if (loadingNodesRef.current.has(loadKey)) return;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));
|
||||
|
||||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||||
const queriesNode: TreeNode = {
|
||||
|
||||
Reference in New Issue
Block a user