From c1e07dddaabfc416e8ee8fad7748fe27e02dd247 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 14 Jul 2026 13:43:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E5=B1=82=20Host=20=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持分组父子层级与 Host/子分组混排排序 - 兼容旧分组配置并清理循环引用和重复归属 - 增加子分组创建、父分组选项及层级拖拽 - 删除分组时原位提升直属 Host 和子分组 --- .../Sidebar.locate-toolbar.test.tsx | 126 +- frontend/src/components/Sidebar.tsx | 243 ++-- .../components/SidebarNestedGroupMenu.test.ts | 93 ++ .../src/components/V2TableContextMenu.tsx | 2 + .../sidebar/SidebarEntityModals.tsx | 92 +- .../sidebar/sidebarLegacyNodeMenu.tsx | 20 +- .../sidebar/useSidebarV2ActionHandlers.tsx | 20 +- frontend/src/components/sidebarV2Utils.ts | 303 +++-- frontend/src/store.test.ts | 320 ++++- frontend/src/store.ts | 1043 +++++++++++++---- frontend/src/types.ts | 10 + shared/i18n/de-DE.json | 7 +- shared/i18n/en-US.json | 7 +- shared/i18n/ja-JP.json | 7 +- shared/i18n/ru-RU.json | 7 +- shared/i18n/zh-CN.json | 7 +- shared/i18n/zh-TW.json | 7 +- 17 files changed, 1799 insertions(+), 515 deletions(-) create mode 100644 frontend/src/components/SidebarNestedGroupMenu.test.ts diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index dc82b158..34e74212 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -6,6 +6,7 @@ import { readV2ThemeCss } from '../test/readV2ThemeCss'; import Sidebar, { buildAllSavedQueriesTreeNode, + buildSidebarConnectionTagTree, buildSidebarTableChildrenForUi, buildV2SidebarTableSectionedChildren, buildSQLFileExecutionFooter, @@ -15,6 +16,7 @@ import Sidebar, { filterV2ExplorerTreeByKind, getV2RailConnectionGroupBadgeText, hasSidebarLazyChildren, + isConnectionTagDescendant, normalizeSidebarTreeRelativeDropPosition, parseV2CommandSearchQuery, resolveV2CommandSearchPersistentFilter, @@ -130,15 +132,35 @@ const mocks = vi.hoisted(() => ({ vi.mock('../store', () => ({ buildSidebarRootConnectionToken: (connectionId: string) => `connection:${connectionId.trim()}`, buildSidebarRootTagToken: (tagId: string) => `tag:${tagId.trim()}`, + resolveConnectionTagChildOrder: ( + tagId: string, + connectionTags: Array<{ id: string; parentTagId?: string; connectionIds: string[]; childOrder?: string[] }>, + ) => { + const tag = connectionTags.find((candidate) => candidate.id === tagId); + if (!tag) return []; + const fallback = [ + ...tag.connectionIds.map((connectionId) => `connection:${connectionId}`), + ...connectionTags + .filter((candidate) => candidate.parentTagId === tagId) + .map((candidate) => `tag:${candidate.id}`), + ]; + const valid = new Set(fallback); + const seen = new Set(); + return [...(tag.childOrder || []), ...fallback].filter((token) => { + if (!valid.has(token) || seen.has(token)) return false; + seen.add(token); + return true; + }); + }, resolveSidebarRootOrderTokens: ( sidebarRootOrder: unknown, - connectionTags: Array<{ id: string; connectionIds: string[] }>, + connectionTags: Array<{ id: string; parentTagId?: string; connectionIds: string[] }>, connections: Array<{ id: string }>, ) => { const groupedConnectionIds = new Set(); connectionTags.forEach((tag) => tag.connectionIds.forEach((id) => groupedConnectionIds.add(id))); const fallback = [ - ...connectionTags.map((tag) => `tag:${tag.id}`), + ...connectionTags.filter((tag) => !tag.parentTagId).map((tag) => `tag:${tag.id}`), ...connections .filter((conn) => !groupedConnectionIds.has(conn.id)) .map((conn) => `connection:${conn.id}`), @@ -190,6 +212,7 @@ vi.mock('../store', () => ({ updateConnectionTag: mocks.noop, removeConnectionTag: mocks.noop, moveConnectionToTag: mocks.noop, + moveConnectionTag: mocks.noop, reorderConnections: mocks.noop, reorderTags: mocks.noop, reorderSidebarRoot: mocks.noop, @@ -617,6 +640,72 @@ describe('Sidebar locate toolbar', () => { expect(getV2RailConnectionGroupBadgeText('生产环境')).toBe('生'); }); + it('builds arbitrarily nested host groups with mixed host and subgroup order', () => { + const connections = [ + 'host1', 'host2', 'host3', 'host4', 'host5', 'host6', + ].map((id) => ({ id, name: id, config: { type: 'mysql', host: `${id}.local` } })) as any[]; + const tags = [ + { + id: 'group-1', + name: '分组1', + connectionIds: ['host1', 'host2'], + childOrder: ['connection:host1', 'connection:host2', 'tag:group-1-1'], + }, + { + id: 'group-1-1', + name: '分组1-1', + parentTagId: 'group-1', + connectionIds: ['host3', 'host4'], + childOrder: ['connection:host3', 'connection:host4', 'tag:group-1-1-1'], + }, + { + id: 'group-1-1-1', + name: '分组1-1-1', + parentTagId: 'group-1-1', + connectionIds: ['host5', 'host6'], + childOrder: ['connection:host5', 'connection:host6'], + }, + ] as any[]; + + const outline = (items: ReturnType): unknown[] => items.map((item) => ( + item.kind === 'connection' + ? item.id + : { id: item.id, children: outline(item.children) } + )); + + expect(outline(buildSidebarConnectionTagTree(connections, tags, ['tag:group-1']))).toEqual([ + { + id: 'group-1', + children: [ + 'host1', + 'host2', + { + id: 'group-1-1', + children: [ + 'host3', + 'host4', + { id: 'group-1-1-1', children: ['host5', 'host6'] }, + ], + }, + ], + }, + ]); + expect(isConnectionTagDescendant('group-1', 'group-1-1-1', tags)).toBe(true); + expect(isConnectionTagDescendant('group-1-1', 'group-1', tags)).toBe(false); + }); + + it('keeps malformed group parents and parent cycles visible at the root', () => { + const tags = [ + { id: 'a', name: 'A', parentTagId: 'b', connectionIds: [] }, + { id: 'b', name: 'B', parentTagId: 'a', connectionIds: [] }, + { id: 'orphan', name: 'Orphan', parentTagId: 'missing', connectionIds: [] }, + ] as any[]; + + expect( + buildSidebarConnectionTagTree([], tags, []).map((item) => item.id), + ).toEqual(['a', 'b', 'orphan']); + }); + it('keeps the sidebar memoized so parent-only button state does not repaint the tree', () => { const source = readSidebarSource(); @@ -1446,44 +1535,40 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}'); }); - it('reorders dragged connections instead of only moving them between groups', () => { + it('uses the hierarchy actions for mixed sibling host and subgroup drag ordering', () => { const source = readSidebarSource(); const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); - expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);'); - expect(source).toContain('reorderConnections('); + expect(source).toContain('const moveConnectionTag = useStore(state => state.moveConnectionTag);'); + expect(source).toContain('moveConnectionTag(dragTagId, targetParentTagId, targetToken, targetInsertBefore);'); + expect(source).toContain('moveConnectionToTag(connectionId, targetParentTagId, targetToken, targetInsertBefore);'); expect(source).toContain('const insertBefore = resolveSidebarDropInsertBefore('); expect(source).toContain('const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);'); expect(source).toContain('const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);'); expect(source).toContain("findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key)"); expect(utilsSource).toContain("const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;"); - expect(source).toContain('insertBefore,'); + expect(source).toContain("info?.dropToGap === false"); }); - it('reorders dragged tags relative to grouped connections instead of always appending them', () => { + it('rejects a drag path that would put a group into itself or one of its descendants', () => { const source = readSidebarSource(); - expect(source).toContain("connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''"); - expect(source).toContain('const dropTagId = dropNode.type === \'tag\''); - expect(source).toContain('if (dropTagId) {'); + expect(source).toContain('const allowSidebarTreeDrop = ({ dragNode, dropNode, dropPosition }: any): boolean => {'); + expect(source).toContain('!isConnectionTagDescendant(dragTagId, targetParentTagId, connectionTags)'); + expect(source).toContain('allowDrop={allowSidebarTreeDrop}'); }); - it('wires v2 tree root dragging through the shared sidebar root order action', () => { + it('keeps selection preservation while routing tree drag through the shared hierarchy path', () => { const source = readSidebarSource(); - expect(source).toContain('const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);'); expect(source).toContain('const treeDragSelectSuppressUntilRef = useRef(0);'); expect(source).toContain('const treeDragSelectionSnapshotRef = useRef<'); expect(source).toContain('snapshotTreeSelectionBeforeDrag();'); expect(source).toContain('restoreTreeSelectionAfterDrag();'); expect(source).toContain('if (Date.now() < treeDragSelectSuppressUntilRef.current) {'); - expect(source).toContain('const getDropRootToken = (node: any): string => {'); - expect(source).toContain("return buildSidebarRootTagToken(String(node?.dataRef?.id || ''));"); - expect(source).toContain(': buildSidebarRootConnectionToken(String(node.key));'); - expect(source).toContain('const dragRootToken = buildSidebarRootTagToken(String(dragTagId));'); - expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore);'); - expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore);'); - expect(source).toContain('buildSidebarRootConnectionToken(String(dragNode.key))'); + expect(source).toContain('const getNodeOrderToken = (node: any): string | null => {'); + expect(source).toContain('const targetParentTagId = droppingIntoTag'); + expect(source).toContain('const targetToken = droppingIntoTag ? null : getNodeOrderToken(dropNode);'); expect(source).toContain('onDrop={handleDrop}'); }); @@ -2554,6 +2639,7 @@ describe('Sidebar locate toolbar', () => { it('localizes v2 connection shell fallbacks and group controls without changing raw names', () => { const source = readSidebarSource(); const menuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8'); + const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); expect(source).toContain("connectionName={String(conn?.name || node.title || t('connection.unnamed'))}"); expect(source).toContain("title: String(node.title || conn.name || t('connection.unnamed'))"); @@ -2561,7 +2647,7 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("title: String(node.title || dataRef.dbName || t('database.unnamed'))"); expect(source).toContain("meta: conn?.name || dataRef.id || t('database.label')"); expect(source).toContain("const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected');"); - expect(source).toContain("name: tag.name || t('connection.sidebar.group.untitled'),"); + expect(utilsSource).toContain("name: item.tag.name || t('connection.sidebar.group.untitled'),"); expect(source).toContain('groupName={group.name}'); expect(source).toContain('count={group.connections.length}'); expect(menuSource).toContain("title={groupName || t('connection.sidebar.group.untitled')}"); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d84ca667..e4be57a5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -108,7 +108,6 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Popover, Radio import { buildSidebarRootConnectionToken, buildSidebarRootTagToken, - resolveSidebarRootOrderTokens, useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -163,6 +162,7 @@ export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils'; import { buildSidebarTableChildrenForUi, + buildSidebarConnectionTagTree, buildV2RailConnectionGroups, buildV2SidebarTableSectionedChildren, collectSidebarSubtreeKeys, @@ -170,6 +170,7 @@ import { filterV2CommandSearchTreeItems, filterV2ExplorerTreeByKind, isSidebarTablePinned, + isConnectionTagDescendant, normalizeSidebarTreeRelativeDropPosition, resolveSidebarConnectionIdFromKey, resolveSidebarDropInsertBefore, @@ -177,7 +178,6 @@ import { resolveSidebarDropTargetMetricsFromDomEvent, resolveSidebarDatabaseTreePruneKeys, resolveSidebarNodeConnectionId, - resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, shouldClearSidebarNodeChildrenOnCollapse, @@ -193,6 +193,7 @@ import { export { buildSidebarTableChildrenForUi, + buildSidebarConnectionTagTree, buildV2RailConnectionGroups, buildV2SidebarTableSectionedChildren, collectSidebarSubtreeKeys, @@ -200,6 +201,7 @@ export { filterV2CommandSearchTreeItems, filterV2ExplorerTreeByKind, isSidebarTablePinned, + isConnectionTagDescendant, normalizeSidebarTreeRelativeDropPosition, resolveSidebarConnectionIdFromKey, resolveSidebarDropInsertBefore, @@ -207,7 +209,6 @@ export { resolveSidebarDropTargetMetricsFromDomEvent, resolveSidebarDatabaseTreePruneKeys, resolveSidebarNodeConnectionId, - resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, shouldClearSidebarNodeChildrenOnCollapse, @@ -217,6 +218,7 @@ export { shouldRunV2CommandSearchEnter, sortSidebarTableEntries, }; +export { resolveSidebarTagDropInsertBefore } from './sidebarV2Utils'; export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils'; type SidebarTreeSwitcherNodeLike = { @@ -469,9 +471,7 @@ const Sidebar: React.FC<{ const updateConnectionTag = useStore(state => state.updateConnectionTag); const removeConnectionTag = useStore(state => state.removeConnectionTag); const moveConnectionToTag = useStore(state => state.moveConnectionToTag); - const reorderConnections = useStore(state => state.reorderConnections); - const reorderTags = useStore(state => state.reorderTags); - const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot); + const moveConnectionTag = useStore(state => state.moveConnectionTag); const closeTabsByConnection = useStore(state => state.closeTabsByConnection); const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase); const theme = useStore(state => state.theme); @@ -966,13 +966,13 @@ const Sidebar: React.FC<{ } as TreeNode; }; - const taggedConnIds = new Set(); - const tagNodesById = new Map(); - connectionTags.forEach((tag) => { - tag.connectionIds.forEach(id => taggedConnIds.add(id)); - tagNodesById.set(tag.id, { - title: tag.name, - key: `tag-${tag.id}`, + const buildTreeNode = (item: ReturnType[number]): TreeNode => { + if (item.kind === 'connection') { + return buildConnectionNode(item.connection); + } + return { + title: item.tag.name, + key: `tag-${item.tag.id}`, icon: ( ), type: 'tag', - dataRef: tag, + dataRef: item.tag, isLeaf: false, - children: tag.connectionIds - .map(cid => connections.find(c => c.id === cid)) - .filter(Boolean) - .map(conn => buildConnectionNode(conn!)), - } as TreeNode); - }); + children: item.children.map(buildTreeNode), + } as TreeNode; + }; - const ungroupedNodesById = new Map(); - connections - .filter(c => !taggedConnIds.has(c.id)) - .forEach((conn) => { - ungroupedNodesById.set(conn.id, buildConnectionNode(conn)); - }); - - const orderedRootTokens = resolveSidebarRootOrderTokens( - sidebarRootOrder, - connectionTags, + const orderedNodes = buildSidebarConnectionTagTree( connections, - ); - const orderedNodes: TreeNode[] = []; - orderedRootTokens.forEach((token) => { - if (token.startsWith('tag:')) { - const tagNode = tagNodesById.get(token.slice('tag:'.length)); - if (!tagNode) return; - orderedNodes.push(tagNode); - tagNodesById.delete(token.slice('tag:'.length)); - return; - } - if (token.startsWith('connection:')) { - const connectionNode = ungroupedNodesById.get(token.slice('connection:'.length)); - if (!connectionNode) return; - orderedNodes.push(connectionNode); - ungroupedNodesById.delete(token.slice('connection:'.length)); - } - }); - - orderedNodes.push(...Array.from(tagNodesById.values())); - orderedNodes.push(...Array.from(ungroupedNodesById.values())); + connectionTags, + sidebarRootOrder, + ).map(buildTreeNode); if (allSavedQueriesNode) { orderedNodes.push(allSavedQueriesNode); } @@ -2630,6 +2601,52 @@ const Sidebar: React.FC<{ treeDragSelectSuppressUntilRef, setIsTreeDragging, }); + const getTagParentId = (tagId: unknown): string | null => { + const tag = connectionTags.find((candidate) => candidate.id === String(tagId || '').trim()); + const parentTagId = String(tag?.parentTagId || '').trim(); + return parentTagId || null; + }; + + const getConnectionParentTagId = (connectionId: unknown): string | null => ( + connectionTags.find((tag) => tag.connectionIds.includes(String(connectionId || '').trim()))?.id || null + ); + + const getNodeParentTagId = (node: any): string | null => { + if (node?.type === 'tag') return getTagParentId(node?.dataRef?.id); + if (node?.type === 'connection') return getConnectionParentTagId(node?.key); + return null; + }; + + const getNodeOrderToken = (node: any): string | null => { + if (node?.type === 'tag') { + const tagId = String(node?.dataRef?.id || '').trim(); + return tagId ? buildSidebarRootTagToken(tagId) : null; + } + if (node?.type === 'connection') { + const connectionId = String(node?.key || '').trim(); + return connectionId ? buildSidebarRootConnectionToken(connectionId) : null; + } + return null; + }; + + const allowSidebarTreeDrop = ({ dragNode, dropNode, dropPosition }: any): boolean => { + if (!dragNode || !dropNode) return false; + if ((dragNode.type !== 'tag' && dragNode.type !== 'connection') || (dropNode.type !== 'tag' && dropNode.type !== 'connection')) { + return false; + } + // Connections cannot contain tree items. A group can contain a group only + // when the pointer lands on its content, not on its before/after gap. + const droppingIntoTag = dropNode.type === 'tag' && Number(dropPosition) === 0; + if (dropNode.type === 'connection' && Number(dropPosition) === 0) return false; + if (dragNode.type !== 'tag') return String(dragNode.key) !== String(dropNode.key); + + const dragTagId = String(dragNode?.dataRef?.id || '').trim(); + const targetParentTagId = droppingIntoTag + ? String(dropNode?.dataRef?.id || '').trim() || null + : getNodeParentTagId(dropNode); + return !!dragTagId && !isConnectionTagDescendant(dragTagId, targetParentTagId, connectionTags); + }; + const handleDrop = (info: any) => { setIsTreeDragging(false); const dropPosition = normalizeSidebarTreeRelativeDropPosition( @@ -2643,128 +2660,34 @@ const Sidebar: React.FC<{ top: dropTargetMetrics.top, height: dropTargetMetrics.height, } : null); - const dragNode = info.dragNode; const dropNode = domDropNode && domDropNode.key === String(info?.node?.key || '') ? info.node : (domDropNode ? findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key) || info.node : info.node); + if (!dragNode || !dropNode) return; - const getDropRootToken = (node: any): string => { - if (!node) return ''; - if (node.type === 'tag') { - return buildSidebarRootTagToken(String(node?.dataRef?.id || '')); - } - if (node.type === 'connection') { - const groupedTagId = connectionTags.find((tag) => - tag.connectionIds.includes(String(node.key)), - )?.id || ''; - return groupedTagId - ? buildSidebarRootTagToken(groupedTagId) - : buildSidebarRootConnectionToken(String(node.key)); - } - return ''; - }; + const droppingIntoTag = dropNode.type === 'tag' && ( + info?.dropToGap === false || (info?.dropToGap === undefined && dropPosition === 0) + ); + const targetParentTagId = droppingIntoTag + ? String(dropNode?.dataRef?.id || '').trim() || null + : getNodeParentTagId(dropNode); + const targetToken = droppingIntoTag ? null : getNodeOrderToken(dropNode); + const targetInsertBefore = droppingIntoTag ? false : insertBefore; - // Root tag or ungrouped connection reordering if (dragNode.type === 'tag') { - if (dropNode.type === 'tag' || dropNode.type === 'connection') { - const currentTagOrder = connectionTags.map(t => t.id); - const dragTagId = dragNode.dataRef.id; - const dropTagId = dropNode.type === 'tag' - ? dropNode.dataRef.id - : (connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''); - const dragRootToken = buildSidebarRootTagToken(String(dragTagId)); - const dropRootToken = getDropRootToken(dropNode); - - if (dropRootToken && dropRootToken !== dragRootToken) { - if (dropTagId) { - const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({ - currentTagOrder, - dragTagId, - dropTagId, - relativeDropPosition: dropPosition, - fallbackInsertBefore: insertBefore, - metrics: dropTargetMetrics ? { - clientY: info?.event?.clientY, - top: dropTargetMetrics.top, - height: dropTargetMetrics.height, - } : null, - }); - reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore); - } else { - reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore); - } - return; - } - - const newOrder = currentTagOrder.filter(id => id !== dragTagId); - let insertIndex = newOrder.length; - if (dropTagId) { - const dropIndex = newOrder.indexOf(dropTagId); - const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({ - currentTagOrder, - dragTagId, - dropTagId, - relativeDropPosition: dropPosition, - fallbackInsertBefore: insertBefore, - metrics: dropTargetMetrics ? { - clientY: info?.event?.clientY, - top: dropTargetMetrics.top, - height: dropTargetMetrics.height, - } : null, - }); - - if (resolvedInsertBefore) { - insertIndex = dropIndex; - } else { - insertIndex = dropIndex + 1; - } - } else { - // Dropped onto an ungrouped root connection, usually meaning moving to the end of tags - // Since tags are always displayed before ungrouped connections, just put it at the end - insertIndex = newOrder.length; - } - - newOrder.splice(insertIndex, 0, dragTagId); - reorderTags(newOrder); - } + const dragTagId = String(dragNode?.dataRef?.id || '').trim(); + if (!dragTagId || isConnectionTagDescendant(dragTagId, targetParentTagId, connectionTags)) return; + moveConnectionTag(dragTagId, targetParentTagId, targetToken, targetInsertBefore); return; } if (dragNode.type === 'connection') { - const dragTagId = connectionTags.find((tag) => - tag.connectionIds.includes(String(dragNode.key)), - )?.id || ''; - const dragIsUngroupedRoot = !dragTagId; - const dropRootToken = getDropRootToken(dropNode); - if (dragIsUngroupedRoot && dropNode.type === 'connection' && dropRootToken) { - reorderSidebarRoot( - buildSidebarRootConnectionToken(String(dragNode.key)), - dropRootToken, - insertBefore, - ); - return; - } - } - - // Connection moving to tag (any drop position on a tag node counts as "into") - if (dragNode.type === 'connection' && dropNode.type === 'tag') { - moveConnectionToTag(dragNode.key, dropNode.dataRef.id); - return; - } - - // Connection reordering against another connection - if (dragNode.type === 'connection' && dropNode.type === 'connection') { - const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key)); - reorderConnections( - String(dragNode.key), - String(dropNode.key), - targetTag?.id || null, - insertBefore, - ); - return; + const connectionId = String(dragNode.key || '').trim(); + if (!connectionId || connectionId === String(dropNode.key || '')) return; + moveConnectionToTag(connectionId, targetParentTagId, targetToken, targetInsertBefore); } }; @@ -3228,6 +3151,7 @@ const Sidebar: React.FC<{ icon: false, nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag' }} + allowDrop={allowSidebarTreeDrop} onDragStart={() => { snapshotTreeSelectionBeforeDrag(); treeDragSelectSuppressUntilRef.current = Date.now() + 600; @@ -3325,7 +3249,6 @@ const Sidebar: React.FC<{ renameViewTarget={renameViewTarget} updateConnectionTag={updateConnectionTag} addConnectionTag={addConnectionTag} - moveConnectionToTag={moveConnectionToTag} isCreateDbModalOpen={isCreateDbModalOpen} setIsCreateDbModalOpen={setIsCreateDbModalOpen} createDbForm={createDbForm} diff --git a/frontend/src/components/SidebarNestedGroupMenu.test.ts b/frontend/src/components/SidebarNestedGroupMenu.test.ts new file mode 100644 index 00000000..11824347 --- /dev/null +++ b/frontend/src/components/SidebarNestedGroupMenu.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildConnectionTagParentOptions } from './sidebar/SidebarEntityModals'; +import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; + +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const modalSource = readFileSync(new URL('./sidebar/SidebarEntityModals.tsx', import.meta.url), 'utf8'); +const legacyMenuSource = readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8'); +const v2MenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8'); +const v2HandlerSource = readFileSync(new URL('./sidebar/useSidebarV2ActionHandlers.tsx', import.meta.url), 'utf8'); + +describe('Sidebar nested group menu', () => { + it('does not offer a group itself or its descendants as an editable parent', () => { + const options = buildConnectionTagParentOptions([ + { id: 'root', name: 'Root', connectionIds: [] }, + { id: 'child', name: 'Child', parentTagId: 'root', connectionIds: [] }, + { id: 'grandchild', name: 'Grandchild', parentTagId: 'child', connectionIds: [] }, + { id: 'other', name: 'Other', connectionIds: [] }, + ], 'root'); + + expect(options).toEqual([{ value: 'other', label: 'Other' }]); + }); + + it('preselects the clicked legacy group when creating a child group', () => { + const createTagForm = { + resetFields: vi.fn(), + setFieldsValue: vi.fn(), + }; + const setRenameViewTarget = vi.fn(); + const setIsCreateTagModalOpen = vi.fn(); + const node = { + type: 'tag', + title: 'Group 1', + dataRef: { + id: 'group-1', + name: 'Group 1', + parentTagId: 'root', + connectionIds: ['host-1'], + childOrder: ['connection:host-1'], + }, + }; + const items = buildSidebarLegacyNodeMenuItems(node, { + createTagForm, + setRenameViewTarget, + setIsCreateTagModalOpen, + removeConnectionTag: vi.fn(), + }); + const newChildItem = (items || []).find((item: any) => item?.key === 'new-child-tag') as any; + const editItem = (items || []).find((item: any) => item?.key === 'edit-tag') as any; + + newChildItem.onClick(); + expect(createTagForm.resetFields).toHaveBeenCalledOnce(); + expect(createTagForm.setFieldsValue).toHaveBeenLastCalledWith({ + parentTagId: 'group-1', + connectionIds: [], + }); + expect(setRenameViewTarget).toHaveBeenLastCalledWith(null); + expect(setIsCreateTagModalOpen).toHaveBeenLastCalledWith(true); + + editItem.onClick(); + expect(createTagForm.setFieldsValue).toHaveBeenLastCalledWith({ + name: 'Group 1', + parentTagId: 'root', + connectionIds: ['host-1'], + }); + }); + + it('keeps modal and both menu implementations aligned with nested grouping', () => { + expect(modalSource).toContain('name="parentTagId"'); + expect(modalSource).toContain('parentTagId,'); + expect(legacyMenuSource).toContain("key: 'new-child-tag'"); + expect(legacyMenuSource).toContain("t('connection.sidebar.group.newSubgroup')"); + expect(v2MenuSource).toContain("| 'new-subgroup'"); + expect(v2MenuSource).toContain("action: 'new-subgroup'"); + expect(v2HandlerSource).toContain("if (action === 'new-subgroup')"); + }); + + it('ships nested-group labels and deletion behavior in every locale', () => { + [ + 'connection.sidebar.group.newSubgroup', + 'sidebar.field.parent_group', + 'sidebar.placeholder.parent_group', + 'connection.sidebar.group.deleteConfirmContent', + 'sidebar.modal.confirm_delete_tag.content', + ].forEach((key) => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index 81248e88..663bfa49 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -494,6 +494,7 @@ export type V2ConnectionContextMenuTagItem = { }; export type V2ConnectionGroupContextMenuActionKey = + | 'new-subgroup' | 'edit-group' | 'delete-group'; @@ -522,6 +523,7 @@ export const V2ConnectionGroupContextMenuView: React.FC<{
{renderItems([ + { action: 'new-subgroup', icon: , title: t('connection.sidebar.group.newSubgroup'), featured: true }, { action: 'edit-group', icon: , title: t('connection.sidebar.group.edit'), kbd: 'F2', featured: true }, ])}
diff --git a/frontend/src/components/sidebar/SidebarEntityModals.tsx b/frontend/src/components/sidebar/SidebarEntityModals.tsx index 54c17c2f..16c1a19c 100644 --- a/frontend/src/components/sidebar/SidebarEntityModals.tsx +++ b/frontend/src/components/sidebar/SidebarEntityModals.tsx @@ -1,16 +1,65 @@ import React from 'react'; -import { Checkbox, Form, Input, Space } from 'antd'; +import { Checkbox, Form, Input, Select, Space } from 'antd'; import type { FormInstance } from 'antd/es/form'; import { FolderOpenOutlined } from '@ant-design/icons'; import Modal from '../common/ResizableDraggableModal'; -import type { SavedConnection, SavedQuery } from '../../types'; +import type { ConnectionTag, SavedConnection, SavedQuery } from '../../types'; import { t } from '../../i18n'; import { noAutoCapInputProps } from '../../utils/inputAutoCap'; -type ConnectionTag = { - id: string; - name: string; - connectionIds: string[]; +const getConnectionTagDescendantIds = ( + connectionTags: ConnectionTag[], + rootTagId: string, +): Set => { + const descendants = new Set(); + const pending = [rootTagId]; + while (pending.length > 0) { + const parentTagId = pending.pop(); + if (!parentTagId) continue; + connectionTags.forEach((tag) => { + if (tag.parentTagId !== parentTagId || descendants.has(tag.id)) return; + descendants.add(tag.id); + pending.push(tag.id); + }); + } + return descendants; +}; + +export const buildConnectionTagParentOptions = ( + connectionTags: ConnectionTag[], + editingTagId: string, +) => { + const excludedTagIds = editingTagId + ? new Set([editingTagId, ...getConnectionTagDescendantIds(connectionTags, editingTagId)]) + : new Set(); + const tagById = new Map(connectionTags.map((tag) => [tag.id, tag])); + + return connectionTags + .filter((tag) => !excludedTagIds.has(tag.id)) + .map((tag) => { + const names: string[] = []; + const visited = new Set(); + let current: ConnectionTag | undefined = tag; + while (current && !visited.has(current.id)) { + visited.add(current.id); + names.unshift(current.name); + current = current.parentTagId ? tagById.get(current.parentTagId) : undefined; + } + return { value: tag.id, label: names.join(' / ') || tag.name }; + }); +}; + +const resolveConnectionTagParentId = ( + value: unknown, + connectionTags: ConnectionTag[], + editingTagId: string, +): string | undefined => { + const parentTagId = typeof value === 'string' ? value.trim() : ''; + if (!parentTagId) return undefined; + return buildConnectionTagParentOptions(connectionTags, editingTagId) + .some((option) => option.value === parentTagId) + ? parentTagId + : undefined; }; type SidebarEntityModalsProps = { @@ -26,7 +75,6 @@ type SidebarEntityModalsProps = { renameViewTarget: any; updateConnectionTag: (tag: ConnectionTag) => void; addConnectionTag: (tag: ConnectionTag) => void; - moveConnectionToTag: (connectionId: string, tagId: string) => void; isCreateDbModalOpen: boolean; setIsCreateDbModalOpen: (open: boolean) => void; createDbForm: FormInstance; @@ -81,7 +129,6 @@ export const SidebarEntityModals: React.FC = ({ renameViewTarget, updateConnectionTag, addConnectionTag, - moveConnectionToTag, isCreateDbModalOpen, setIsCreateDbModalOpen, createDbForm, @@ -134,28 +181,29 @@ export const SidebarEntityModals: React.FC = ({ 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, + editingTagId, + ); if (renameViewTarget?.type === 'tag') { updateConnectionTag({ ...renameViewTarget.dataRef, name: values.name, + parentTagId, connectionIds: values.connectionIds || [], }); - const allOtherTagsIds = connectionTags.filter(tag => tag.id !== renameViewTarget.dataRef.id).flatMap(tag => tag.connectionIds); - (values.connectionIds || []).forEach((connectionId: string) => { - if (allOtherTagsIds.includes(connectionId)) { - moveConnectionToTag(connectionId, renameViewTarget.dataRef.id); - } - }); } else { const tagId = Date.now().toString(); addConnectionTag({ id: tagId, name: values.name, + parentTagId, connectionIds: values.connectionIds || [], }); - (values.connectionIds || []).forEach((connectionId: string) => { - moveConnectionToTag(connectionId, tagId); - }); } setIsCreateTagModalOpen(false); }); @@ -167,6 +215,16 @@ export const SidebarEntityModals: React.FC = ({ + +