mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-27 07:48:22 -05:00
improvement(preview): consolidate block rendering and fix handle configurations (#3013)
* improvement(preview): consolidate block rendering and fix handle configurations * refactor(preview): extract SubflowContainerProps interface
This commit is contained in:
@@ -207,6 +207,7 @@ function TemplateCardInner({
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[var(--surface-4)]' />
|
||||
|
||||
@@ -214,6 +214,7 @@ function TemplateCardInner({
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
cursorStyle='pointer'
|
||||
lightweight
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
|
||||
|
||||
@@ -446,6 +446,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -466,6 +466,7 @@ export function Editor() {
|
||||
defaultZoom={0.6}
|
||||
fitPadding={0.15}
|
||||
cursorStyle='grab'
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
@@ -10,14 +11,12 @@ import {
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import type { ExecutionStatus } from '@/app/workspace/[workspaceId]/w/components/preview/preview'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/** Execution status for blocks in preview mode */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
/** Subblock value structure matching workflow state */
|
||||
interface SubBlockValueEntry {
|
||||
value: unknown
|
||||
@@ -35,6 +34,16 @@ interface WorkflowPreviewBlockData {
|
||||
executionStatus?: ExecutionStatus
|
||||
/** Subblock values from the workflow state */
|
||||
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
|
||||
/** Skips expensive subblock computations for thumbnails */
|
||||
lightweight?: boolean
|
||||
/** Whether this is a subflow container (loop/parallel) */
|
||||
isSubflow?: boolean
|
||||
/** Type of subflow container */
|
||||
subflowKind?: 'loop' | 'parallel'
|
||||
/** Width of subflow container */
|
||||
width?: number
|
||||
/** Height of subflow container */
|
||||
height?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,21 +213,16 @@ function resolveToolsDisplay(
|
||||
* - Shows '-' for other selector types that need hydration
|
||||
*/
|
||||
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
|
||||
// Mask password fields
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
|
||||
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
|
||||
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
|
||||
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
|
||||
const workflowName = resolveWorkflowName(subBlock, rawValue)
|
||||
|
||||
// Check if this is a selector type that needs hydration (show '-' for raw IDs)
|
||||
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
|
||||
|
||||
// Compute final display value matching WorkflowBlock logic
|
||||
// Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value
|
||||
const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName
|
||||
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
|
||||
|
||||
@@ -242,11 +246,115 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface SubflowContainerProps {
|
||||
name: string
|
||||
width?: number
|
||||
height?: number
|
||||
kind: 'loop' | 'parallel'
|
||||
isPreviewSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a subflow container (loop/parallel) for preview mode.
|
||||
*/
|
||||
function SubflowContainer({
|
||||
name,
|
||||
width = 500,
|
||||
height = 300,
|
||||
kind,
|
||||
isPreviewSelected = false,
|
||||
}: SubflowContainerProps) {
|
||||
const isLoop = kind === 'loop'
|
||||
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
|
||||
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
|
||||
|
||||
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
|
||||
|
||||
const leftHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
|
||||
const rightHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
style={{ width, height }}
|
||||
>
|
||||
{/* Selection ring overlay */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
)}
|
||||
|
||||
{/* Target handle on left (input to the subflow) */}
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={leftHandleClass}
|
||||
style={{
|
||||
left: '-8px',
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header - matches actual subflow header structure */}
|
||||
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area - matches workflow structure */}
|
||||
<div
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
{/* Subflow Start - connects to first block in subflow */}
|
||||
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End source handle on right (output from the subflow) */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={endHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{
|
||||
right: '-8px',
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview block component for workflow visualization.
|
||||
* Renders block header, subblock values, and handles without
|
||||
* hooks, store subscriptions, or interactive features.
|
||||
* Matches the visual structure of WorkflowBlock exactly.
|
||||
* Also handles subflow containers (loop/parallel) when isSubflow is true.
|
||||
*/
|
||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||
const {
|
||||
@@ -258,53 +366,84 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
isPreviewSelected = false,
|
||||
executionStatus,
|
||||
subBlockValues,
|
||||
lightweight = false,
|
||||
isSubflow = false,
|
||||
subflowKind,
|
||||
width,
|
||||
height,
|
||||
} = data
|
||||
|
||||
if (isSubflow && subflowKind) {
|
||||
return (
|
||||
<SubflowContainer
|
||||
name={name}
|
||||
width={width}
|
||||
height={height}
|
||||
kind={subflowKind}
|
||||
isPreviewSelected={isPreviewSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
() =>
|
||||
lightweight
|
||||
? { groupsById: {}, canonicalIdBySubBlockId: {} }
|
||||
: buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks, lightweight]
|
||||
)
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (!subBlockValues) return {}
|
||||
if (lightweight || !subBlockValues) return {}
|
||||
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
|
||||
acc[key] = extractValue(entry)
|
||||
return acc
|
||||
}, {})
|
||||
}, [subBlockValues])
|
||||
}, [subBlockValues, lightweight])
|
||||
|
||||
const visibleSubBlocks = useMemo(() => {
|
||||
if (!blockConfig?.subBlocks) return []
|
||||
if (lightweight || !blockConfig?.subBlocks) return []
|
||||
|
||||
const isStarterOrTrigger =
|
||||
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
|
||||
|
||||
const effectiveTrigger = isTrigger || type === 'starter'
|
||||
|
||||
return blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden) return false
|
||||
if (subBlock.hideFromPreview) return false
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
|
||||
// Handle trigger mode visibility
|
||||
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
|
||||
if (effectiveTrigger) {
|
||||
const isValidTriggerSubblock = isPureTriggerBlock
|
||||
? subBlock.mode === 'trigger' || !subBlock.mode
|
||||
: subBlock.mode === 'trigger'
|
||||
if (!isValidTriggerSubblock) return false
|
||||
} else {
|
||||
if (subBlock.mode === 'trigger') return false
|
||||
}
|
||||
|
||||
// Check advanced mode visibility
|
||||
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check condition visibility
|
||||
if (!subBlock.condition) return true
|
||||
return evaluateSubBlockCondition(subBlock.condition, rawValues)
|
||||
})
|
||||
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues])
|
||||
}, [
|
||||
lightweight,
|
||||
blockConfig?.subBlocks,
|
||||
blockConfig?.category,
|
||||
blockConfig?.triggers?.enabled,
|
||||
type,
|
||||
isTrigger,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
])
|
||||
|
||||
/**
|
||||
* Compute condition rows for condition blocks
|
||||
*/
|
||||
const conditionRows = useMemo(() => {
|
||||
if (type !== 'condition') return []
|
||||
if (lightweight || type !== 'condition') return []
|
||||
|
||||
const conditionsValue = rawValues.conditions
|
||||
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
|
||||
@@ -332,13 +471,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
{ id: 'if', title: 'if', value: '' },
|
||||
{ id: 'else', title: 'else', value: '' },
|
||||
]
|
||||
}, [type, rawValues])
|
||||
}, [lightweight, type, rawValues])
|
||||
|
||||
/**
|
||||
* Compute router rows for router_v2 blocks
|
||||
*/
|
||||
const routerRows = useMemo(() => {
|
||||
if (type !== 'router_v2') return []
|
||||
if (lightweight || type !== 'router_v2') return []
|
||||
|
||||
const routesValue = rawValues.routes
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
@@ -361,7 +497,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
}
|
||||
|
||||
return [{ id: 'route1', value: '' }]
|
||||
}, [type, rawValues])
|
||||
}, [lightweight, type, rawValues])
|
||||
|
||||
if (!blockConfig) {
|
||||
return null
|
||||
@@ -439,12 +575,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
{type === 'condition' ? (
|
||||
// Condition block: render condition rows
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
// Router block: render context + route rows
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
@@ -460,7 +594,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Standard blocks: render visible subblocks
|
||||
visibleSubBlocks.map((subBlock) => {
|
||||
const rawValue = rawValues[subBlock.id]
|
||||
return (
|
||||
@@ -479,18 +612,102 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source handle */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
|
||||
style={
|
||||
horizontalHandles
|
||||
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
|
||||
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
|
||||
}
|
||||
/>
|
||||
{/* Condition block handles - one per condition branch + 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={horizontalHandleClass}
|
||||
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Router block handles - one per route + error */}
|
||||
{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={horizontalHandleClass}
|
||||
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Standard block handles - source + error (not for condition, router, or response) */}
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
position={horizontalHandles ? Position.Right : Position.Bottom}
|
||||
id='source'
|
||||
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
|
||||
style={
|
||||
horizontalHandles
|
||||
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
|
||||
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
|
||||
}
|
||||
/>
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: 'auto',
|
||||
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -499,7 +716,6 @@ function shouldSkipPreviewBlockRender(
|
||||
prevProps: NodeProps<WorkflowPreviewBlockData>,
|
||||
nextProps: NodeProps<WorkflowPreviewBlockData>
|
||||
): boolean {
|
||||
// Check primitive props first (fast path)
|
||||
if (
|
||||
prevProps.id !== nextProps.id ||
|
||||
prevProps.data.type !== nextProps.data.type ||
|
||||
@@ -508,12 +724,16 @@ function shouldSkipPreviewBlockRender(
|
||||
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
|
||||
prevProps.data.enabled !== nextProps.data.enabled ||
|
||||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
|
||||
prevProps.data.executionStatus !== nextProps.data.executionStatus
|
||||
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
|
||||
prevProps.data.lightweight !== nextProps.data.lightweight ||
|
||||
prevProps.data.isSubflow !== nextProps.data.isSubflow ||
|
||||
prevProps.data.subflowKind !== nextProps.data.subflowKind ||
|
||||
prevProps.data.width !== nextProps.data.width ||
|
||||
prevProps.data.height !== nextProps.data.height
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare subBlockValues by reference first
|
||||
const prevValues = prevProps.data.subBlockValues
|
||||
const nextValues = nextProps.data.subBlockValues
|
||||
|
||||
@@ -525,7 +745,6 @@ function shouldSkipPreviewBlockRender(
|
||||
return false
|
||||
}
|
||||
|
||||
// Shallow compare keys and values
|
||||
const prevKeys = Object.keys(prevValues)
|
||||
const nextKeys = Object.keys(nextValues)
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
interface WorkflowPreviewSubflowData {
|
||||
name: string
|
||||
width?: number
|
||||
height?: number
|
||||
kind: 'loop' | 'parallel'
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview subflow component for workflow visualization.
|
||||
* Renders loop/parallel containers without hooks, store subscriptions,
|
||||
* or interactive features.
|
||||
*/
|
||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||
|
||||
const isLoop = kind === 'loop'
|
||||
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
|
||||
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
|
||||
|
||||
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
|
||||
|
||||
const leftHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
|
||||
const rightHandleClass =
|
||||
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{/* Selection ring overlay */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
)}
|
||||
|
||||
{/* Target handle on left (input to the subflow) */}
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={leftHandleClass}
|
||||
style={{
|
||||
left: '-8px',
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header - matches actual subflow header structure */}
|
||||
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area - matches workflow structure */}
|
||||
<div
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
{/* Subflow Start - connects to first block in subflow */}
|
||||
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End source handle on right (output from the subflow) */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={endHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{
|
||||
right: '-8px',
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowPreviewSubflow = memo(WorkflowPreviewSubflowInner)
|
||||
@@ -18,7 +18,6 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowPreview')
|
||||
@@ -53,13 +52,12 @@ function getPreviewBlockDimensions(block: BlockState): { width: number; height:
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions and sizes.
|
||||
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
|
||||
* Accepts pre-filtered childBlocks for O(1) lookup instead of filtering all blocks.
|
||||
*/
|
||||
function calculateContainerDimensions(
|
||||
containerId: string,
|
||||
blocks: Record<string, BlockState>
|
||||
): { width: number; height: number } {
|
||||
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
|
||||
|
||||
function calculateContainerDimensions(childBlocks: BlockState[]): {
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
if (childBlocks.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
@@ -113,8 +111,34 @@ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefin
|
||||
return leftmostId
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively calculates the absolute position of a block,
|
||||
* accounting for parent container offsets.
|
||||
*/
|
||||
function calculateAbsolutePosition(
|
||||
block: BlockState,
|
||||
blocks: Record<string, BlockState>
|
||||
): { x: number; y: number } {
|
||||
if (!block.data?.parentId) {
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentBlock = blocks[block.data.parentId]
|
||||
if (!parentBlock) {
|
||||
logger.warn(`Parent block not found for child block: ${block.id}`)
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
|
||||
|
||||
return {
|
||||
x: parentAbsolutePosition.x + block.position.x,
|
||||
y: parentAbsolutePosition.y + block.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
export type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflowState: WorkflowState
|
||||
@@ -136,6 +160,8 @@ interface WorkflowPreviewProps {
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
/** Currently selected block ID for highlighting */
|
||||
selectedBlockId?: string | null
|
||||
/** Skips expensive computations for thumbnails/template previews */
|
||||
lightweight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,8 +170,6 @@ interface WorkflowPreviewProps {
|
||||
*/
|
||||
const previewNodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowPreviewBlock,
|
||||
noteBlock: WorkflowPreviewBlock,
|
||||
subflowNode: WorkflowPreviewSubflow,
|
||||
}
|
||||
|
||||
// Define edge types
|
||||
@@ -221,64 +245,30 @@ export function WorkflowPreview({
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
lightweight = false,
|
||||
}: WorkflowPreviewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeTypes = previewNodeTypes
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
|
||||
const blocksStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.blocks || {}).length,
|
||||
ids: Object.keys(workflowState.blocks || {}).join(','),
|
||||
}
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
const workflowStructureIds = useMemo(() => {
|
||||
if (!isValidWorkflowState) return ''
|
||||
const blockIds = Object.keys(workflowState.blocks || {})
|
||||
const edgeIds = (workflowState.edges || []).map((e) => e.id)
|
||||
return [...blockIds, ...edgeIds].join(',')
|
||||
}, [workflowState.blocks, workflowState.edges, isValidWorkflowState])
|
||||
|
||||
const loopsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.loops || {}).length,
|
||||
ids: Object.keys(workflowState.loops || {}).join(','),
|
||||
// Pre-compute parent-child relationships for O(1) lookups in container dimension calculations
|
||||
const containerChildIndex = useMemo(() => {
|
||||
const index: Record<string, BlockState[]> = {}
|
||||
for (const block of Object.values(workflowState.blocks || {})) {
|
||||
if (block?.data?.parentId) {
|
||||
const parentId = block.data.parentId
|
||||
if (!index[parentId]) index[parentId] = []
|
||||
index[parentId].push(block)
|
||||
}
|
||||
}
|
||||
}, [workflowState.loops, isValidWorkflowState])
|
||||
|
||||
const parallelsStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: Object.keys(workflowState.parallels || {}).length,
|
||||
ids: Object.keys(workflowState.parallels || {}).join(','),
|
||||
}
|
||||
}, [workflowState.parallels, isValidWorkflowState])
|
||||
|
||||
const edgesStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
return {
|
||||
count: workflowState.edges?.length || 0,
|
||||
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
|
||||
}
|
||||
}, [workflowState.edges, isValidWorkflowState])
|
||||
|
||||
const calculateAbsolutePosition = (
|
||||
block: any,
|
||||
blocks: Record<string, any>
|
||||
): { x: number; y: number } => {
|
||||
if (!block.data?.parentId) {
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentBlock = blocks[block.data.parentId]
|
||||
if (!parentBlock) {
|
||||
logger.warn(`Parent block not found for child block: ${block.id}`)
|
||||
return block.position
|
||||
}
|
||||
|
||||
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
|
||||
|
||||
return {
|
||||
x: parentAbsolutePosition.x + block.position.x,
|
||||
y: parentAbsolutePosition.y + block.position.y,
|
||||
}
|
||||
}
|
||||
return index
|
||||
}, [workflowState.blocks])
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
@@ -293,21 +283,24 @@ export function WorkflowPreview({
|
||||
|
||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||
|
||||
// Handle loop/parallel containers
|
||||
// Handle loop/parallel containers - use unified workflowBlock with isSubflow flag
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
const childBlocks = containerChildIndex[blockId] || []
|
||||
const dimensions = calculateContainerDimensions(childBlocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
data: {
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
isPreviewSelected: isSelected,
|
||||
isSubflow: true,
|
||||
subflowKind: block.type as 'loop' | 'parallel',
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -348,19 +341,20 @@ export function WorkflowPreview({
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus,
|
||||
subBlockValues: block.subBlocks,
|
||||
lightweight,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [
|
||||
blocksStructure,
|
||||
loopsStructure,
|
||||
parallelsStructure,
|
||||
workflowStructureIds,
|
||||
workflowState.blocks,
|
||||
containerChildIndex,
|
||||
isValidWorkflowState,
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
lightweight,
|
||||
])
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
@@ -395,7 +389,7 @@ export function WorkflowPreview({
|
||||
zIndex: executionStatus === 'success' ? 10 : 0,
|
||||
}
|
||||
})
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
|
||||
}, [workflowStructureIds, workflowState.edges, isValidWorkflowState, executedBlocks])
|
||||
|
||||
if (!isValidWorkflowState) {
|
||||
return (
|
||||
@@ -449,7 +443,7 @@ export function WorkflowPreview({
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodeTypes={previewNodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitView
|
||||
@@ -489,7 +483,7 @@ export function WorkflowPreview({
|
||||
onPaneClick={onPaneClick}
|
||||
/>
|
||||
<FitViewOnChange
|
||||
nodeIds={blocksStructure.ids}
|
||||
nodeIds={workflowStructureIds}
|
||||
fitPadding={fitPadding}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user