[feat] sync subgraph node titles with breadcrumb renaming (#4565)

This commit is contained in:
Christian Byrne
2025-07-28 18:00:59 -07:00
committed by GitHub
parent b6922cf386
commit b1436a068b
4 changed files with 403 additions and 19 deletions

View File

@@ -42,6 +42,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore' import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore' import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
const MIN_WIDTH = 28 const MIN_WIDTH = 28
const ITEM_GAP = 8 const ITEM_GAP = 8
@@ -71,6 +72,14 @@ const items = computed(() => {
if (!canvas.graph) throw new TypeError('Canvas has no graph') if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph) canvas.setGraph(subgraph)
},
updateTitle: (title: string) => {
const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph
if (!rootGraph) return
forEachSubgraphNode(rootGraph, subgraph.id, (node) => {
node.title = title
})
} }
})) }))

View File

@@ -81,6 +81,9 @@ const rename = async (
initialName: string initialName: string
) => { ) => {
if (newName && newName !== initialName) { if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) { if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) { } else if (workflowStore.activeWorkflow) {

View File

@@ -86,13 +86,7 @@ export function triggerCallbackOnAllNodes(
graph: LGraph | Subgraph, graph: LGraph | Subgraph,
callbackProperty: keyof LGraphNode callbackProperty: keyof LGraphNode
): void { ): void {
visitGraphNodes(graph, (node) => { forEachNode(graph, (node) => {
// Recursively process subgraphs first
if (node.isSubgraphNode?.() && node.subgraph) {
triggerCallbackOnAllNodes(node.subgraph, callbackProperty)
}
// Invoke callback if it exists on the node
const callback = node[callbackProperty] const callback = node[callbackProperty]
if (typeof callback === 'function') { if (typeof callback === 'function') {
callback.call(node) 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<T>(
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. * Collects all nodes in a graph hierarchy (including subgraphs) into a flat array.
* *
@@ -111,21 +157,12 @@ export function collectAllNodes(
graph: LGraph | Subgraph, graph: LGraph | Subgraph,
filter?: (node: LGraphNode) => boolean filter?: (node: LGraphNode) => boolean
): LGraphNode[] { ): LGraphNode[] {
const nodes: LGraphNode[] = [] return mapAllNodes(graph, (node) => {
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)
if (!filter || filter(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 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<T>(
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
})
}

View File

@@ -5,10 +5,15 @@ import {
collectAllNodes, collectAllNodes,
findNodeInHierarchy, findNodeInHierarchy,
findSubgraphByUuid, findSubgraphByUuid,
forEachNode,
forEachSubgraphNode,
getLocalNodeIdFromExecutionId, getLocalNodeIdFromExecutionId,
getNodeByExecutionId, getNodeByExecutionId,
getNodeByLocatorId, getNodeByLocatorId,
getRootGraph,
getSubgraphPathFromExecutionId, getSubgraphPathFromExecutionId,
mapAllNodes,
mapSubgraphNodes,
parseExecutionId, parseExecutionId,
traverseSubgraphPath, traverseSubgraphPath,
triggerCallbackOnAllNodes, 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', () => { describe('findNodeInHierarchy', () => {
it('should find node in root graph', () => { it('should find node in root graph', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
@@ -482,5 +622,140 @@ describe('graphTraversalUtil', () => {
expect(found).toBeNull() 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 }
])
})
})
}) })
}) })