feat(redis): 支持按命名空间快速模糊筛选

- 为 Redis Key 目录增加完整路径快捷筛选入口
- 自动回填搜索框并切换到模糊匹配模式
- 补齐多语言文案、无障碍标签和交互回归测试

Fixes #692
This commit is contained in:
Syngnat
2026-07-27 21:21:21 +08:00
parent f14a2801db
commit 32eed103a4
10 changed files with 119 additions and 21 deletions

View File

@@ -296,6 +296,58 @@ describe('RedisViewer tree interactions', () => {
renderer!.unmount();
});
it('filters keys by the full namespace path from a group action', async () => {
let renderer: ReactTestRenderer;
let groupTitleRenderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const searchModeGroup = renderer!.root.findAll(
node => node.props.buttonStyle === 'solid' && typeof node.props.onChange === 'function',
)[0];
await act(async () => {
searchModeGroup.props.onChange({ target: { value: 'exact' } });
});
await flushEffects();
const appGroup = antdState.treeProps.treeData.find((node: any) => node.key === 'group:app');
const userGroup = appGroup.children.find((node: any) => node.key === 'group:app:user');
await act(async () => {
groupTitleRenderer = create(antdState.treeProps.titleRender(userGroup));
});
const filterButton = groupTitleRenderer!.root.findByProps({ 'aria-label': 'Filter by namespace' });
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
await act(async () => {
filterButton.props.onClick(event);
});
await flushEffects();
const searchInput = renderer!.root.findAllByType('input').find(node => typeof node.props.onSearch === 'function');
const updatedSearchModeGroup = renderer!.root.findAll(
node => node.props.buttonStyle === 'solid' && typeof node.props.onChange === 'function',
)[0];
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(searchInput?.props.value).toBe('app:user');
expect(updatedSearchModeGroup.props.value).toBe('fuzzy');
expect(redisBackend.RedisScanKeys).toHaveBeenLastCalledWith(
expect.any(Object),
'*[aA][pP][pP]:[uU][sS][eE][rR]*',
'0',
600,
);
groupTitleRenderer!.unmount();
renderer!.unmount();
});
it('shows Redis Cluster topology context in the key explorer header', async () => {
storeState.connections = [
{

View File

@@ -1092,6 +1092,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
});
}, [isLargeKeyspace]);
const handleFilterByGroup = useCallback((treeNode: RedisTreeDataNode) => {
const groupPath = treeNode.groupPath?.trim();
if (!groupPath) {
return;
}
setSearchMode('fuzzy');
executeSearch(groupPath, 'fuzzy');
}, [executeSearch]);
const stopTreeTitleEvent = (event: React.SyntheticEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -1164,26 +1174,51 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</span>
<span style={{ fontSize: 12, color: workbenchTheme.textMuted, flexShrink: 0 }}>({treeNode.groupLeafCount ?? 0})</span>
</Space>
<Button
size="small"
style={{
paddingInline: 10,
height: 26,
borderRadius: 999,
flexShrink: 0,
borderColor: workbenchTheme.accentBorder,
background: workbenchTheme.accentSoft,
color: workbenchTheme.accent,
fontWeight: 600,
}}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleSelectGroupDescendants(treeNode);
}}
>
{groupFullyChecked ? tr('redis_viewer.action.clear_group_selection') : tr('redis_viewer.action.select_group')}
</Button>
<Space size={6} style={{ flexShrink: 0 }}>
<Tooltip title={tr('redis_viewer.action.filter_group')}>
<Button
size="small"
className="redis-tree-group-filter-button"
aria-label={tr('redis_viewer.action.filter_group')}
icon={<SearchOutlined />}
style={{
width: 26,
height: 26,
padding: 0,
borderRadius: 999,
borderColor: workbenchTheme.actionSecondaryBorder,
background: workbenchTheme.actionSecondaryBg,
color: workbenchTheme.accent,
boxShadow: 'none',
}}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleFilterByGroup(treeNode);
}}
/>
</Tooltip>
<Button
size="small"
style={{
paddingInline: 10,
height: 26,
borderRadius: 999,
flexShrink: 0,
borderColor: workbenchTheme.accentBorder,
background: workbenchTheme.accentSoft,
color: workbenchTheme.accent,
fontWeight: 600,
}}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleSelectGroupDescendants(treeNode);
}}
>
{groupFullyChecked ? tr('redis_viewer.action.clear_group_selection') : tr('redis_viewer.action.select_group')}
</Button>
</Space>
</div>
);
}

