From 8a1c1842ee4d1c2c463b4b41b9edefde5b41354c Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 14 Jul 2026 17:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(saved-query):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E7=BA=A7=E5=B7=B2=E5=AD=98=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增已存 SQL 分组的持久化模型与后端管理接口 - 支持创建、编辑、嵌套、排序、移动和删除分组 - 在侧栏树和菜单中提供分组管理与未分组查询视图 - 同步 Wails 绑定、六种语言文案及前后端回归测试 --- frontend/src/App.tsx | 6 +- .../Sidebar.locate-toolbar.test.tsx | 76 ++ frontend/src/components/Sidebar.tsx | 251 +++++-- .../components/SidebarNestedGroupMenu.test.ts | 54 ++ .../SidebarSavedQueriesTree.i18n.test.ts | 15 + .../sidebar/SavedQueryGroupModal.tsx | 214 ++++++ .../sidebar/sidebarLegacyNodeMenu.tsx | 96 ++- frontend/src/components/sidebarV2Utils.ts | 1 + frontend/src/main.tsx | 108 +++ frontend/src/store.ts | 80 ++ frontend/src/types.ts | 11 + frontend/src/utils/savedQueryGroups.test.ts | 56 ++ frontend/src/utils/savedQueryGroups.ts | 223 ++++++ .../src/utils/savedQueryPersistence.test.ts | 20 +- frontend/src/utils/savedQueryPersistence.ts | 116 ++- frontend/wailsjs/go/app/App.d.ts | 10 + frontend/wailsjs/go/app/App.js | 20 + frontend/wailsjs/go/models.ts | 22 + internal/app/methods_saved_queries.go | 32 + internal/app/saved_queries.go | 693 +++++++++++++++++- internal/app/saved_queries_test.go | 420 +++++++++++ internal/connection/saved_query.go | 12 + shared/i18n/de-DE.json | 28 + shared/i18n/en-US.json | 28 + shared/i18n/ja-JP.json | 28 + shared/i18n/ru-RU.json | 28 + shared/i18n/zh-CN.json | 28 + shared/i18n/zh-TW.json | 28 + 28 files changed, 2624 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/sidebar/SavedQueryGroupModal.tsx create mode 100644 frontend/src/utils/savedQueryGroups.test.ts create mode 100644 frontend/src/utils/savedQueryGroups.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d2807be..6deb2519 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -660,6 +660,7 @@ function App() { const replaceConnections = useStore(state => state.replaceConnections); const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy); const replaceSavedQueries = useStore(state => state.replaceSavedQueries); + const reloadSavedQueryGroups = useStore(state => state.reloadSavedQueryGroups); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); const shortcutOptions = useStore(state => state.shortcutOptions); @@ -1033,6 +1034,9 @@ function App() { } }, }); + if (!cancelled) { + await reloadSavedQueryGroups(); + } } catch (err) { console.warn('Failed to bootstrap saved queries', err); } @@ -1042,7 +1046,7 @@ function App() { return () => { cancelled = true; }; - }, [isStoreHydrated, replaceSavedQueries]); + }, [isStoreHydrated, reloadSavedQueryGroups, replaceSavedQueries]); const normalizeSecurityUpdateStatus = useCallback((status?: Partial | null): SecurityUpdateStatus => { const fallback = createEmptySecurityUpdateStatus(); diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 34e74212..00688b47 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -194,9 +194,14 @@ vi.mock('../store', () => ({ useStore: (selector: (state: any) => any) => selector({ connections: mocks.state.connections, savedQueries: [], + savedQueryGroups: [], externalSQLDirectories: [], saveQuery: mocks.noop, deleteQuery: mocks.noop, + saveSavedQueryGroup: mocks.noop, + deleteSavedQueryGroup: mocks.noop, + moveSavedQueryToGroup: mocks.noop, + reloadSavedQueryGroups: mocks.noop, saveExternalSQLDirectory: mocks.noop, deleteExternalSQLDirectory: mocks.noop, addConnection: mocks.noop, @@ -773,6 +778,77 @@ describe('Sidebar locate toolbar', () => { }); }); + it('renders saved query groups in mixed child order and keeps grouped SQL out of the ungrouped branch', () => { + const tree = buildAllSavedQueriesTreeNode( + [ + { + id: 'query-root', + name: 'Root query', + sql: 'select 1', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 100, + }, + { + id: 'query-child', + name: 'Child query', + sql: 'select 2', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 200, + }, + { + id: 'query-ungrouped', + name: 'Ungrouped query', + sql: 'select 3', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 300, + }, + ], + [{ + id: 'conn-1', + name: 'Primary', + config: { type: 'mysql', host: 'db.local', port: 3306 }, + }] as any, + [ + { + id: 'root-group', + name: 'Root group', + queryIds: ['query-root'], + childOrder: ['group:child-group', 'query:query-root'], + }, + { + id: 'child-group', + name: 'Child group', + parentGroupId: 'root-group', + queryIds: ['query-child'], + childOrder: ['query:query-child'], + }, + ], + ); + + const rootGroup = tree?.children?.find((child) => child.key === 'saved-query-manual-group-root-group'); + expect(rootGroup?.children?.map((child) => child.key)).toEqual([ + 'saved-query-manual-group-child-group', + 'all-saved-query-query-root', + ]); + expect(rootGroup?.children?.[0].children?.map((child) => child.key)).toEqual([ + 'all-saved-query-query-child', + ]); + + const ungrouped = tree?.children?.find((child) => child.key === 'all-saved-queries-ungrouped'); + expect(ungrouped?.children?.[0]).toMatchObject({ + key: 'all-saved-queries-connection-conn-1', + title: 'Primary', + }); + expect(ungrouped?.children?.[0].children?.[0].children?.map((child) => child.key)).toEqual([ + 'all-saved-query-query-ungrouped', + ]); + expect(JSON.stringify(ungrouped)).not.toContain('all-saved-query-query-root'); + expect(JSON.stringify(ungrouped)).not.toContain('all-saved-query-query-child'); + }); + it('releases backend database connections when disconnecting a sidebar connection', () => { const source = readSidebarSource(); const disconnectSource = source.slice( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e4be57a5..f6975f94 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { } from './sidebar/useSidebarBatchExport'; import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; import { SidebarEntityModals } from './sidebar/SidebarEntityModals'; +import { SavedQueryGroupModal } from './sidebar/SavedQueryGroupModal'; import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle'; import { useSidebarV2ContextMenu, @@ -111,7 +112,7 @@ import { useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, SchemaVisibilityRule } from '../types'; + import { SavedConnection, SavedQuery, SavedQueryGroup, ExternalSQLDirectory, ExternalSQLTreeEntry, SchemaVisibilityRule } from '../types'; import { getDbIcon } from './DatabaseIcons'; import { ListSQLDirectory } from '../../wailsjs/go/app/App'; import { supportsTableTruncateAction } from './tableDataDangerActions'; @@ -135,6 +136,15 @@ import { type SidebarLocateTreeNodeLike, } from '../utils/sidebarLocate'; import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual'; +import { + getSavedQueryGroupIdFromToken, + getSavedQueryGroupOwnerIds, + getSavedQueryIdFromGroupToken, + isSavedQueryGroupQueryToken, + isSavedQueryGroupToken, + normalizeSavedQueryGroups, + resolveSavedQueryGroupChildOrder, +} from '../utils/savedQueryGroups'; import { getSchemaVisibilityRule, moveSchemaVisibilityRule, @@ -325,14 +335,16 @@ const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: export const buildAllSavedQueriesTreeNode = ( savedQueries: SavedQuery[], connections: SavedConnection[], + savedQueryGroups: SavedQueryGroup[] = [], ): TreeNode | null => { - if (savedQueries.length === 0) { + const normalizedGroups = normalizeSavedQueryGroups( + savedQueryGroups, + savedQueries.map((query) => query.id), + ); + if (savedQueries.length === 0 && normalizedGroups.length === 0) { return null; } - const connectionIds = new Set(connections.map((conn) => conn.id)); - const unmatchedSavedQueries = savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds)); - const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id)); const createQueryNode = (query: SavedQuery): TreeNode => ({ title: query.name || t('sidebar.tree.untitled_query'), key: `all-saved-query-${query.id}`, @@ -358,61 +370,115 @@ export const buildAllSavedQueriesTreeNode = ( })); }; - const groupedByConnection = new Map(); - savedQueries.forEach((query) => { - if (unmatchedIds.has(query.id)) { - return; - } - groupedByConnection.set(query.connectionId, [ - ...(groupedByConnection.get(query.connectionId) || []), - query, - ]); - }); - - const children: TreeNode[] = []; - connections.forEach((conn) => { - const connectionQueries = groupedByConnection.get(conn.id); - if (!connectionQueries || connectionQueries.length === 0) { - return; - } - const iconType = resolveConnectionIconType(conn); - const iconColor = resolveConnectionAccentColor(conn); - children.push({ - title: conn.name || conn.id, - key: `all-saved-queries-connection-${conn.id}`, - icon: getDbIcon(iconType, iconColor, 22), - type: 'saved-query-group', - selectable: false, - isLeaf: false, - children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`), - }); - }); - - if (unmatchedSavedQueries.length > 0) { - const groupedByOriginalConnection = new Map(); - unmatchedSavedQueries.forEach((query) => { - const originalConnectionId = String(query.originalConnectionId || query.connectionId || t('sidebar.tree.unknown_connection')).trim() || t('sidebar.tree.unknown_connection'); - groupedByOriginalConnection.set(originalConnectionId, [ - ...(groupedByOriginalConnection.get(originalConnectionId) || []), + const buildAutomaticChildren = (queries: SavedQuery[]): TreeNode[] => { + const connectionIds = new Set(connections.map((conn) => conn.id)); + const unmatchedSavedQueries = queries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds)); + const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id)); + const groupedByConnection = new Map(); + queries.forEach((query) => { + if (unmatchedIds.has(query.id)) return; + groupedByConnection.set(query.connectionId, [ + ...(groupedByConnection.get(query.connectionId) || []), query, ]); }); - children.push({ - title: t('sidebar.tree.unmatched_saved_queries'), - key: 'all-saved-queries-unmatched', - icon: , - type: 'saved-query-group', - selectable: false, - isLeaf: false, - children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({ - title: connectionLabel, - key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`, - icon: , + + const automaticChildren: TreeNode[] = []; + connections.forEach((conn) => { + const connectionQueries = groupedByConnection.get(conn.id); + if (!connectionQueries || connectionQueries.length === 0) return; + const iconType = resolveConnectionIconType(conn); + const iconColor = resolveConnectionAccentColor(conn); + automaticChildren.push({ + title: conn.name || conn.id, + key: `all-saved-queries-connection-${conn.id}`, + icon: getDbIcon(iconType, iconColor, 22), type: 'saved-query-group', selectable: false, isLeaf: false, - children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`), - })), + children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`), + }); + }); + + if (unmatchedSavedQueries.length > 0) { + const groupedByOriginalConnection = new Map(); + unmatchedSavedQueries.forEach((query) => { + const originalConnectionId = String(query.originalConnectionId || query.connectionId || t('sidebar.tree.unknown_connection')).trim() || t('sidebar.tree.unknown_connection'); + groupedByOriginalConnection.set(originalConnectionId, [ + ...(groupedByOriginalConnection.get(originalConnectionId) || []), + query, + ]); + }); + automaticChildren.push({ + title: t('sidebar.tree.unmatched_saved_queries'), + key: 'all-saved-queries-unmatched', + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({ + title: connectionLabel, + key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`, + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`), + })), + }); + } + return automaticChildren; + }; + + const queryById = new Map(savedQueries.map((query) => [query.id, query])); + const groupById = new Map(normalizedGroups.map((group) => [group.id, group])); + const groupOwners = getSavedQueryGroupOwnerIds(normalizedGroups); + const buildManualGroupNode = (group: SavedQueryGroup, ancestors = new Set()): TreeNode => { + const nextAncestors = new Set(ancestors); + nextAncestors.add(group.id); + const children = resolveSavedQueryGroupChildOrder(group.id, normalizedGroups).flatMap((token): TreeNode[] => { + if (isSavedQueryGroupQueryToken(token)) { + const query = queryById.get(getSavedQueryIdFromGroupToken(token)); + return query ? [createQueryNode(query)] : []; + } + if (isSavedQueryGroupToken(token)) { + const childGroupId = getSavedQueryGroupIdFromToken(token); + const childGroup = groupById.get(childGroupId); + if (!childGroup || childGroup.parentGroupId !== group.id || nextAncestors.has(childGroup.id)) return []; + return [buildManualGroupNode(childGroup, nextAncestors)]; + } + return []; + }); + return { + title: group.name || t('sidebar.saved_query_group.untitled'), + key: `saved-query-manual-group-${group.id}`, + icon: , + type: 'saved-query-manual-group', + dataRef: group, + selectable: false, + isLeaf: false, + children, + }; + }; + + const automaticChildren = buildAutomaticChildren( + savedQueries.filter((query) => !groupOwners.has(query.id)), + ); + const children: TreeNode[] = normalizedGroups + .filter((group) => !group.parentGroupId) + .map((group) => buildManualGroupNode(group)); + + if (normalizedGroups.length === 0) { + children.push(...automaticChildren); + } else if (automaticChildren.length > 0) { + children.push({ + title: t('sidebar.tree.ungrouped_saved_queries'), + key: 'all-saved-queries-ungrouped', + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: automaticChildren, }); } @@ -448,9 +514,14 @@ const Sidebar: React.FC<{ }) => { const connections = useStore(state => state.connections); const savedQueries = useStore(state => state.savedQueries); + const savedQueryGroups = useStore(state => state.savedQueryGroups); const externalSQLDirectories = useStore(state => state.externalSQLDirectories); const saveQuery = useStore(state => state.saveQuery); const deleteQuery = useStore(state => state.deleteQuery); + const saveSavedQueryGroup = useStore(state => state.saveSavedQueryGroup); + const deleteSavedQueryGroup = useStore(state => state.deleteSavedQueryGroup); + const moveSavedQueryToGroup = useStore(state => state.moveSavedQueryToGroup); + const reloadSavedQueryGroups = useStore(state => state.reloadSavedQueryGroups); const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory); const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory); const updateRecentSQLFilePath = useStore(state => state.updateRecentSQLFilePath); @@ -614,8 +685,8 @@ const Sidebar: React.FC<{ [connectionIdSet, savedQueries], ); const allSavedQueriesNode = useMemo(() => { - return buildAllSavedQueriesTreeNode(savedQueries, connections); - }, [connections, savedQueries]); + return buildAllSavedQueriesTreeNode(savedQueries, connections, savedQueryGroups); + }, [connections, savedQueries, savedQueryGroups]); const snapshotTreeSelectionBeforeDrag = useCallback(() => { treeDragSelectionSnapshotRef.current = { selectedKeys: [...selectedKeys], @@ -824,6 +895,9 @@ const Sidebar: React.FC<{ const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false); const [renameSavedQueryForm] = Form.useForm(); const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState(null); + const [isSavedQueryGroupModalOpen, setIsSavedQueryGroupModalOpen] = useState(false); + const [savedQueryGroupTargetId, setSavedQueryGroupTargetId] = useState(null); + const [savedQueryGroupInitialParentId, setSavedQueryGroupInitialParentId] = useState(null); // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); @@ -1447,7 +1521,7 @@ const Sidebar: React.FC<{ }, []); const onLoadData = async ({ key, children, dataRef, type }: any) => { - if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return; + if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'saved-query-manual-group' || type === 'unmatched-saved-queries') return; if (hasSidebarLazyChildren(children)) return; if (type === 'connection') { @@ -1968,6 +2042,51 @@ const Sidebar: React.FC<{ return rawName || t('query_editor.save_modal.unnamed'); }; + const openSavedQueryGroupModal = useCallback(async ( + target?: SavedQueryGroup | null, + initialParentGroupId?: string | null, + ) => { + try { + const groups = await reloadSavedQueryGroups(); + const targetId = String(target?.id || '').trim(); + if (targetId && !groups.some((group) => group.id === targetId)) { + message.warning(t('sidebar.message.saved_query_group_not_found')); + return; + } + const parentId = String(initialParentGroupId || '').trim(); + setSavedQueryGroupTargetId(targetId || null); + setSavedQueryGroupInitialParentId( + parentId && groups.some((group) => group.id === parentId) ? parentId : null, + ); + setIsSavedQueryGroupModalOpen(true); + } catch (error) { + message.error(t('sidebar.message.saved_query_group_load_failed', { + error: error instanceof Error ? error.message : String(error), + })); + } + }, [reloadSavedQueryGroups]); + + const closeSavedQueryGroupModal = useCallback(() => { + setIsSavedQueryGroupModalOpen(false); + setSavedQueryGroupTargetId(null); + setSavedQueryGroupInitialParentId(null); + }, []); + + const handleSaveSavedQueryGroup = useCallback(async (group: SavedQueryGroup) => { + const isEditing = Boolean(group.id); + await saveSavedQueryGroup(group); + message.success(t( + isEditing + ? 'sidebar.message.saved_query_group_updated' + : 'sidebar.message.saved_query_group_created', + )); + }, [saveSavedQueryGroup]); + + const savedQueryGroupTarget = useMemo( + () => savedQueryGroups.find((group) => group.id === savedQueryGroupTargetId) || null, + [savedQueryGroupTargetId, savedQueryGroups], + ); + const { loadDatabases, loadJVMResources, @@ -2576,6 +2695,10 @@ const Sidebar: React.FC<{ openRenameSavedQueryModal, resolveSavedQueryDisplayName, deleteQuery, + savedQueryGroups, + openSavedQueryGroupModal, + deleteSavedQueryGroup, + moveSavedQueryToGroup, treeDataRef, setTreeData, handleAddExternalSQLDirectory, @@ -3290,6 +3413,20 @@ const Sidebar: React.FC<{ handleRenameSavedQuery={handleRenameSavedQuery} /> + + , diff --git a/frontend/src/components/SidebarNestedGroupMenu.test.ts b/frontend/src/components/SidebarNestedGroupMenu.test.ts index c0d1b38b..dc9ac2b6 100644 --- a/frontend/src/components/SidebarNestedGroupMenu.test.ts +++ b/frontend/src/components/SidebarNestedGroupMenu.test.ts @@ -105,6 +105,60 @@ describe('Sidebar nested group menu', () => { }); }); + it('exposes saved query group actions from the saved-query tree', () => { + const openSavedQueryGroupModal = vi.fn(); + const moveSavedQueryToGroup = vi.fn(); + const savedQueryGroups = [{ + id: 'group-1', + name: 'Group 1', + queryIds: ['query-1'], + childOrder: ['query:query-1'], + }]; + const context = { + openSavedQueryGroupModal, + deleteSavedQueryGroup: vi.fn(), + moveSavedQueryToGroup, + savedQueryGroups, + connections: [], + isSavedQueryUnmatched: () => false, + addTab: vi.fn(), + resolveSavedQueryDisplayName: (name: string) => name, + deleteQuery: vi.fn(), + }; + + const rootItems = buildSidebarLegacyNodeMenuItems({ type: 'all-saved-queries' }, context) as any[]; + const newGroup = rootItems.find((item) => item?.key === 'new-saved-query-group'); + newGroup.onClick(); + expect(openSavedQueryGroupModal).toHaveBeenCalledWith(null, null); + + const groupItems = buildSidebarLegacyNodeMenuItems({ + type: 'saved-query-manual-group', + dataRef: savedQueryGroups[0], + }, context) as any[]; + const newSubgroup = groupItems.find((item) => item?.key === 'new-saved-query-subgroup'); + const editGroup = groupItems.find((item) => item?.key === 'edit-saved-query-group'); + newSubgroup.onClick(); + expect(openSavedQueryGroupModal).toHaveBeenLastCalledWith(null, 'group-1'); + editGroup.onClick(); + expect(openSavedQueryGroupModal).toHaveBeenLastCalledWith(savedQueryGroups[0]); + + const queryItems = buildSidebarLegacyNodeMenuItems({ + type: 'saved-query', + dataRef: { + id: 'query-1', + name: 'Grouped query', + sql: 'select 1', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 1, + }, + }, context) as any[]; + const moveToGroup = queryItems.find((item) => item?.key === 'move-saved-query-to-group'); + const moveOut = queryItems.find((item) => item?.key === 'move-saved-query-to-ungrouped'); + expect(moveToGroup.children).toHaveLength(1); + expect(moveOut).toBeDefined(); + }); + it('keeps modal and both menu implementations aligned with nested grouping', () => { expect(modalSource).toContain('name="parentTagId"'); expect(modalSource).toContain('parentTagId,'); diff --git a/frontend/src/components/SidebarSavedQueriesTree.i18n.test.ts b/frontend/src/components/SidebarSavedQueriesTree.i18n.test.ts index f7d174d4..3b3871d2 100644 --- a/frontend/src/components/SidebarSavedQueriesTree.i18n.test.ts +++ b/frontend/src/components/SidebarSavedQueriesTree.i18n.test.ts @@ -10,6 +10,20 @@ const requiredKeys = [ 'sidebar.tree.unknown_connection', 'sidebar.tree.unmatched_saved_queries', 'sidebar.tree.all_saved_queries', + 'sidebar.tree.ungrouped_saved_queries', + 'sidebar.saved_query_group.create_title', + 'sidebar.saved_query_group.edit_title', + 'sidebar.saved_query_group.new_group', + 'sidebar.saved_query_group.new_subgroup', + 'sidebar.saved_query_group.move_to_group', + 'sidebar.saved_query_group.move_to_ungrouped', + 'sidebar.saved_query_group.empty_queries', + 'sidebar.message.saved_query_group_created', + 'sidebar.message.saved_query_group_delete_failed', + 'sidebar.message.saved_query_group_move_failed', + 'sidebar.message.saved_query_group_save_failed', + 'sidebar.saved_query_group.error.backend_unavailable', + 'sidebar.saved_query_group.error.invalid_input', ]; describe('Sidebar saved queries tree i18n', () => { @@ -29,6 +43,7 @@ describe('Sidebar saved queries tree i18n', () => { expect(source).toContain("t('sidebar.tree.unknown_connection')"); expect(source).toContain("title: t('sidebar.tree.unmatched_saved_queries')"); expect(source).toContain("title: t('sidebar.tree.all_saved_queries')"); + expect(source).toContain("title: t('sidebar.tree.ungrouped_saved_queries')"); }); it('keeps saved query fallback keys available in every locale', () => { diff --git a/frontend/src/components/sidebar/SavedQueryGroupModal.tsx b/frontend/src/components/sidebar/SavedQueryGroupModal.tsx new file mode 100644 index 00000000..2543ecbf --- /dev/null +++ b/frontend/src/components/sidebar/SavedQueryGroupModal.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Checkbox, Form, Input, message, Select, Space } from 'antd'; +import { FolderOpenOutlined } from '@ant-design/icons'; + +import Modal from '../common/ResizableDraggableModal'; +import type { SavedQuery, SavedQueryGroup } from '../../types'; +import { t } from '../../i18n'; +import { noAutoCapInputProps } from '../../utils/inputAutoCap'; +import { + buildSavedQueryGroupParentOptions, + buildSavedQueryGroupQueryToken, + buildSavedQueryGroupToken, + getSavedQueryGroupOwnerIds, + normalizeSavedQueryGroups, +} from '../../utils/savedQueryGroups'; + +type SavedQueryGroupModalProps = { + open: boolean; + groups: SavedQueryGroup[]; + savedQueries: SavedQuery[]; + target: SavedQueryGroup | null; + initialParentGroupId?: string | null; + modalPanelStyle: React.CSSProperties; + modalSectionStyle: React.CSSProperties; + modalScrollSectionStyle: React.CSSProperties; + renderModalTitle: (icon: React.ReactNode, title: string, description: string) => React.ReactNode; + onClose: () => void; + onSave: (group: SavedQueryGroup) => Promise; +}; + +const asStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.reduce((result, item) => { + const id = String(item || '').trim(); + if (!id || seen.has(id)) return result; + seen.add(id); + result.push(id); + return result; + }, []); +}; + +const buildChildOrder = ( + group: SavedQueryGroup | null, + queryIds: string[], + groups: SavedQueryGroup[], +): string[] => { + const directChildGroupIds = group?.id + ? groups + .filter((candidate) => candidate.parentGroupId === group.id) + .map((candidate) => candidate.id) + : []; + const defaults = [ + ...queryIds.map(buildSavedQueryGroupQueryToken), + ...directChildGroupIds.map(buildSavedQueryGroupToken), + ]; + const validTokens = new Set(defaults); + const seen = new Set(); + return [...(group?.childOrder || []), ...defaults].filter((token) => { + if (!validTokens.has(token) || seen.has(token)) return false; + seen.add(token); + return true; + }); +}; + +export const SavedQueryGroupModal: React.FC = ({ + open, + groups, + savedQueries, + target, + initialParentGroupId, + modalPanelStyle, + modalSectionStyle, + modalScrollSectionStyle, + renderModalTitle, + onClose, + onSave, +}) => { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const normalizedGroups = useMemo( + () => normalizeSavedQueryGroups(groups, savedQueries.map((query) => query.id)), + [groups, savedQueries], + ); + const editingGroup = useMemo( + () => normalizedGroups.find((group) => group.id === target?.id) || null, + [normalizedGroups, target?.id], + ); + const editingGroupId = editingGroup?.id || ''; + const parentOptions = useMemo( + () => buildSavedQueryGroupParentOptions(normalizedGroups, editingGroupId), + [editingGroupId, normalizedGroups], + ); + const queryOwnerIds = useMemo( + () => getSavedQueryGroupOwnerIds(normalizedGroups), + [normalizedGroups], + ); + const selectableQueries = useMemo( + () => savedQueries.filter((query) => { + const ownerId = queryOwnerIds.get(query.id); + return !ownerId || ownerId === editingGroupId; + }), + [editingGroupId, queryOwnerIds, savedQueries], + ); + + useEffect(() => { + if (!open) return; + form.resetFields(); + form.setFieldsValue({ + name: editingGroup?.name || '', + parentGroupId: editingGroup?.parentGroupId || initialParentGroupId || undefined, + queryIds: editingGroup?.queryIds || [], + }); + }, [editingGroup?.id, form, initialParentGroupId, open]); + + const close = () => { + if (saving) return; + form.resetFields(); + onClose(); + }; + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const queryIds = asStringArray(values.queryIds); + const nextGroup: SavedQueryGroup = { + id: editingGroup?.id || '', + name: String(values.name || '').trim(), + // Keep an explicit empty parent so the backend can move an edited + // subgroup back to the root instead of preserving its old parent. + parentGroupId: String(values.parentGroupId || '').trim(), + queryIds, + childOrder: buildChildOrder(editingGroup, queryIds, normalizedGroups), + }; + setSaving(true); + await onSave(nextGroup); + form.resetFields(); + onClose(); + } catch (error) { + if (error instanceof Error) { + message.error(t('sidebar.message.saved_query_group_save_failed', { error: error.message })); + } + } finally { + setSaving(false); + } + }; + + const title = editingGroup + ? t('sidebar.saved_query_group.edit_title') + : t('sidebar.saved_query_group.create_title'); + + return ( + , + title, + t('sidebar.saved_query_group.description'), + )} + open={open} + centered + width={560} + okText={t('common.save')} + cancelText={t('common.cancel')} + confirmLoading={saving} + styles={{ + content: modalPanelStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, + body: { paddingTop: 8 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 }, + }} + onOk={() => void handleSave()} + onCancel={close} + > +
+
+ + + + +