mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(workflow-renderer): extract pure WorkflowBlockView + SubBlockRowView (#5267)
* feat(workflow-renderer): extract pure SubBlockRowView from the block's summary row Splits the canvas block's collapsed subblock summary row into a pure SubBlockRowView (title + resolved displayValue + monospace flag) in @sim/workflow-renderer. The ~9 selector-name hydration hooks stay in the SubBlockRow container behind its memo comparator; the view receives only resolved strings. Byte-identical row JSX. First step toward the full WorkflowBlockView. * feat(workflow-renderer): extract pure WorkflowBlockView shell Moves the canvas block's render (header, badges, dynamic handles, ring) into a pure WorkflowBlockView in @sim/workflow-renderer. WorkflowBlock becomes a thin container that resolves all stores/hooks/permissions, builds the subblock rows + actionBar slots, and binds wouldCreateConnectionCycle (reads the edge store fresh per call to preserve cycle prevention). getHandleClasses/getHandleStyle move into the view; config.icon/bgColor, the webhook provider name, and every visual flag cross as props. Byte-identical JSX — every handle id/class/style/offset and badge guard preserved (verified by an independent adversarial audit). Container drops from 1137 to 829 lines.
This commit is contained in:
+128
-436
@@ -1,10 +1,9 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
|
||||
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { type NodeProps, useUpdateNodeInternals } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
@@ -371,25 +370,7 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{displayValue !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate text-right text-[var(--text-primary)] text-sm',
|
||||
isMonospaceField && 'font-mono'
|
||||
)}
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SubBlockRowView title={title} displayValue={displayValue} isMonospace={isMonospaceField} />
|
||||
)
|
||||
}, areSubBlockRowPropsEqual)
|
||||
|
||||
@@ -630,32 +611,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
|
||||
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
|
||||
const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
|
||||
top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
|
||||
bottom:
|
||||
'!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = (position: 'horizontal' | 'vertical') => {
|
||||
if (position === 'horizontal') {
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
return { left: '50%', transform: 'translateX(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-condition rows (title/value/id) for condition blocks so we can render
|
||||
* one row per condition statement with its own output handle.
|
||||
@@ -739,399 +694,136 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
|
||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
ref={contentRef}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, handleClick)}
|
||||
className={cn(
|
||||
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-amber-500 px-2 py-0.5 text-white text-xs'>
|
||||
Next Step
|
||||
</div>
|
||||
)}
|
||||
const wouldCreateConnectionCycle = (source: string, target: string) =>
|
||||
wouldCreateCycle(useWorkflowStore.getState().edges, source, target)
|
||||
|
||||
{!data.isPreview && !data.isEmbedded && (
|
||||
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
|
||||
)}
|
||||
const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={horizontalHandles ? Position.Left : Position.Top}
|
||||
id='target'
|
||||
className={getHandleClasses(horizontalHandles ? 'left' : 'top')}
|
||||
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
|
||||
data-nodeid={id}
|
||||
data-handleid='target'
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={true}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.source === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
const rows = (
|
||||
<>
|
||||
{type === 'condition' ? (
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(subBlockState.context?.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2',
|
||||
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
|
||||
style={{
|
||||
background: isEnabled ? config.bgColor : 'gray',
|
||||
}}
|
||||
>
|
||||
<config.icon className='size-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && runPathStatus !== 'success' && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
|
||||
{isWorkflowSelector &&
|
||||
childWorkflowId &&
|
||||
typeof childIsDeployed === 'boolean' &&
|
||||
(!childIsDeployed || childNeedsRedeploy) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant={!childIsDeployed ? 'red' : 'amber'}
|
||||
className={userPermissions.canAdmin ? 'cursor-pointer' : 'cursor-not-allowed'}
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
|
||||
deployChildWorkflow({ workflowId: childWorkflowId })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>
|
||||
{!userPermissions.canAdmin
|
||||
? 'Admin permission required to deploy'
|
||||
: !childIsDeployed
|
||||
? 'Click to deploy'
|
||||
: 'Click to redeploy'}
|
||||
</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
|
||||
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant='amber'
|
||||
className='cursor-pointer'
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (scheduleInfo?.id) {
|
||||
reactivateSchedule(scheduleInfo.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>Click to reactivate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{showWebhookIndicator && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge variant='orange' dot>
|
||||
Webhook
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px]'>
|
||||
{webhookProvider && webhookPath ? (
|
||||
<>
|
||||
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>Path: {webhookPath}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
This workflow is triggered by a webhook.
|
||||
</p>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isWebhookConfigured && isWebhookDisabled && webhookId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant='amber'
|
||||
className='cursor-pointer'
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
reactivateWebhook(webhookId)
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>Click to reactivate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* {isActive && (
|
||||
<div className='mr-0.5 ml-2 flex size-[16px] items-center justify-center'>
|
||||
<div
|
||||
className='h-full w-full animate-spin-slow rounded-full border-[2.5px] border-[rgba(255,102,0,0.25)] border-t-[var(--warning)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{type === 'condition' ? (
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(subBlockState.context?.value)}
|
||||
/>
|
||||
{routerRows.map((route, index) => (
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
subBlockRows.map((row, rowIndex) =>
|
||||
row.flatMap((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
if (subBlock.type === 'mcp-dynamic-args') {
|
||||
const schema = subBlockState._toolSchema?.value as
|
||||
| { properties?: Record<string, unknown> }
|
||||
| undefined
|
||||
const properties = schema?.properties
|
||||
if (properties && typeof properties === 'object') {
|
||||
const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
return Object.keys(properties).map((paramName) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
key={`${subBlock.id}-${paramName}-${rowIndex}`}
|
||||
title={formatParameterLabel(paramName)}
|
||||
value={getDisplayValue(args[paramName])}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
subBlockRows.map((row, rowIndex) =>
|
||||
row.flatMap((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
if (subBlock.type === 'mcp-dynamic-args') {
|
||||
const schema = subBlockState._toolSchema?.value as
|
||||
| { properties?: Record<string, unknown> }
|
||||
| undefined
|
||||
const properties = schema?.properties
|
||||
if (properties && typeof properties === 'object') {
|
||||
const args = (
|
||||
rawValue && typeof rawValue === 'object' ? rawValue : {}
|
||||
) as Record<string, unknown>
|
||||
return Object.keys(properties).map((paramName) => (
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${paramName}-${rowIndex}`}
|
||||
title={formatParameterLabel(paramName)}
|
||||
value={getDisplayValue(args[paramName])}
|
||||
/>
|
||||
))
|
||||
}
|
||||
return []
|
||||
}
|
||||
return [
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
displayAdvancedOptions={effectiveAdvanced}
|
||||
canonicalIndex={canonicalIndex}
|
||||
canonicalModeOverrides={canonicalModeOverrides}
|
||||
/>,
|
||||
]
|
||||
})
|
||||
)
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
}
|
||||
return []
|
||||
}
|
||||
return [
|
||||
<SubBlockRow
|
||||
key={`${subBlock.id}-${rowIndex}`}
|
||||
title={subBlock.title ?? subBlock.id}
|
||||
value={getDisplayValue(rawValue)}
|
||||
subBlock={subBlock}
|
||||
rawValue={rawValue}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={currentWorkflowId}
|
||||
blockId={id}
|
||||
allSubBlockValues={subBlockState}
|
||||
displayAdvancedOptions={effectiveAdvanced}
|
||||
canonicalIndex={canonicalIndex}
|
||||
canonicalModeOverrides={canonicalModeOverrides}
|
||||
/>,
|
||||
]
|
||||
})
|
||||
)
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</>
|
||||
)
|
||||
|
||||
{type === 'condition' && (
|
||||
<>
|
||||
{conditionRows.map((cond, condIndex) => {
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${cond.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`condition-${cond.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`condition-${cond.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'router_v2' && (
|
||||
<>
|
||||
{routerRows.map((route, routeIndex) => {
|
||||
// +1 row offset for context row at the top
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${route.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`router-${route.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`router-${route.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={getHandleClasses(horizontalHandles ? 'right' : 'bottom')}
|
||||
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
|
||||
data-nodeid={id}
|
||||
data-handleid='source'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
return !wouldCreateCycle(edges, connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasRing && (
|
||||
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<WorkflowBlockView
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
isPending={isPending}
|
||||
isEnabled={isEnabled}
|
||||
isLocked={isLocked}
|
||||
hasRing={hasRing}
|
||||
ringStyles={ringStyles}
|
||||
runPathStatus={runPathStatus}
|
||||
Icon={config.icon}
|
||||
iconBgColor={config.bgColor}
|
||||
horizontalHandles={horizontalHandles}
|
||||
shouldShowDefaultHandles={shouldShowDefaultHandles}
|
||||
hasContentBelowHeader={hasContentBelowHeader}
|
||||
conditionRows={conditionRows}
|
||||
routerRows={routerRows}
|
||||
wouldCreateConnectionCycle={wouldCreateConnectionCycle}
|
||||
isWorkflowSelector={isWorkflowSelector}
|
||||
childWorkflowId={childWorkflowId}
|
||||
childIsDeployed={childIsDeployed}
|
||||
childNeedsRedeploy={childNeedsRedeploy}
|
||||
isDeploying={isDeploying}
|
||||
canAdmin={userPermissions.canAdmin}
|
||||
onDeployChild={() => {
|
||||
if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
|
||||
deployChildWorkflow({ workflowId: childWorkflowId })
|
||||
}
|
||||
}}
|
||||
shouldShowScheduleBadge={shouldShowScheduleBadge}
|
||||
scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)}
|
||||
onReactivateSchedule={() => {
|
||||
if (scheduleInfo?.id) {
|
||||
reactivateSchedule(scheduleInfo.id)
|
||||
}
|
||||
}}
|
||||
showWebhookIndicator={showWebhookIndicator}
|
||||
webhookProvider={webhookProvider}
|
||||
webhookPath={webhookPath}
|
||||
webhookProviderName={webhookProviderName}
|
||||
isWebhookConfigured={isWebhookConfigured}
|
||||
isWebhookDisabled={isWebhookDisabled}
|
||||
webhookId={webhookId}
|
||||
onReactivateWebhook={() => {
|
||||
if (webhookId) {
|
||||
reactivateWebhook(webhookId)
|
||||
}
|
||||
}}
|
||||
onSelect={handleClick}
|
||||
contentRef={contentRef}
|
||||
actionBar={
|
||||
!data.isPreview && !data.isEmbedded ? (
|
||||
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
|
||||
) : undefined
|
||||
}
|
||||
rows={rows}
|
||||
/>
|
||||
)
|
||||
}, shouldSkipBlockRender)
|
||||
|
||||
@@ -7,3 +7,8 @@ export {
|
||||
type SubflowNodeViewProps,
|
||||
} from './subflow/subflow-node-view'
|
||||
export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
|
||||
export { SubBlockRowView, type SubBlockRowViewProps } from './workflow-block/sub-block-row-view'
|
||||
export {
|
||||
WorkflowBlockView,
|
||||
type WorkflowBlockViewProps,
|
||||
} from './workflow-block/workflow-block-view'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
/**
|
||||
* Props for the pure subblock summary row. The container resolves the value —
|
||||
* including all selector-name hydration (credentials, knowledge bases, tables,
|
||||
* MCP servers/tools, sub-workflows, skills, …) — and passes only the final
|
||||
* strings, so this view carries no store, query, or registry coupling.
|
||||
*/
|
||||
export interface SubBlockRowViewProps {
|
||||
/** Subblock label, rendered capitalized on the left. */
|
||||
title: string
|
||||
/** Resolved display value on the right; `undefined` hides the value span. */
|
||||
displayValue?: string
|
||||
/** Render the value in a monospace font (e.g. filter expressions). */
|
||||
isMonospace?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer for a collapsed block's subblock summary row: a capitalized
|
||||
* title and its resolved display value.
|
||||
*/
|
||||
export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{displayValue !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate text-right text-[var(--text-primary)] text-sm',
|
||||
isMonospace && 'font-mono'
|
||||
)}
|
||||
title={displayValue}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { ComponentType, ReactNode, Ref } from 'react'
|
||||
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '../dimensions'
|
||||
import type { BlockRunStatus } from '../types'
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
|
||||
const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
|
||||
top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
|
||||
bottom:
|
||||
'!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = (position: 'horizontal' | 'vertical') => {
|
||||
if (position === 'horizontal') {
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
return { left: '50%', transform: 'translateX(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the pure workflow-block renderer.
|
||||
*
|
||||
* Presentation comes from the editor (or docs) container: visual flags
|
||||
* (enabled/locked/pending/ring), handle topology (condition/router rows), and
|
||||
* the resolved badge state (child-deploy, schedule, webhook) are all computed
|
||||
* upstream and passed in. The block icon, content rows, and editor-only action
|
||||
* bar are injected as slots so the pure renderer carries no store, query, or
|
||||
* registry coupling.
|
||||
*/
|
||||
export interface WorkflowBlockViewProps {
|
||||
/** Block identity and visual state, resolved by the container. */
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
isPending?: boolean
|
||||
isEnabled: boolean
|
||||
isLocked: boolean
|
||||
hasRing: boolean
|
||||
ringStyles: string
|
||||
/** Resolved run-path outcome, drives the muted-name styling. */
|
||||
runPathStatus?: BlockRunStatus
|
||||
/** Block icon component and its background color. */
|
||||
Icon: ComponentType<{ className?: string }>
|
||||
iconBgColor: string
|
||||
|
||||
/** Handle orientation and topology, resolved by the container. */
|
||||
horizontalHandles: boolean
|
||||
shouldShowDefaultHandles: boolean
|
||||
hasContentBelowHeader: boolean
|
||||
conditionRows: { id: string; title: string; value: string }[]
|
||||
routerRows: { id: string; value: string }[]
|
||||
/** Connection-cycle guard; reads fresh edge state on every call. */
|
||||
wouldCreateConnectionCycle: (source: string, target: string) => boolean
|
||||
|
||||
/** Child-workflow deploy badge state. */
|
||||
isWorkflowSelector: boolean
|
||||
childWorkflowId?: string
|
||||
childIsDeployed: boolean | null
|
||||
childNeedsRedeploy: boolean
|
||||
isDeploying: boolean
|
||||
canAdmin: boolean
|
||||
onDeployChild: () => void
|
||||
|
||||
/** Schedule badge state. */
|
||||
shouldShowScheduleBadge: boolean
|
||||
scheduleIsDisabled: boolean
|
||||
onReactivateSchedule: () => void
|
||||
|
||||
/** Webhook badge state. */
|
||||
showWebhookIndicator: boolean
|
||||
webhookProvider?: string
|
||||
webhookPath?: string
|
||||
webhookProviderName?: string
|
||||
isWebhookConfigured: boolean
|
||||
isWebhookDisabled: boolean
|
||||
webhookId?: string
|
||||
onReactivateWebhook: () => void
|
||||
|
||||
/** Selects this block in the editor panel. */
|
||||
onSelect: () => void
|
||||
/** Ref attached to the inner content container. */
|
||||
contentRef?: Ref<HTMLDivElement>
|
||||
/** Editor-only action bar; omit in read-only / preview contexts. */
|
||||
actionBar?: ReactNode
|
||||
/** Collapsed subblock summary rows, built by the container. */
|
||||
rows: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure renderer for a workflow block: a header (icon, name, status badges), an
|
||||
* optional content section of collapsed subblock rows, and the full handle
|
||||
* topology (default/condition/router/error connection handles).
|
||||
*/
|
||||
export function WorkflowBlockView({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
isPending,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
runPathStatus,
|
||||
Icon,
|
||||
iconBgColor,
|
||||
horizontalHandles,
|
||||
shouldShowDefaultHandles,
|
||||
hasContentBelowHeader,
|
||||
conditionRows,
|
||||
routerRows,
|
||||
wouldCreateConnectionCycle,
|
||||
isWorkflowSelector,
|
||||
childWorkflowId,
|
||||
childIsDeployed,
|
||||
childNeedsRedeploy,
|
||||
isDeploying,
|
||||
canAdmin,
|
||||
onDeployChild,
|
||||
shouldShowScheduleBadge,
|
||||
scheduleIsDisabled,
|
||||
onReactivateSchedule,
|
||||
showWebhookIndicator,
|
||||
webhookProvider,
|
||||
webhookPath,
|
||||
webhookProviderName,
|
||||
isWebhookConfigured,
|
||||
isWebhookDisabled,
|
||||
webhookId,
|
||||
onReactivateWebhook,
|
||||
onSelect,
|
||||
contentRef,
|
||||
actionBar,
|
||||
rows,
|
||||
}: WorkflowBlockViewProps) {
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div
|
||||
ref={contentRef}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
|
||||
className={cn(
|
||||
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-amber-500 px-2 py-0.5 text-white text-xs'>
|
||||
Next Step
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionBar}
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={horizontalHandles ? Position.Left : Position.Top}
|
||||
id='target'
|
||||
className={getHandleClasses(horizontalHandles ? 'left' : 'top')}
|
||||
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
|
||||
data-nodeid={id}
|
||||
data-handleid='target'
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={true}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.source === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2',
|
||||
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
|
||||
style={{
|
||||
background: isEnabled ? iconBgColor : 'gray',
|
||||
}}
|
||||
>
|
||||
<Icon className='size-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-md',
|
||||
!isEnabled && runPathStatus !== 'success' && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
|
||||
{isWorkflowSelector &&
|
||||
childWorkflowId &&
|
||||
typeof childIsDeployed === 'boolean' &&
|
||||
(!childIsDeployed || childNeedsRedeploy) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant={!childIsDeployed ? 'red' : 'amber'}
|
||||
className={canAdmin ? 'cursor-pointer' : 'cursor-not-allowed'}
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeployChild()
|
||||
}}
|
||||
>
|
||||
{isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>
|
||||
{!canAdmin
|
||||
? 'Admin permission required to deploy'
|
||||
: !childIsDeployed
|
||||
? 'Click to deploy'
|
||||
: 'Click to redeploy'}
|
||||
</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
|
||||
{type === 'schedule' && shouldShowScheduleBadge && scheduleIsDisabled && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant='amber'
|
||||
className='cursor-pointer'
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReactivateSchedule()
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>Click to reactivate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{showWebhookIndicator && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge variant='orange' dot>
|
||||
Webhook
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px]'>
|
||||
{webhookProvider && webhookPath ? (
|
||||
<>
|
||||
<p className='text-sm'>{webhookProviderName} Webhook</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>Path: {webhookPath}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
This workflow is triggered by a webhook.
|
||||
</p>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isWebhookConfigured && isWebhookDisabled && webhookId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant='amber'
|
||||
className='cursor-pointer'
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReactivateWebhook()
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>Click to reactivate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* {isActive && (
|
||||
<div className='mr-0.5 ml-2 flex size-[16px] items-center justify-center'>
|
||||
<div
|
||||
className='h-full w-full animate-spin-slow rounded-full border-[2.5px] border-[rgba(255,102,0,0.25)] border-t-[var(--warning)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContentBelowHeader && <div className='flex flex-col gap-2 p-2'>{rows}</div>}
|
||||
|
||||
{type === 'condition' && (
|
||||
<>
|
||||
{conditionRows.map((cond, condIndex) => {
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${cond.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`condition-${cond.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`condition-${cond.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'router_v2' && (
|
||||
<>
|
||||
{routerRows.map((route, routeIndex) => {
|
||||
// +1 row offset for context row at the top
|
||||
const topOffset =
|
||||
HANDLE_POSITIONS.CONDITION_START_Y +
|
||||
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
|
||||
return (
|
||||
<Handle
|
||||
key={`handle-${route.id}`}
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={`router-${route.id}`}
|
||||
className={getHandleClasses('right')}
|
||||
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
data-nodeid={id}
|
||||
data-handleid={`router-${route.id}`}
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={getHandleClasses(horizontalHandles ? 'right' : 'bottom')}
|
||||
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
|
||||
data-nodeid={id}
|
||||
data-handleid='source'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={getHandleClasses('right', true)}
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
data-nodeid={id}
|
||||
data-handleid='error'
|
||||
isConnectableStart={true}
|
||||
isConnectableEnd={false}
|
||||
isValidConnection={(connection) => {
|
||||
if (connection.target === id) return false
|
||||
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasRing && (
|
||||
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user