🐛 fix(sidebar): 排除已归属连接的分组选项

- 创建分组时仅展示未归属其他分组的连接
- 编辑分组时保留当前分组已有成员
- 无可选连接时显示明确空状态并补充多语言文案
- 增加连接筛选回归测试
This commit is contained in:
Syngnat
2026-07-14 17:06:35 +08:00
parent 454b6e0236
commit 7886cdc613
8 changed files with 91 additions and 12 deletions

View File

@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
import { buildConnectionTagParentOptions } from './sidebar/SidebarEntityModals';
import {
buildConnectionTagParentOptions,
buildConnectionTagSelectableConnections,
} from './sidebar/SidebarEntityModals';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
@@ -22,6 +25,42 @@ describe('Sidebar nested group menu', () => {
expect(options).toEqual([{ value: 'other', label: 'Other' }]);
});
it('only offers unassigned connections when creating and retains the edited group members', () => {
const connections = [
{
id: 'host-current',
name: 'Current group host',
config: { type: 'mysql', host: 'current.local', port: 3306, user: 'root' },
},
{
id: 'host-other',
name: 'Other group host',
config: { type: 'mysql', host: 'other.local', port: 3306, user: 'root' },
},
{
id: 'host-unassigned',
name: 'Ungrouped host',
config: { type: 'mysql', host: 'unassigned.local', port: 3306, user: 'root' },
},
];
const connectionTags = [
{ id: 'current', name: 'Current', connectionIds: ['host-current'] },
{ id: 'other', name: 'Other', connectionIds: ['host-other'] },
];
expect(
buildConnectionTagSelectableConnections(connections, connectionTags, '')
.map((connection) => connection.id),
).toEqual(['host-unassigned']);
expect(
buildConnectionTagSelectableConnections(connections, connectionTags, 'current')
.map((connection) => connection.id),
).toEqual(['host-current', 'host-unassigned']);
expect(
buildConnectionTagSelectableConnections(connections.slice(0, 2), connectionTags, ''),
).toEqual([]);
});
it('preselects the clicked legacy group when creating a child group', () => {
const createTagForm = {
resetFields: vi.fn(),
@@ -69,6 +108,8 @@ describe('Sidebar nested group menu', () => {
it('keeps modal and both menu implementations aligned with nested grouping', () => {
expect(modalSource).toContain('name="parentTagId"');
expect(modalSource).toContain('parentTagId,');
expect(modalSource).toContain('Empty.PRESENTED_IMAGE_SIMPLE');
expect(modalSource).toContain("sidebar.modal.tag.no_available_connections");
expect(legacyMenuSource).toContain("key: 'new-child-tag'");
expect(legacyMenuSource).toContain("t('connection.sidebar.group.newSubgroup')");
expect(v2MenuSource).toContain("| 'new-subgroup'");
@@ -81,6 +122,7 @@ describe('Sidebar nested group menu', () => {
'connection.sidebar.group.newSubgroup',
'sidebar.field.parent_group',
'sidebar.placeholder.parent_group',
'sidebar.modal.tag.no_available_connections',
'connection.sidebar.group.deleteConfirmContent',
'sidebar.modal.confirm_delete_tag.content',
].forEach((key) => {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Checkbox, Form, Input, Select, Space } from 'antd';
import { Checkbox, Empty, Form, Input, Select, Space } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { FolderOpenOutlined } from '@ant-design/icons';
import Modal from '../common/ResizableDraggableModal';
@@ -49,6 +49,26 @@ export const buildConnectionTagParentOptions = (
});
};
export const buildConnectionTagSelectableConnections = (
connections: SavedConnection[],
connectionTags: ConnectionTag[],
editingTagId: string,
): SavedConnection[] => {
const currentTagId = editingTagId.trim();
const connectionIdsAssignedToOtherTags = new Set<string>();
connectionTags.forEach((tag) => {
if (tag.id === currentTagId) return;
tag.connectionIds.forEach((connectionId) => {
connectionIdsAssignedToOtherTags.add(connectionId);
});
});
return connections.filter(
(connection) => !connectionIdsAssignedToOtherTags.has(connection.id),
);
};
const resolveConnectionTagParentId = (
value: unknown,
connectionTags: ConnectionTag[],
@@ -168,8 +188,17 @@ export const SidebarEntityModals: React.FC<SidebarEntityModalsProps> = ({
renameSavedQueryTarget,
setRenameSavedQueryTarget,
handleRenameSavedQuery,
}) => (
<>
}) => {
const editingTagId = renameViewTarget?.type === 'tag'
? String(renameViewTarget?.dataRef?.id || '')
: '';
const selectableConnections = buildConnectionTagSelectableConnections(
connections,
connectionTags,
editingTagId,
);
return (<>
<Modal
title={renderSidebarModalTitle(
<FolderOpenOutlined />,
@@ -181,9 +210,6 @@ export const SidebarEntityModals: React.FC<SidebarEntityModalsProps> = ({
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
onOk={() => {
createTagForm.validateFields().then(values => {
const editingTagId = renameViewTarget?.type === 'tag'
? String(renameViewTarget?.dataRef?.id || '')
: '';
const parentTagId = resolveConnectionTagParentId(
values.parentTagId,
connectionTags,
@@ -221,7 +247,7 @@ export const SidebarEntityModals: React.FC<SidebarEntityModalsProps> = ({
placeholder={t('sidebar.placeholder.parent_group')}
options={buildConnectionTagParentOptions(
connectionTags,
renameViewTarget?.type === 'tag' ? String(renameViewTarget?.dataRef?.id || '') : '',
editingTagId,
)}
/>
</Form.Item>
@@ -229,11 +255,16 @@ export const SidebarEntityModals: React.FC<SidebarEntityModalsProps> = ({
<Checkbox.Group style={{ width: '100%' }}>
<div style={modalScrollSectionStyle}>
<Space direction="vertical" style={{ width: '100%' }}>
{connections.map(conn => (
{selectableConnections.length > 0 ? selectableConnections.map(conn => (
<Checkbox key={conn.id} value={conn.id}>
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
</Checkbox>
))}
)) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('sidebar.modal.tag.no_available_connections')}
/>
)}
</Space>
</div>
</Checkbox.Group>
@@ -380,5 +411,5 @@ export const SidebarEntityModals: React.FC<SidebarEntityModalsProps> = ({
</Form.Item>
</Form>
</Modal>
</>
);
</>);
};