mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-22 06:23:43 +08:00
✨ feat(saved-query): 新增已存查询独立查看入口
- 侧边栏新增“全部已存查询”根节点,不依赖连接实例或加载数据库 - 按连接、数据库和未匹配状态分组展示后端已加载查询 - 使用独立树节点 key,避免与数据库节点下的同一查询冲突 - 重命名和删除按真实 query id 同步更新所有展示副本 - 补充独立入口分组结构测试,覆盖已匹配和未匹配查询
This commit is contained in:
@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Sidebar, {
|
||||
buildAllSavedQueriesTreeNode,
|
||||
buildSidebarTableChildrenForUi,
|
||||
buildV2SidebarTableSectionedChildren,
|
||||
buildV2RailConnectionGroups,
|
||||
@@ -499,6 +500,67 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(source).toContain('}> = React.memo(({');
|
||||
});
|
||||
|
||||
it('builds a standalone saved-query tree without loading database nodes', () => {
|
||||
const tree = buildAllSavedQueriesTreeNode(
|
||||
[
|
||||
{
|
||||
id: 'saved-1',
|
||||
name: 'Orders',
|
||||
sql: 'select * from orders',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'app',
|
||||
createdAt: 100,
|
||||
},
|
||||
{
|
||||
id: 'saved-orphan',
|
||||
name: 'Legacy Report',
|
||||
sql: 'select 1',
|
||||
connectionId: 'legacy-1',
|
||||
originalConnectionId: 'legacy-1',
|
||||
dbName: 'legacy_db',
|
||||
createdAt: 200,
|
||||
bindingStatus: 'orphan',
|
||||
},
|
||||
],
|
||||
[{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: 'db.local',
|
||||
port: 3306,
|
||||
},
|
||||
}] as any,
|
||||
);
|
||||
|
||||
expect(tree?.key).toBe('all-saved-queries');
|
||||
expect(tree?.title).toBe('全部已存查询');
|
||||
expect(tree?.children?.[0]).toMatchObject({
|
||||
key: 'all-saved-queries-connection-conn-1',
|
||||
title: 'Primary',
|
||||
type: 'saved-query-group',
|
||||
});
|
||||
expect(tree?.children?.[0].children?.[0]).toMatchObject({
|
||||
key: 'all-saved-queries-connection-conn-1-db-app',
|
||||
title: 'app',
|
||||
});
|
||||
expect(tree?.children?.[0].children?.[0].children?.[0]).toMatchObject({
|
||||
key: 'all-saved-query-saved-1',
|
||||
title: 'Orders',
|
||||
type: 'saved-query',
|
||||
});
|
||||
const unmatchedGroup = tree?.children?.find((child) => child.key === 'all-saved-queries-unmatched');
|
||||
expect(unmatchedGroup?.title).toBe('未匹配');
|
||||
expect(unmatchedGroup?.children?.[0]).toMatchObject({
|
||||
key: 'all-saved-queries-unmatched-legacy-1',
|
||||
title: 'legacy-1',
|
||||
});
|
||||
expect(unmatchedGroup?.children?.[0].children?.[0].children?.[0]).toMatchObject({
|
||||
key: 'all-saved-query-saved-orphan',
|
||||
title: 'Legacy Report',
|
||||
});
|
||||
});
|
||||
|
||||
it('releases backend database connections when disconnecting a sidebar connection', () => {
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
const disconnectSource = source.slice(
|
||||
|
||||
@@ -226,7 +226,7 @@ interface TreeNode {
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'all-saved-queries' | 'saved-query-group' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
@@ -257,6 +257,115 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
tag: <TagOutlined />,
|
||||
};
|
||||
|
||||
const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set<string>): boolean => (
|
||||
query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId)
|
||||
);
|
||||
|
||||
export const buildAllSavedQueriesTreeNode = (
|
||||
savedQueries: SavedQuery[],
|
||||
connections: SavedConnection[],
|
||||
): TreeNode | null => {
|
||||
if (savedQueries.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 || '未命名查询',
|
||||
key: `all-saved-query-${query.id}`,
|
||||
icon: <FileTextOutlined />,
|
||||
type: 'saved-query',
|
||||
dataRef: query,
|
||||
isLeaf: true,
|
||||
});
|
||||
const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => {
|
||||
const groupedByDatabase = new Map<string, SavedQuery[]>();
|
||||
queries.forEach((query) => {
|
||||
const dbName = String(query.dbName || '').trim() || '默认数据库';
|
||||
groupedByDatabase.set(dbName, [...(groupedByDatabase.get(dbName) || []), query]);
|
||||
});
|
||||
return Array.from(groupedByDatabase.entries()).map(([dbName, items]) => ({
|
||||
title: dbName,
|
||||
key: `${keyPrefix}-db-${encodeURIComponent(dbName)}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
type: 'saved-query-group',
|
||||
selectable: false,
|
||||
isLeaf: false,
|
||||
children: items.map(createQueryNode),
|
||||
}));
|
||||
};
|
||||
|
||||
const groupedByConnection = new Map<string, SavedQuery[]>();
|
||||
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<string, SavedQuery[]>();
|
||||
unmatchedSavedQueries.forEach((query) => {
|
||||
const originalConnectionId = String(query.originalConnectionId || query.connectionId || '未知连接').trim() || '未知连接';
|
||||
groupedByOriginalConnection.set(originalConnectionId, [
|
||||
...(groupedByOriginalConnection.get(originalConnectionId) || []),
|
||||
query,
|
||||
]);
|
||||
});
|
||||
children.push({
|
||||
title: '未匹配',
|
||||
key: 'all-saved-queries-unmatched',
|
||||
icon: <WarningOutlined />,
|
||||
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: <FolderOpenOutlined />,
|
||||
type: 'saved-query-group',
|
||||
selectable: false,
|
||||
isLeaf: false,
|
||||
children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: '全部已存查询',
|
||||
key: 'all-saved-queries',
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'all-saved-queries',
|
||||
isLeaf: false,
|
||||
selectable: false,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{
|
||||
onCreateConnection?: () => void;
|
||||
onEditConnection?: (conn: SavedConnection) => void;
|
||||
@@ -425,9 +534,12 @@ const Sidebar: React.FC<{
|
||||
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
|
||||
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
|
||||
const unmatchedSavedQueries = useMemo(
|
||||
() => savedQueries.filter((query) => query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId)),
|
||||
() => savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIdSet)),
|
||||
[connectionIdSet, savedQueries],
|
||||
);
|
||||
const allSavedQueriesNode = useMemo<TreeNode | null>(() => {
|
||||
return buildAllSavedQueriesTreeNode(savedQueries, connections);
|
||||
}, [connections, savedQueries]);
|
||||
const v2RailConnectionGroups = useMemo(
|
||||
() => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder),
|
||||
[connections, connectionTags, sidebarRootOrder],
|
||||
@@ -843,28 +955,13 @@ const Sidebar: React.FC<{
|
||||
|
||||
orderedNodes.push(...Array.from(tagNodesById.values()));
|
||||
orderedNodes.push(...Array.from(ungroupedNodesById.values()));
|
||||
if (unmatchedSavedQueries.length > 0) {
|
||||
orderedNodes.push({
|
||||
title: '未匹配已存查询',
|
||||
key: 'unmatched-saved-queries',
|
||||
icon: <FolderOpenOutlined />,
|
||||
type: 'unmatched-saved-queries',
|
||||
isLeaf: false,
|
||||
selectable: false,
|
||||
children: unmatchedSavedQueries.map((query) => ({
|
||||
title: query.name,
|
||||
key: query.id,
|
||||
icon: <FileTextOutlined />,
|
||||
type: 'saved-query',
|
||||
dataRef: query,
|
||||
isLeaf: true,
|
||||
})),
|
||||
});
|
||||
if (allSavedQueriesNode) {
|
||||
orderedNodes.push(allSavedQueriesNode);
|
||||
}
|
||||
const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root');
|
||||
return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes;
|
||||
});
|
||||
}, [connections, connectionTags, sidebarRootOrder, unmatchedSavedQueries]);
|
||||
}, [connections, connectionTags, sidebarRootOrder, allSavedQueriesNode]);
|
||||
|
||||
const handleDuplicateConnection = async (conn: SavedConnection) => {
|
||||
if (!conn?.id) return;
|
||||
@@ -2528,7 +2625,7 @@ const Sidebar: React.FC<{
|
||||
}, []);
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
if (type === 'tag' || type === 'unmatched-saved-queries') return;
|
||||
if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return;
|
||||
if (hasSidebarLazyChildren(children)) return;
|
||||
|
||||
if (type === 'connection') {
|
||||
@@ -4671,7 +4768,7 @@ const Sidebar: React.FC<{
|
||||
});
|
||||
const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list.map(node => {
|
||||
if (node.key === renameSavedQueryTarget.id) {
|
||||
if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) {
|
||||
return {
|
||||
...node,
|
||||
title: persisted.name,
|
||||
@@ -7557,7 +7654,7 @@ const Sidebar: React.FC<{
|
||||
// 从树中移除节点
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id))
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
const nextTreeData = removeNode(treeDataRef.current);
|
||||
treeDataRef.current = nextTreeData;
|
||||
|
||||
@@ -21,6 +21,8 @@ export type SidebarTreeNodeType =
|
||||
| 'v2-table-section'
|
||||
| 'queries-folder'
|
||||
| 'saved-query'
|
||||
| 'all-saved-queries'
|
||||
| 'saved-query-group'
|
||||
| 'unmatched-saved-queries'
|
||||
| 'external-sql-root'
|
||||
| 'external-sql-directory'
|
||||
|
||||
@@ -1210,11 +1210,11 @@ export namespace connection {
|
||||
fingerprintVersion?: string;
|
||||
bindingStatus?: string;
|
||||
originalConnectionId?: string;
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SavedQuery(source);
|
||||
}
|
||||
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
@@ -1232,17 +1232,17 @@ export namespace connection {
|
||||
export class SavedQueryImportPayload {
|
||||
queries: SavedQuery[];
|
||||
legacyConnections?: SavedConnectionInput[];
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SavedQueryImportPayload(source);
|
||||
}
|
||||
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.queries = this.convertValues(source["queries"], SavedQuery);
|
||||
this.legacyConnections = this.convertValues(source["legacyConnections"], SavedConnectionInput);
|
||||
}
|
||||
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
@@ -1454,3 +1454,4 @@ export namespace sync {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user