feat(workflow): enhance group edge management and validation

- Introduced `createGroupInboundEdges` function to manage edges for group nodes, ensuring proper connections to head nodes.
- Updated edge creation logic to handle group nodes in both inbound and outbound scenarios, including temporary edges.
- Enhanced validation in `useWorkflow` to check connections for group nodes based on their head nodes.
- Refined edge processing in `preprocessNodesAndEdges` to ensure correct handling of source handles for group edges.
This commit is contained in:
zhsama 2026-01-05 15:48:26 +08:00
parent 75afc2dc0e
commit 60250355cb
3 changed files with 277 additions and 42 deletions

View File

@ -138,7 +138,7 @@ function createGroupEdgePair(params: {
targetHandle, targetHandle,
data: { data: {
...baseEdgeData, ...baseEdgeData,
sourceType: originalNode.data.type, // Use original node type, not group sourceType: BlockEnum.Group,
targetType: targetNode.data.type, targetType: targetNode.data.type,
_isTemp: true, _isTemp: true,
}, },
@ -148,6 +148,62 @@ function createGroupEdgePair(params: {
return { realEdge, uiEdge } return { realEdge, uiEdge }
} }
function createGroupInboundEdges(params: {
sourceNodeId: string
sourceHandle: string
groupNodeId: string
groupData: GroupNodeData
nodes: Node[]
baseEdgeData?: Partial<Edge['data']>
zIndex?: number
}): { realEdges: Edge[], uiEdge: Edge } | null {
const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params
const sourceNode = nodes.find(node => node.id === sourceNodeId)
const headNodeIds = groupData.headNodeIds || []
if (!sourceNode || headNodeIds.length === 0)
return null
const realEdges: Edge[] = headNodeIds.map((headNodeId) => {
const headNode = nodes.find(node => node.id === headNodeId)
return {
id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`,
type: CUSTOM_EDGE,
source: sourceNodeId,
sourceHandle,
target: headNodeId,
targetHandle: 'target',
hidden: true,
data: {
...baseEdgeData,
sourceType: sourceNode.data.type,
targetType: headNode?.data.type,
_hiddenInGroupId: groupNodeId,
},
zIndex,
} as Edge
})
const uiEdge: Edge = {
id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`,
type: CUSTOM_EDGE,
source: sourceNodeId,
sourceHandle,
target: groupNodeId,
targetHandle: 'target',
data: {
...baseEdgeData,
sourceType: sourceNode.data.type,
targetType: BlockEnum.Group,
_isTemp: true,
},
zIndex,
}
return { realEdges, uiEdge }
}
export const useNodesInteractions = () => { export const useNodesInteractions = () => {
const { t } = useTranslation() const { t } = useTranslation()
const store = useStoreApi() const store = useStoreApi()
@ -593,7 +649,76 @@ export const useNodesInteractions = () => {
return return
} }
// Normal edge connection (non-group source) const isTargetGroup = targetNode?.data.type === BlockEnum.Group
if (isTargetGroup && source && sourceHandle) {
const groupData = targetNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
if (edges.find(edge =>
edge.source === source
&& edge.sourceHandle === sourceHandle
&& edge.target === target
&& edge.targetHandle === targetHandle,
)) {
return
}
const parentNode = nodes.find(node => node.id === sourceNode?.parentId)
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
const inboundResult = createGroupInboundEdges({
sourceNodeId: source,
sourceHandle,
groupNodeId: target!,
groupData,
nodes,
baseEdgeData: {
isInIteration,
iteration_id: isInIteration ? sourceNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? sourceNode?.parentId : undefined,
},
})
if (!inboundResult)
return
const { realEdges, uiEdge } = inboundResult
const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge }))
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
const newEdges = produce(edges, (draft) => {
realEdges.forEach((edge) => {
draft.push(edge)
})
draft.push(uiEdge)
})
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
nodeId: headNodeIds[0],
})
return
}
if ( if (
edges.find( edges.find(
edge => edge =>
@ -1421,12 +1546,37 @@ export const useNodesInteractions = () => {
} }
} }
else { else {
// Normal case: find edge to remove const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group
const currentEdge = edges.find(
edge => edge.source === prevNodeId && edge.target === nextNodeId, if (isNextNodeGroupForRemoval) {
) const groupData = nextNode.data as GroupNodeData
if (currentEdge) const headNodeIds = groupData.headNodeIds || []
edgesToRemove.push(currentEdge.id)
headNodeIds.forEach((headNodeId) => {
const realEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === headNodeId,
)
if (realEdge)
edgesToRemove.push(realEdge.id)
})
const uiEdge = edges.find(
edge => edge.source === prevNodeId
&& edge.sourceHandle === prevNodeSourceHandle
&& edge.target === nextNodeId,
)
if (uiEdge)
edgesToRemove.push(uiEdge.id)
}
else {
const currentEdge = edges.find(
edge => edge.source === prevNodeId && edge.target === nextNodeId,
)
if (currentEdge)
edgesToRemove.push(currentEdge.id)
}
if (nodeType !== BlockEnum.DataSource) { if (nodeType !== BlockEnum.DataSource) {
newPrevEdge = { newPrevEdge = {
@ -1455,6 +1605,8 @@ export const useNodesInteractions = () => {
} }
let newNextEdge: Edge | null = null let newNextEdge: Edge | null = null
let newNextUiEdge: Edge | null = null
const newNextRealEdges: Edge[] = []
const nextNodeParentNode const nextNodeParentNode
= nodes.find(node => node.id === nextNode.parentId) || null = nodes.find(node => node.id === nextNode.parentId) || null
@ -1465,34 +1617,94 @@ export const useNodesInteractions = () => {
= !!nextNodeParentNode = !!nextNodeParentNode
&& nextNodeParentNode.data.type === BlockEnum.Loop && nextNodeParentNode.data.type === BlockEnum.Loop
const isNextNodeGroup = nextNode.data.type === BlockEnum.Group
if ( if (
nodeType !== BlockEnum.IfElse nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd && nodeType !== BlockEnum.LoopEnd
) { ) {
newNextEdge = { if (isNextNodeGroup) {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, const groupData = nextNode.data as GroupNodeData
type: CUSTOM_EDGE, const headNodeIds = groupData.headNodeIds || []
source: newNode.id,
sourceHandle, headNodeIds.forEach((headNodeId) => {
target: nextNodeId, const headNode = nodes.find(node => node.id === headNodeId)
targetHandle: nextNodeTargetHandle, newNextRealEdges.push({
data: { id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`,
sourceType: newNode.data.type, type: CUSTOM_EDGE,
targetType: nextNode.data.type, source: newNode.id,
isInIteration: isNextNodeInIteration, sourceHandle,
isInLoop: isNextNodeInLoop, target: headNodeId,
iteration_id: isNextNodeInIteration targetHandle: 'target',
? nextNode.parentId hidden: true,
: undefined, data: {
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, sourceType: newNode.data.type,
_connectedNodeIsSelected: true, targetType: headNode?.data.type,
}, isInIteration: isNextNodeInIteration,
zIndex: nextNode.parentId isInLoop: isNextNodeInLoop,
? isNextNodeInIteration iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
? ITERATION_CHILDREN_Z_INDEX loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
: LOOP_CHILDREN_Z_INDEX _hiddenInGroupId: nextNodeId,
: 0, _connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
} as Edge)
})
newNextUiEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
targetHandle: 'target',
data: {
sourceType: newNode.data.type,
targetType: BlockEnum.Group,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_isTemp: true,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
}
else {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
targetHandle: nextNodeTargetHandle,
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration
? nextNode.parentId
: undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId
? isNextNodeInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
}
} }
} }
const edgeChanges = [ const edgeChanges = [
@ -1500,6 +1712,8 @@ export const useNodesInteractions = () => {
...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []), ...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []),
...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []), ...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []),
...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []), ...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []),
...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })),
...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []),
] ]
const nodesConnectedSourceOrTargetHandleIdsMap const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap( = getNodesConnectedSourceOrTargetHandleIdsMap(
@ -1568,7 +1782,6 @@ export const useNodesInteractions = () => {
}) })
} }
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
// Remove edges by id (supports removing multiple edges for group case)
const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id)) const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id))
draft.length = 0 draft.length = 0
draft.push(...filteredDraft) draft.push(...filteredDraft)
@ -1585,6 +1798,11 @@ export const useNodesInteractions = () => {
draft.push(newPrevUiEdge) draft.push(newPrevUiEdge)
if (newNextEdge) if (newNextEdge)
draft.push(newNextEdge) draft.push(newNextEdge)
newNextRealEdges.forEach((edge) => {
draft.push(edge)
})
if (newNextUiEdge)
draft.push(newNextUiEdge)
}) })
setEdges(newEdges) setEdges(newEdges)
} }
@ -2503,15 +2721,15 @@ export const useNodesInteractions = () => {
type: edge.type || CUSTOM_EDGE, type: edge.type || CUSTOM_EDGE,
source: groupNode.id, source: groupNode.id,
target: edge.target, target: edge.target,
sourceHandle: handlerId, // handler id: nodeId-sourceHandle sourceHandle: handlerId,
targetHandle: edge.targetHandle, targetHandle: edge.targetHandle,
data: { data: {
...edge.data, ...edge.data,
sourceType: edge.data.sourceType, // Keep original node type, not group sourceType: BlockEnum.Group,
targetType: nodeTypeMap.get(edge.target)!, targetType: nodeTypeMap.get(edge.target)!,
_hiddenInGroupId: undefined, _hiddenInGroupId: undefined,
_isBundled: false, _isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend _isTemp: true,
}, },
zIndex: edge.zIndex, zIndex: edge.zIndex,
}) })

View File

@ -1,6 +1,7 @@
import type { import type {
Connection, Connection,
} from 'reactflow' } from 'reactflow'
import type { GroupNodeData } from '../nodes/group/types'
import type { IterationNodeType } from '../nodes/iteration/types' import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types' import type { LoopNodeType } from '../nodes/loop/types'
import type { import type {
@ -410,11 +411,25 @@ export const useWorkflow = () => {
const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks
const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type)) if (targetNode.data.type === BlockEnum.Group) {
return false const groupData = targetNode.data as GroupNodeData
const headNodeIds = groupData.headNodeIds || []
if (headNodeIds.length > 0) {
const headNode = nodes.find(node => node.id === headNodeIds[0])
if (headNode) {
const headNodeAvailablePrevNodes = getAvailableBlocks(headNode.data.type, !!targetNode.parentId).availablePrevBlocks
if (!headNodeAvailablePrevNodes.includes(actualSourceType))
return false
}
}
}
else {
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
return false
if (!targetNodeAvailablePrevNodes.includes(actualSourceType)) if (!targetNodeAvailablePrevNodes.includes(actualSourceType))
return false return false
}
} }
const hasCycle = (node: Node, visited = new Set()) => { const hasCycle = (node: Node, visited = new Set()) => {

View File

@ -269,14 +269,15 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
// Inbound edge: source outside group, target is a head node // Inbound edge: source outside group, target is a head node
// Use Set to dedupe since multiple head nodes may share same external source // Use Set to dedupe since multiple head nodes may share same external source
if (!memberSet.has(edge.source) && headSet.has(edge.target)) { if (!memberSet.has(edge.source) && headSet.has(edge.target)) {
const edgeId = `${edge.source}-${edge.sourceHandle}-${groupNode.id}-target` const sourceHandle = edge.sourceHandle || 'source'
const edgeId = `${edge.source}-${sourceHandle}-${groupNode.id}-target`
if (!inboundEdgeIds.has(edgeId)) { if (!inboundEdgeIds.has(edgeId)) {
inboundEdgeIds.add(edgeId) inboundEdgeIds.add(edgeId)
groupTempEdges.push({ groupTempEdges.push({
id: edgeId, id: edgeId,
type: 'custom', type: 'custom',
source: edge.source, source: edge.source,
sourceHandle: edge.sourceHandle, sourceHandle,
target: groupNode.id, target: groupNode.id,
targetHandle: 'target', targetHandle: 'target',
data: { data: {
@ -290,8 +291,9 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
// Outbound edge: source is a leaf node, target outside group // Outbound edge: source is a leaf node, target outside group
if (leafSet.has(edge.source) && !memberSet.has(edge.target)) { if (leafSet.has(edge.source) && !memberSet.has(edge.target)) {
const edgeSourceHandle = edge.sourceHandle || 'source'
const handler = handlers.find( const handler = handlers.find(
h => h.nodeId === edge.source && h.sourceHandle === edge.sourceHandle, h => h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
) )
if (handler) { if (handler) {
groupTempEdges.push({ groupTempEdges.push({