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:
Waleed
2026-06-29 13:06:08 -07:00
committed by GitHub
parent 69e3550a72
commit c7bb37d488
4 changed files with 642 additions and 436 deletions
@@ -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)
+5
View File
@@ -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>
)
}