mirror of https://github.com/langgenius/dify.git
feat: Enhance CustomGroupNode with exit ports and visual indicators
This commit is contained in:
parent
16bff9e82f
commit
3d61496d25
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
Loading…
Reference in New Issue