Skip to content

Commit a670e69

Browse files
committed
feat(workflow-renderer): extract edge, subflow, and note Views into @sim/workflow-renderer
Adds a @sim/workflow-renderer package with pure, props-driven WorkflowEdgeView, SubflowNodeView, and NoteBlockView shared by the editor and (future) docs preview. Moves block-dimensions constants into the package. Each editor node becomes a thin Container that wires stores/permissions and injects the editor-only ActionBar via a slot. No optimizePackageImports for the workspace component packages (avoids the toast-style module duplication); Tailwind scans the package source.
1 parent f5116f4 commit a670e69

32 files changed

Lines changed: 1114 additions & 845 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx

Lines changed: 17 additions & 485 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop'
22
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel'
3-
export {
4-
SubflowNodeComponent,
5-
type SubflowNodeData,
6-
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
3+
export { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx

Lines changed: 26 additions & 228 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,20 @@
11
import { memo, useMemo } from 'react'
2-
import { Badge, cn, handleKeyboardActivation } from '@sim/emcn'
3-
import { RepeatIcon, SplitIcon } from 'lucide-react'
4-
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
5-
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
6-
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
2+
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
3+
import { type NodeProps, useReactFlow } from 'reactflow'
4+
import { hasDiffStatus } from '@/lib/workflows/diff/types'
75
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
86
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
97
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
108
import { useLastRunPath } from '@/stores/execution'
119
import { usePanelEditorStore } from '@/stores/panel'
1210

1311
/**
14-
* Data structure for subflow nodes (loop and parallel containers)
15-
*/
16-
export interface SubflowNodeData {
17-
width?: number
18-
height?: number
19-
parentId?: string
20-
extent?: 'parent'
21-
isPreview?: boolean
22-
/** Whether this subflow is selected in preview mode */
23-
isPreviewSelected?: boolean
24-
kind: 'loop' | 'parallel'
25-
name?: string
26-
/** Execution status passed by preview/snapshot views */
27-
executionStatus?: 'success' | 'error' | 'not-executed'
28-
/** Whether the parent workflow is locked and should render as read-only */
29-
isWorkflowLocked?: boolean
30-
}
31-
32-
const HANDLE_STYLE = {
33-
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
34-
transform: 'translateY(-50%)',
35-
} as const
36-
37-
/**
38-
* Reusable class names for Handle components.
39-
* Matches the styling pattern from workflow-block.tsx.
40-
*/
41-
const getHandleClasses = (position: 'left' | 'right') => {
42-
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
43-
const colorClasses = '!bg-[var(--workflow-edge)]'
44-
45-
const positionClasses = {
46-
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',
47-
right:
48-
'!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',
49-
}
50-
51-
return cn(baseClasses, colorClasses, positionClasses[position])
52-
}
53-
54-
/**
55-
* Subflow node component for loop and parallel execution containers.
56-
* Renders a resizable container with a header displaying the block name and icon,
57-
* handles for connections, and supports nested execution contexts.
12+
* Editor container for {@link SubflowNodeView}.
5813
*
59-
* @param props - Node properties containing data and id
60-
* @returns Rendered subflow node component
14+
* Resolves the subflow's enabled/locked/focus/diff/run state from the editor
15+
* stores, computes its nesting depth from the ReactFlow node tree, and renders
16+
* the pure view shared with the docs preview — injecting the editor-only
17+
* {@link ActionBar} through the view's `actionBar` slot.
6118
*/
6219
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
6320
const { getNodes } = useReactFlow()
@@ -66,7 +23,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
6623

6724
const currentWorkflow = useCurrentWorkflow()
6825
const currentBlock = currentWorkflow.getBlockById(id)
69-
const diffStatus: DiffStatus =
26+
const diffStatus =
7027
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
7128
? currentBlock.is_diff
7229
: undefined
@@ -75,12 +32,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
7532
const isLocked = currentBlock?.locked ?? false
7633
const isPreview = data?.isPreview || false
7734

78-
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
7935
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
36+
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
8037
const isFocused = currentBlockId === id
8138

82-
const isPreviewSelected = data?.isPreviewSelected || false
83-
8439
const lastRunPath = useLastRunPath()
8540
const executionStatus = data.executionStatus
8641
const runPathStatus: 'success' | 'error' | undefined =
@@ -91,8 +46,8 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
9146
: lastRunPath.get(id)
9247

9348
/**
94-
* Calculate the nesting level of this subflow node based on its parent hierarchy.
95-
* Used to apply appropriate styling for nested containers.
49+
* Nesting depth, walking the parent chain so the view can apply nested
50+
* container styling.
9651
*/
9752
const nestingLevel = useMemo(() => {
9853
let level = 0
@@ -108,178 +63,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
10863
return level
10964
}, [data?.parentId, getNodes])
11065

111-
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
112-
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
113-
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
114-
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
115-
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
116-
117-
/**
118-
* Determine the ring styling based on subflow state priority:
119-
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
120-
* 2. Diff status (version comparison) - green/orange ring
121-
* 3. Run path status (execution result) - green/red ring
122-
*/
123-
const isSelected = !isPreview && selected
124-
const hasRing =
125-
isFocused ||
126-
isSelected ||
127-
isPreviewSelected ||
128-
diffStatus === 'new' ||
129-
diffStatus === 'edited' ||
130-
!!runPathStatus
131-
132-
/**
133-
* Compute the ring color for the subflow selection indicator.
134-
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
135-
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
136-
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
137-
*/
138-
const getRingColor = (): string | undefined => {
139-
if (!hasRing) return undefined
140-
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
141-
if (diffStatus === 'new') return 'var(--brand-accent)'
142-
if (diffStatus === 'edited') return 'var(--warning)'
143-
if (runPathStatus === 'success') {
144-
return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
145-
}
146-
if (runPathStatus === 'error') return 'var(--text-error)'
147-
return undefined
148-
}
149-
const ringColor = getRingColor()
150-
15166
return (
152-
<div className='group pointer-events-none relative'>
153-
<div
154-
className='relative select-none rounded-lg border border-[var(--border-1)] transition-block-bg'
155-
style={{
156-
width: data.width || 500,
157-
height: data.height || 300,
158-
overflow: 'visible',
159-
pointerEvents: 'none',
160-
...(ringColor && {
161-
boxShadow: `0 0 0 1.75px ${ringColor}`,
162-
}),
163-
}}
164-
data-node-id={id}
165-
data-type='subflowNode'
166-
data-nesting-level={nestingLevel}
167-
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
168-
>
169-
{!isPreview && <ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
170-
171-
{/* Header Section */}
172-
<div
173-
role='button'
174-
tabIndex={0}
175-
aria-label={`Select ${blockName}`}
176-
onClick={() => setCurrentBlockId(id)}
177-
onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
178-
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
179-
style={{ pointerEvents: 'auto' }}
180-
>
181-
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
182-
<div
183-
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
184-
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
185-
>
186-
<BlockIcon className='size-[16px] text-white' />
187-
</div>
188-
<span
189-
className={cn(
190-
'truncate font-medium text-md',
191-
!isEnabled && 'text-[var(--text-muted)]'
192-
)}
193-
title={blockName}
194-
>
195-
{blockName}
196-
</span>
197-
</div>
198-
<div className='flex items-center gap-1'>
199-
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
200-
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
201-
</div>
202-
</div>
203-
204-
{/*
205-
* Subflow body background. Captures clicks to select the subflow in the
206-
* panel editor, matching the header click behavior. Child nodes and edges
207-
* are rendered as sibling divs at the viewport level by ReactFlow (not as
208-
* DOM children), so enabling pointer events here doesn't block them.
209-
*/}
210-
<div
211-
role='button'
212-
tabIndex={isPreview ? -1 : 0}
213-
aria-label={`Select ${blockName}`}
214-
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
215-
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
216-
onClick={() => setCurrentBlockId(id)}
217-
onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
218-
/>
219-
220-
{!isPreview && canEditWorkflow && (
221-
<div
222-
role='separator'
223-
aria-orientation='horizontal'
224-
className='absolute right-[8px] bottom-2 z-20 flex size-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
225-
style={{ pointerEvents: 'auto' }}
226-
/>
227-
)}
228-
229-
<div
230-
className='relative h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
231-
data-dragarea='true'
232-
style={{ pointerEvents: 'none' }}
233-
>
234-
{/* Subflow Start */}
235-
<div
236-
className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'
237-
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
238-
data-parent-id={id}
239-
data-node-role={`${data.kind}-start`}
240-
data-extent='parent'
241-
>
242-
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
243-
244-
<Handle
245-
type='source'
246-
position={Position.Right}
247-
id={startHandleId}
248-
className={getHandleClasses('right')}
249-
style={{
250-
top: '50%',
251-
transform: 'translateY(-50%)',
252-
pointerEvents: 'auto',
253-
}}
254-
data-parent-id={id}
255-
/>
256-
</div>
257-
</div>
258-
259-
{/* Input handle on left middle */}
260-
<Handle
261-
type='target'
262-
position={Position.Left}
263-
className={getHandleClasses('left')}
264-
style={{
265-
...HANDLE_STYLE,
266-
pointerEvents: 'auto',
267-
}}
268-
/>
269-
270-
{/* Output handle on right middle */}
271-
<Handle
272-
type='source'
273-
position={Position.Right}
274-
className={getHandleClasses('right')}
275-
style={{
276-
...HANDLE_STYLE,
277-
pointerEvents: 'auto',
278-
}}
279-
id={endHandleId}
280-
/>
281-
</div>
282-
</div>
67+
<SubflowNodeView
68+
id={id}
69+
data={data}
70+
selected={selected}
71+
isEnabled={isEnabled}
72+
isLocked={isLocked}
73+
isFocused={isFocused}
74+
runPathStatus={runPathStatus}
75+
diffStatus={diffStatus}
76+
nestingLevel={nestingLevel}
77+
canEditWorkflow={canEditWorkflow}
78+
onSelect={() => setCurrentBlockId(id)}
79+
actionBar={<ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
80+
/>
28381
)
28482
})
28583

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
22
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
33
import { createLogger } from '@sim/logger'
4+
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
45
import { isEqual } from 'es-toolkit'
56
import { useParams } from 'next/navigation'
67
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
78
import { useStoreWithEqualityFn } from 'zustand/traditional'
89
import { getBaseUrl } from '@/lib/core/utils/urls'
910
import { createMcpToolId } from '@/lib/mcp/shared'
1011
import { getProviderIdFromServiceId } from '@/lib/oauth'
11-
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
1212
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
1313
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
1414
import {

0 commit comments

Comments
 (0)