feat: Enhance CustomGroupNode with exit ports and visual indicators

This commit is contained in:
zhsama 2025-12-23 15:36:53 +08:00
parent 16bff9e82f
commit 3d61496d25
6 changed files with 104 additions and 13 deletions

View File

@ -3,6 +3,7 @@
import type { FC } from 'react'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
import type { CustomGroupNodeData } from './types'
import { cn } from '@/utils/classnames'
@ -11,13 +12,15 @@ type CustomGroupNodeProps = {
data: CustomGroupNodeData
}
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ id: _id, data }) => {
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ data }) => {
const { group } = data
const exitPorts = group.exitPorts ?? []
const connectedSourceHandleIds = data._connectedSourceHandleIds ?? []
return (
<div
className={cn(
'bg-workflow-block-parma-bg/50 relative rounded-2xl border-2 border-dashed border-components-panel-border',
'bg-workflow-block-parma-bg/50 group relative rounded-2xl border-2 border-dashed border-components-panel-border',
data.selected && 'border-primary-400',
)}
style={{
@ -37,11 +40,53 @@ const CustomGroupNode: FC<CustomGroupNodeProps> = ({ id: _id, data }) => {
id="target"
type="target"
position={Position.Left}
className="!h-4 !w-4 !border-2 !border-white !bg-primary-500"
className={cn(
'!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
)}
style={{ top: '50%' }}
/>
{/* Source handles will be rendered by exit port nodes */}
<div className="px-3 pt-3">
{exitPorts.map((port, index) => {
const connected = connectedSourceHandleIds.includes(port.portNodeId)
return (
<div key={port.portNodeId} className="relative flex h-6 items-center px-1">
<div className="w-full text-right text-xs font-semibold text-text-secondary">
{port.name}
</div>
<Handle
id={port.portNodeId}
type="source"
position={Position.Right}
className={cn(
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
!connected && 'after:opacity-0',
'!-right-[21px] !top-1/2 !-translate-y-1/2',
)}
isConnectable
/>
{/* Visual "+" indicator (styling aligned with existing branch handles) */}
<div
className={cn(
'pointer-events-none absolute z-10 hidden h-4 w-4 items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface',
'-right-[21px] top-1/2 -translate-y-1/2',
'group-hover:flex',
data.selected && '!flex',
)}
>
<Plus02 className="h-2.5 w-2.5" />
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -19,6 +19,8 @@ export type CustomGroupNodeData = {
type: '' // Empty string bypasses backend NodeType validation
title: string
desc?: string
_connectedSourceHandleIds?: string[]
_connectedTargetHandleIds?: string[]
group: {
groupId: string
title: string

View File

@ -2063,14 +2063,43 @@ export const useNodesInteractions = () => {
label: node.data.title,
}
})
const handlers: GroupHandler[] = leafNodeIds.map((nodeId) => {
// Build handlers from all leaf nodes
// For multi-branch nodes (if-else, classifier), create one handler per branch
// For regular nodes, create one handler with 'source' handle
const handlerMap = new Map<string, GroupHandler>()
leafNodeIds.forEach((nodeId) => {
const node = bundledNodes.find(n => n.id === nodeId)
return {
id: nodeId,
label: node?.data.title || nodeId,
if (!node)
return
const targetBranches = node.data._targetBranches
if (targetBranches && targetBranches.length > 0) {
// Multi-branch node: create handler for each branch
targetBranches.forEach((branch: { id: string; name?: string }) => {
const handlerId = `${nodeId}-${branch.id}`
handlerMap.set(handlerId, {
id: handlerId,
label: branch.name || node.data.title || nodeId,
nodeId,
sourceHandle: branch.id,
})
})
}
else {
// Regular node: single 'source' handler
const handlerId = `${nodeId}-source`
handlerMap.set(handlerId, {
id: handlerId,
label: node.data.title || nodeId,
nodeId,
sourceHandle: 'source',
})
}
})
const handlers: GroupHandler[] = Array.from(handlerMap.values())
// put the group node at the top-left corner of the selection, slightly offset
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
@ -2123,7 +2152,7 @@ export const useNodesInteractions = () => {
}
})
// re-add the external inbound edges to the group node (previous order is not lost)
// re-add the external inbound edges to the group node as UI-only edges (not persisted to backend)
inboundEdges.forEach((edge) => {
draft.push({
id: `${edge.id}__to-${groupNode.id}`,
@ -2138,22 +2167,27 @@ export const useNodesInteractions = () => {
targetType: BlockEnum.Group,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend
},
zIndex: edge.zIndex,
})
})
// outbound edges of the group node: only map the outbound edges of the leaf nodes to the corresponding handlers
// outbound edges of the group node as UI-only edges (not persisted to backend)
outboundEdges.forEach((edge) => {
if (!bundledNodeIdIsLeaf.has(edge.source))
return
// Use the same handler id format: nodeId-sourceHandle
const originalSourceHandle = edge.sourceHandle || 'source'
const handlerId = `${edge.source}-${originalSourceHandle}`
draft.push({
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${edge.source}`,
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`,
type: edge.type || CUSTOM_EDGE,
source: groupNode.id,
target: edge.target,
sourceHandle: edge.source, // handler id corresponds to the leaf node id
sourceHandle: handlerId, // handler id: nodeId-sourceHandle
targetHandle: edge.targetHandle,
data: {
...edge.data,
@ -2161,6 +2195,7 @@ export const useNodesInteractions = () => {
targetType: nodeTypeMap.get(edge.target)!,
_hiddenInGroupId: undefined,
_isBundled: false,
_isTemp: true, // UI-only edge, not persisted to backend
},
zIndex: edge.zIndex,
})

View File

@ -221,7 +221,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
}
{
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && (
<NodeSourceHandle
id={id}
data={data}

View File

@ -1,6 +1,7 @@
import { memo, useMemo } from 'react'
import { RiArrowRightSLine } from '@remixicon/react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { NodeSourceHandle } from '../_base/components/node-handle'
import type { NodeProps } from '@/app/components/workflow/types'
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
import type { BlockEnum } from '@/app/components/workflow/types'
@ -68,11 +69,17 @@ const GroupNode = (props: NodeProps<GroupNodeData>) => {
<div
key={handler.id}
className={cn(
'relative',
'system-sm-semibold uppercase',
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
)}
>
{handler.label || handler.id}
<NodeSourceHandle
{...props}
handleId={handler.id}
handleClassName='!top-1/2 !-translate-y-1/2 !-right-[21px]'
/>
</div>
))}
</div>

View File

@ -9,6 +9,8 @@ export type GroupMember = {
export type GroupHandler = {
id: string
label?: string
nodeId?: string // leaf node id for multi-branch nodes
sourceHandle?: string // original sourceHandle (e.g., case_id for if-else)
}
export type GroupNodeData = CommonNodeType<{