diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue index 141d30303..83bec7ffd 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumb.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumb.vue @@ -42,6 +42,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver' import { useCanvasStore } from '@/stores/graphStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useWorkflowStore } from '@/stores/workflowStore' +import { forEachSubgraphNode } from '@/utils/graphTraversalUtil' const MIN_WIDTH = 28 const ITEM_GAP = 8 @@ -71,6 +72,14 @@ const items = computed(() => { if (!canvas.graph) throw new TypeError('Canvas has no graph') canvas.setGraph(subgraph) + }, + updateTitle: (title: string) => { + const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph + if (!rootGraph) return + + forEachSubgraphNode(rootGraph, subgraph.id, (node) => { + node.title = title + }) } })) diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue index 0814aa338..ffadd86f8 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue @@ -81,6 +81,9 @@ const rename = async ( initialName: string ) => { if (newName && newName !== initialName) { + // Synchronize the node titles with the new name + props.item.updateTitle?.(newName) + if (workflowStore.activeSubgraph) { workflowStore.activeSubgraph.name = newName } else if (workflowStore.activeWorkflow) { diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts index 32f3e5727..ecb116bf6 100644 --- a/src/utils/graphTraversalUtil.ts +++ b/src/utils/graphTraversalUtil.ts @@ -86,13 +86,7 @@ export function triggerCallbackOnAllNodes( graph: LGraph | Subgraph, callbackProperty: keyof LGraphNode ): void { - visitGraphNodes(graph, (node) => { - // Recursively process subgraphs first - if (node.isSubgraphNode?.() && node.subgraph) { - triggerCallbackOnAllNodes(node.subgraph, callbackProperty) - } - - // Invoke callback if it exists on the node + forEachNode(graph, (node) => { const callback = node[callbackProperty] if (typeof callback === 'function') { callback.call(node) @@ -100,6 +94,58 @@ export function triggerCallbackOnAllNodes( }) } +/** + * Maps a function over all nodes in a graph hierarchy (including subgraphs). + * This is a pure functional traversal that doesn't mutate the graph. + * + * @param graph - The root graph to traverse + * @param mapFn - Function to apply to each node + * @returns Array of mapped results (excluding undefined values) + */ +export function mapAllNodes( + graph: LGraph | Subgraph, + mapFn: (node: LGraphNode) => T | undefined +): T[] { + const results: T[] = [] + + visitGraphNodes(graph, (node) => { + // Recursively map over subgraphs first + if (node.isSubgraphNode?.() && node.subgraph) { + results.push(...mapAllNodes(node.subgraph, mapFn)) + } + + // Apply map function to current node + const result = mapFn(node) + if (result !== undefined) { + results.push(result) + } + }) + + return results +} + +/** + * Executes a side-effect function on all nodes in a graph hierarchy. + * This is for operations that modify nodes or perform side effects. + * + * @param graph - The root graph to traverse + * @param fn - Function to execute on each node + */ +export function forEachNode( + graph: LGraph | Subgraph, + fn: (node: LGraphNode) => void +): void { + visitGraphNodes(graph, (node) => { + // Recursively process subgraphs first + if (node.isSubgraphNode?.() && node.subgraph) { + forEachNode(node.subgraph, fn) + } + + // Execute function on current node + fn(node) + }) +} + /** * Collects all nodes in a graph hierarchy (including subgraphs) into a flat array. * @@ -111,21 +157,12 @@ export function collectAllNodes( graph: LGraph | Subgraph, filter?: (node: LGraphNode) => boolean ): LGraphNode[] { - const nodes: LGraphNode[] = [] - - visitGraphNodes(graph, (node) => { - // Recursively collect from subgraphs - if (node.isSubgraphNode?.() && node.subgraph) { - nodes.push(...collectAllNodes(node.subgraph, filter)) - } - - // Add node if it passes the filter (or no filter provided) + return mapAllNodes(graph, (node) => { if (!filter || filter(node)) { - nodes.push(node) + return node } + return undefined }) - - return nodes } /** @@ -241,3 +278,63 @@ export function getNodeByLocatorId( return targetSubgraph.getNodeById(localNodeId) || null } + +/** + * Finds the root graph from any graph in the hierarchy. + * + * @param graph - Any graph or subgraph in the hierarchy + * @returns The root graph + */ +export function getRootGraph(graph: LGraph | Subgraph): LGraph | Subgraph { + let current: LGraph | Subgraph = graph + while ('rootGraph' in current && current.rootGraph) { + current = current.rootGraph + } + return current +} + +/** + * Applies a function to all nodes whose type matches a subgraph ID. + * Operates on the entire graph hierarchy starting from the root. + * + * @param rootGraph - The root graph to search in + * @param subgraphId - The ID/type of the subgraph to match nodes against + * @param fn - Function to apply to each matching node + */ +export function forEachSubgraphNode( + rootGraph: LGraph | Subgraph | null | undefined, + subgraphId: string | null | undefined, + fn: (node: LGraphNode) => void +): void { + if (!rootGraph || !subgraphId) return + + forEachNode(rootGraph, (node) => { + if (node.type === subgraphId) { + fn(node) + } + }) +} + +/** + * Maps a function over all nodes whose type matches a subgraph ID. + * Operates on the entire graph hierarchy starting from the root. + * + * @param rootGraph - The root graph to search in + * @param subgraphId - The ID/type of the subgraph to match nodes against + * @param mapFn - Function to apply to each matching node + * @returns Array of mapped results + */ +export function mapSubgraphNodes( + rootGraph: LGraph | Subgraph | null | undefined, + subgraphId: string | null | undefined, + mapFn: (node: LGraphNode) => T +): T[] { + if (!rootGraph || !subgraphId) return [] + + return mapAllNodes(rootGraph, (node) => { + if (node.type === subgraphId) { + return mapFn(node) + } + return undefined + }) +} diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts index d485055bd..2241a248a 100644 --- a/tests-ui/tests/utils/graphTraversalUtil.test.ts +++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts @@ -5,10 +5,15 @@ import { collectAllNodes, findNodeInHierarchy, findSubgraphByUuid, + forEachNode, + forEachSubgraphNode, getLocalNodeIdFromExecutionId, getNodeByExecutionId, getNodeByLocatorId, + getRootGraph, getSubgraphPathFromExecutionId, + mapAllNodes, + mapSubgraphNodes, parseExecutionId, traverseSubgraphPath, triggerCallbackOnAllNodes, @@ -283,6 +288,141 @@ describe('graphTraversalUtil', () => { }) }) + describe('mapAllNodes', () => { + it('should map over all nodes in a flat graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const results = mapAllNodes(graph, (node) => node.id) + + expect(results).toEqual([1, 2, 3]) + }) + + it('should map over nodes in subgraphs', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + const results = mapAllNodes(graph, (node) => node.id) + + expect(results).toHaveLength(3) + expect(results).toContain(100) + }) + + it('should exclude undefined results', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const results = mapAllNodes(graph, (node) => { + return Number(node.id) > 1 ? node.id : undefined + }) + + expect(results).toEqual([2, 3]) + }) + + it('should handle deeply nested structures', () => { + const deepNode = createMockNode(300) + const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode]) + + const midNode = createMockNode(200) + const midSubgraphNode = createMockNode(201, { + isSubgraph: true, + subgraph: deepSubgraph + }) + const midSubgraph = createMockSubgraph('mid-uuid', [ + midNode, + midSubgraphNode + ]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph: midSubgraph }) + ] + + const graph = createMockGraph(nodes) + const results = mapAllNodes(graph, (node) => `node-${node.id}`) + + expect(results).toHaveLength(5) + expect(results).toContain('node-300') + }) + }) + + describe('forEachNode', () => { + it('should execute function on all nodes in a flat graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const visited: number[] = [] + forEachNode(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toHaveLength(3) + expect(visited).toContain(1) + expect(visited).toContain(2) + expect(visited).toContain(3) + }) + + it('should execute function on nodes in subgraphs', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + + const visited: number[] = [] + forEachNode(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toHaveLength(3) + expect(visited).toContain(100) + }) + + it('should allow node mutations', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + // Add a title property to each node + forEachNode(graph, (node) => { + ;(node as any).title = `Node ${node.id}` + }) + + expect(nodes[0]).toHaveProperty('title', 'Node 1') + expect(nodes[1]).toHaveProperty('title', 'Node 2') + expect(nodes[2]).toHaveProperty('title', 'Node 3') + }) + + it('should handle node type matching for subgraph references', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const matchingNodes: number[] = [] + forEachNode(graph, (node) => { + if (node.type === subgraphId) { + matchingNodes.push(node.id as number) + } + }) + + expect(matchingNodes).toEqual([2, 4]) + }) + }) + describe('findNodeInHierarchy', () => { it('should find node in root graph', () => { const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] @@ -482,5 +622,140 @@ describe('graphTraversalUtil', () => { expect(found).toBeNull() }) }) + + describe('getRootGraph', () => { + it('should return the same graph if it is already root', () => { + const graph = createMockGraph([]) + expect(getRootGraph(graph)).toBe(graph) + }) + + it('should return root graph from subgraph', () => { + const rootGraph = createMockGraph([]) + const subgraph = createMockSubgraph('sub-uuid', []) + ;(subgraph as any).rootGraph = rootGraph + + expect(getRootGraph(subgraph)).toBe(rootGraph) + }) + + it('should return root graph from deeply nested subgraph', () => { + const rootGraph = createMockGraph([]) + const midSubgraph = createMockSubgraph('mid-uuid', []) + const deepSubgraph = createMockSubgraph('deep-uuid', []) + + ;(midSubgraph as any).rootGraph = rootGraph + ;(deepSubgraph as any).rootGraph = midSubgraph + + expect(getRootGraph(deepSubgraph)).toBe(rootGraph) + }) + }) + + describe('forEachSubgraphNode', () => { + it('should apply function to all nodes matching subgraph type', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const matchingIds: number[] = [] + forEachSubgraphNode(graph, subgraphId, (node) => { + matchingIds.push(node.id as number) + }) + + expect(matchingIds).toEqual([2, 4]) + }) + + it('should work with root graph directly', () => { + const subgraphId = 'target-subgraph' + const rootNodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + createMockNode(2), + { ...createMockNode(3), type: subgraphId } as LGraphNode + ] + const rootGraph = createMockGraph(rootNodes) + + const matchingIds: number[] = [] + forEachSubgraphNode(rootGraph, subgraphId, (node) => { + matchingIds.push(node.id as number) + }) + + expect(matchingIds).toEqual([1, 3]) + }) + + it('should handle null inputs gracefully', () => { + const fn = vi.fn() + + forEachSubgraphNode(null, 'id', fn) + forEachSubgraphNode(createMockGraph([]), null, fn) + forEachSubgraphNode(null, null, fn) + + expect(fn).not.toHaveBeenCalled() + }) + + it('should allow node mutations like title updates', () => { + const subgraphId = 'my-subgraph' + const nodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3) + ] + const graph = createMockGraph(nodes) + + forEachSubgraphNode(graph, subgraphId, (node) => { + ;(node as any).title = 'Updated Title' + }) + + expect(nodes[0]).toHaveProperty('title', 'Updated Title') + expect(nodes[1]).toHaveProperty('title', 'Updated Title') + expect(nodes[2]).not.toHaveProperty('title', 'Updated Title') + }) + }) + + describe('mapSubgraphNodes', () => { + it('should map over nodes matching subgraph type', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const results = mapSubgraphNodes(graph, subgraphId, (node) => node.id) + + expect(results).toEqual([2, 4]) + }) + + it('should return empty array for null inputs', () => { + expect(mapSubgraphNodes(null, 'id', (n) => n.id)).toEqual([]) + expect( + mapSubgraphNodes(createMockGraph([]), null, (n) => n.id) + ).toEqual([]) + }) + + it('should work with complex transformations', () => { + const subgraphId = 'target' + const nodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + { ...createMockNode(2), type: 'other' } as LGraphNode, + { ...createMockNode(3), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const results = mapSubgraphNodes(graph, subgraphId, (node) => ({ + id: node.id, + isTarget: true + })) + + expect(results).toEqual([ + { id: 1, isTarget: true }, + { id: 3, isTarget: true } + ]) + }) + }) }) })