View File

@@ -24,6 +24,8 @@ describe('redisViewerTree helpers', () => {
expect(appGroup).toBeTruthy();
expect(userGroup).toBeTruthy();
expect(appGroup?.groupPath).toBe('app');
expect(userGroup?.groupPath).toBe('app:user');
expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);

View File

@@ -17,9 +17,11 @@ type RedisKeyTreeGroup = {
leafCount: number;
};
export type RedisTreeDataNode = DataNode & {
export type RedisTreeDataNode = Omit<DataNode, 'children'> & {
nodeType: 'group' | 'leaf';
children?: RedisTreeDataNode[];
groupName?: string;
groupPath?: string;
groupLeafCount?: number;
leafLabel?: string;
rawKey?: string;
@@ -134,6 +136,7 @@ export const buildRedisKeyTree = (
title: child.name,
nodeType: 'group',
groupName: child.name,
groupPath: child.path,
groupLeafCount: child.leafCount,
descendantRawKeys,
children,

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "Bearbeiten",
"redis_viewer.action.export_all": "Alle exportieren",
"redis_viewer.action.export_selected": "Auswahl exportieren",
"redis_viewer.action.filter_group": "Nach diesem Namensraum filtern",
"redis_viewer.action.import": "Importieren",
"redis_viewer.action.load_all": "Alle laden",
"redis_viewer.action.load_more": "Mehr laden",

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "Edit",
"redis_viewer.action.export_all": "Export all",
"redis_viewer.action.export_selected": "Export selected",
"redis_viewer.action.filter_group": "Filter by namespace",
"redis_viewer.action.import": "Import",
"redis_viewer.action.load_all": "Load all",
"redis_viewer.action.load_more": "Load more",

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "編集",
"redis_viewer.action.export_all": "すべてエクスポート",
"redis_viewer.action.export_selected": "選択した Key をエクスポート",
"redis_viewer.action.filter_group": "この名前空間で絞り込む",
"redis_viewer.action.import": "インポート",
"redis_viewer.action.load_all": "すべて読み込む",
"redis_viewer.action.load_more": "さらに読み込む",

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "Редактировать",
"redis_viewer.action.export_all": "Экспортировать все",
"redis_viewer.action.export_selected": "Экспортировать выбранное",
"redis_viewer.action.filter_group": "Фильтровать по этому пространству имён",
"redis_viewer.action.import": "Импортировать",
"redis_viewer.action.load_all": "Загрузить все",
"redis_viewer.action.load_more": "Загрузить еще",

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "编辑",
"redis_viewer.action.export_all": "导出全部",
"redis_viewer.action.export_selected": "导出已选",
"redis_viewer.action.filter_group": "按此目录筛选",
"redis_viewer.action.import": "导入",
"redis_viewer.action.load_all": "加载全部",
"redis_viewer.action.load_more": "加载更多",

View File

@@ -6868,6 +6868,7 @@
"redis_viewer.action.edit": "編輯",
"redis_viewer.action.export_all": "匯出全部",
"redis_viewer.action.export_selected": "匯出已選",
"redis_viewer.action.filter_group": "依此目錄篩選",
"redis_viewer.action.import": "匯入",
"redis_viewer.action.load_all": "載入全部",
"redis_viewer.action.load_more": "載入更多",