🐛 fix(redis): 修复命名空间行点击误选中

- 命名空间行点击改为展开或折叠分组

- 阻止行点击冒泡触发 Tree 复选/选中逻辑

- 增加 Redis 树行点击交互回归测试

Refs #457
This commit is contained in:
Syngnat
2026-05-12 22:09:51 +08:00
parent 9848b8b295
commit 6d5d49ef50
2 changed files with 209 additions and 1 deletions

View File

@@ -0,0 +1,193 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import RedisViewer from './RedisViewer';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'redis-1',
name: 'redis',
config: {
type: 'redis',
host: '127.0.0.1',
port: 6379,
password: '',
database: '',
},
},
],
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
useNativeMacWindowControls: false,
},
}));
const redisBackend = vi.hoisted(() => ({
RedisScanKeys: vi.fn(),
RedisGetValue: vi.fn(),
}));
const antdState = vi.hoisted(() => ({
treeProps: null as any,
message: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('@monaco-editor/react', async () => {
const React = await import('react');
return {
default: () => React.createElement('div', { 'data-monaco-editor': true }),
};
});
vi.mock('@ant-design/icons', async () => {
const React = await import('react');
const Icon = () => React.createElement('span', { 'data-icon': true });
return {
ReloadOutlined: Icon,
DeleteOutlined: Icon,
PlusOutlined: Icon,
EditOutlined: Icon,
SearchOutlined: Icon,
ClockCircleOutlined: Icon,
CopyOutlined: Icon,
FolderOpenOutlined: Icon,
KeyOutlined: Icon,
RightOutlined: Icon,
DownOutlined: Icon,
};
});
vi.mock('antd', async () => {
const React = await import('react');
const passthrough = (tag: string) => ({ children, ...props }: any) => React.createElement(tag, props, children);
const Button = ({ children, ...props }: any) => React.createElement('button', props, children);
const Input = Object.assign(
({ ...props }: any) => React.createElement('input', props),
{
Search: ({ ...props }: any) => React.createElement('input', props),
TextArea: ({ ...props }: any) => React.createElement('textarea', props),
},
);
const FormComponent = Object.assign(
({ children, ...props }: any) => React.createElement('form', props, children),
{
Item: passthrough('div'),
useForm: () => [{
validateFields: vi.fn(),
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
}],
},
);
return {
Table: passthrough('div'),
Input,
Button,
Space: Object.assign(passthrough('div'), { Compact: passthrough('div') }),
Tag: passthrough('span'),
Tree: (props: any) => {
antdState.treeProps = props;
return React.createElement('redis-tree');
},
Spin: ({ children }: any) => React.createElement(React.Fragment, null, children),
message: antdState.message,
Modal: Object.assign(passthrough('div'), { confirm: vi.fn() }),
Form: FormComponent,
InputNumber: ({ ...props }: any) => React.createElement('input', props),
Popconfirm: passthrough('span'),
Tooltip: ({ children }: any) => React.createElement(React.Fragment, null, children),
Radio: {
Group: passthrough('div'),
Button,
},
};
});
const flushEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
};
describe('RedisViewer tree interactions', () => {
beforeEach(() => {
vi.clearAllMocks();
antdState.treeProps = null;
redisBackend.RedisScanKeys.mockResolvedValue({
success: true,
data: {
cursor: '0',
keys: [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: -1 },
],
},
});
redisBackend.RedisGetValue.mockResolvedValue({
success: true,
data: { key: 'app:user:1', type: 'string', ttl: -1, value: 'demo' },
});
vi.stubGlobal('window', {
innerWidth: 1280,
innerHeight: 800,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
go: {
app: {
App: redisBackend,
},
},
});
vi.stubGlobal('ResizeObserver', undefined);
});
it('toggles namespace expansion from row clicks without checking the group', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const appGroup = antdState.treeProps.treeData.find((node: any) => node.key === 'group:app');
expect(appGroup).toBeTruthy();
expect(antdState.treeProps.expandedKeys).not.toContain('group:app');
const groupTitle = antdState.treeProps.titleRender(appGroup);
expect(typeof groupTitle.props.onClick).toBe('function');
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
await act(async () => {
groupTitle.props.onClick(event);
});
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(antdState.treeProps.expandedKeys).toContain('group:app');
expect(antdState.treeProps.checkedKeys.checked).toEqual([]);
renderer!.unmount();
});
});

View File

@@ -762,7 +762,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
});
}, [isLargeKeyspace]);
const stopTreeTitleEvent = (event: React.MouseEvent<HTMLElement>) => {
const stopTreeTitleEvent = (event: React.SyntheticEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
};
@@ -776,6 +776,20 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const isExpanded = expandedGroupKeys.includes(groupNodeKey);
return (
<div
role="button"
tabIndex={0}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
style={{
display: 'flex',
alignItems: 'center',
@@ -784,6 +798,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
width: '100%',
minWidth: 0,
padding: '2px 0',
cursor: 'pointer',
}}
>
<Space size={6} style={{ minWidth: 0, overflow: 'hidden' }}>