diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css
index 84891c0553..24dbca36cb 100644
--- a/apps/sim/app/globals.css
+++ b/apps/sim/app/globals.css
@@ -14,7 +14,8 @@
}
.workflow-container .react-flow__node-loopNode,
-.workflow-container .react-flow__node-parallelNode {
+.workflow-container .react-flow__node-parallelNode,
+.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
index fe1e8a2167..832d40c160 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
@@ -2,8 +2,7 @@ export { ControlBar } from './control-bar/control-bar'
export { ErrorBoundary } from './error/index'
export { Panel } from './panel/panel'
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
-export { LoopNodeComponent } from './subflows/loop/loop-node'
-export { ParallelNodeComponent } from './subflows/parallel/parallel-node'
+export { SubflowNodeComponent } from './subflows/subflow-node'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges.test.tsx
new file mode 100644
index 0000000000..9071f63953
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges.test.tsx
@@ -0,0 +1,388 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Mock hooks
+const mockCollaborativeUpdates = {
+ collaborativeUpdateLoopType: vi.fn(),
+ collaborativeUpdateParallelType: vi.fn(),
+ collaborativeUpdateIterationCount: vi.fn(),
+ collaborativeUpdateIterationCollection: vi.fn(),
+}
+
+const mockStoreData = {
+ loops: {},
+ parallels: {},
+}
+
+vi.mock('@/hooks/use-collaborative-workflow', () => ({
+ useCollaborativeWorkflow: () => mockCollaborativeUpdates,
+}))
+
+vi.mock('@/stores/workflows/workflow/store', () => ({
+ useWorkflowStore: () => mockStoreData,
+}))
+
+vi.mock('@/components/ui/badge', () => ({
+ Badge: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: (props: any) => ,
+}))
+
+vi.mock('@/components/ui/popover', () => ({
+ Popover: ({ children }: any) => {children}
,
+ PopoverContent: ({ children }: any) => {children}
,
+ PopoverTrigger: ({ children }: any) => {children}
,
+}))
+
+vi.mock('@/components/ui/tag-dropdown', () => ({
+ checkTagTrigger: vi.fn(() => ({ show: false })),
+ TagDropdown: ({ children }: any) => {children}
,
+}))
+
+vi.mock('react-simple-code-editor', () => ({
+ default: (props: any) => ,
+}))
+
+describe('IterationBadges', () => {
+ const defaultProps = {
+ nodeId: 'test-node-1',
+ data: {
+ width: 500,
+ height: 300,
+ isPreview: false,
+ },
+ iterationType: 'loop' as const,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockStoreData.loops = {}
+ mockStoreData.parallels = {}
+ })
+
+ describe('Component Interface', () => {
+ it.concurrent('should accept required props', () => {
+ expect(defaultProps.nodeId).toBeDefined()
+ expect(defaultProps.data).toBeDefined()
+ expect(defaultProps.iterationType).toBeDefined()
+ })
+
+ it.concurrent('should handle loop iteration type prop', () => {
+ const loopProps = { ...defaultProps, iterationType: 'loop' as const }
+ expect(loopProps.iterationType).toBe('loop')
+ })
+
+ it.concurrent('should handle parallel iteration type prop', () => {
+ const parallelProps = { ...defaultProps, iterationType: 'parallel' as const }
+ expect(parallelProps.iterationType).toBe('parallel')
+ })
+ })
+
+ describe('Configuration System', () => {
+ it.concurrent('should use correct config for loop type', () => {
+ const CONFIG = {
+ loop: {
+ typeLabels: { for: 'For Loop', forEach: 'For Each' },
+ typeKey: 'loopType' as const,
+ storeKey: 'loops' as const,
+ maxIterations: 100,
+ configKeys: {
+ iterations: 'iterations' as const,
+ items: 'forEachItems' as const,
+ },
+ },
+ }
+
+ expect(CONFIG.loop.typeLabels.for).toBe('For Loop')
+ expect(CONFIG.loop.typeLabels.forEach).toBe('For Each')
+ expect(CONFIG.loop.maxIterations).toBe(100)
+ expect(CONFIG.loop.storeKey).toBe('loops')
+ })
+
+ it.concurrent('should use correct config for parallel type', () => {
+ const CONFIG = {
+ parallel: {
+ typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
+ typeKey: 'parallelType' as const,
+ storeKey: 'parallels' as const,
+ maxIterations: 20,
+ configKeys: {
+ iterations: 'count' as const,
+ items: 'distribution' as const,
+ },
+ },
+ }
+
+ expect(CONFIG.parallel.typeLabels.count).toBe('Parallel Count')
+ expect(CONFIG.parallel.typeLabels.collection).toBe('Parallel Each')
+ expect(CONFIG.parallel.maxIterations).toBe(20)
+ expect(CONFIG.parallel.storeKey).toBe('parallels')
+ })
+ })
+
+ describe('Type Determination Logic', () => {
+ it.concurrent('should default to "for" for loop type', () => {
+ type IterationType = 'loop' | 'parallel'
+ const determineDefaultType = (iterationType: IterationType) => {
+ return iterationType === 'loop' ? 'for' : 'count'
+ }
+
+ const currentType = determineDefaultType('loop')
+ expect(currentType).toBe('for')
+ })
+
+ it.concurrent('should default to "count" for parallel type', () => {
+ type IterationType = 'loop' | 'parallel'
+ const determineDefaultType = (iterationType: IterationType) => {
+ return iterationType === 'loop' ? 'for' : 'count'
+ }
+
+ const currentType = determineDefaultType('parallel')
+ expect(currentType).toBe('count')
+ })
+
+ it.concurrent('should use explicit loopType when provided', () => {
+ type IterationType = 'loop' | 'parallel'
+ const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
+ return explicitType || (iterationType === 'loop' ? 'for' : 'count')
+ }
+
+ const currentType = determineType('forEach', 'loop')
+ expect(currentType).toBe('forEach')
+ })
+
+ it.concurrent('should use explicit parallelType when provided', () => {
+ type IterationType = 'loop' | 'parallel'
+ const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
+ return explicitType || (iterationType === 'loop' ? 'for' : 'count')
+ }
+
+ const currentType = determineType('collection', 'parallel')
+ expect(currentType).toBe('collection')
+ })
+ })
+
+ describe('Count Mode Detection', () => {
+ it.concurrent('should be in count mode for loop + for combination', () => {
+ type IterationType = 'loop' | 'parallel'
+ type LoopType = 'for' | 'forEach'
+ type ParallelType = 'count' | 'collection'
+
+ const iterationType: IterationType = 'loop'
+ const currentType: LoopType = 'for'
+ const isCountMode = iterationType === 'loop' && currentType === 'for'
+
+ expect(isCountMode).toBe(true)
+ })
+
+ it.concurrent('should be in count mode for parallel + count combination', () => {
+ type IterationType = 'loop' | 'parallel'
+ type ParallelType = 'count' | 'collection'
+
+ const iterationType: IterationType = 'parallel'
+ const currentType: ParallelType = 'count'
+ const isCountMode = iterationType === 'parallel' && currentType === 'count'
+
+ expect(isCountMode).toBe(true)
+ })
+
+ it.concurrent('should not be in count mode for loop + forEach combination', () => {
+ type IterationType = 'loop' | 'parallel'
+
+ const testCountMode = (iterationType: IterationType, currentType: string) => {
+ return iterationType === 'loop' && currentType === 'for'
+ }
+
+ const isCountMode = testCountMode('loop', 'forEach')
+ expect(isCountMode).toBe(false)
+ })
+
+ it.concurrent('should not be in count mode for parallel + collection combination', () => {
+ type IterationType = 'loop' | 'parallel'
+
+ const testCountMode = (iterationType: IterationType, currentType: string) => {
+ return iterationType === 'parallel' && currentType === 'count'
+ }
+
+ const isCountMode = testCountMode('parallel', 'collection')
+ expect(isCountMode).toBe(false)
+ })
+ })
+
+ describe('Configuration Values', () => {
+ it.concurrent('should handle default iteration count', () => {
+ const data = { count: undefined }
+ const configIterations = data.count ?? 5
+ expect(configIterations).toBe(5)
+ })
+
+ it.concurrent('should use provided iteration count', () => {
+ const data = { count: 10 }
+ const configIterations = data.count ?? 5
+ expect(configIterations).toBe(10)
+ })
+
+ it.concurrent('should handle string collection', () => {
+ const collection = '[1, 2, 3, 4, 5]'
+ const collectionString =
+ typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
+ expect(collectionString).toBe('[1, 2, 3, 4, 5]')
+ })
+
+ it.concurrent('should handle object collection', () => {
+ const collection = { items: [1, 2, 3] }
+ const collectionString =
+ typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
+ expect(collectionString).toBe('{"items":[1,2,3]}')
+ })
+
+ it.concurrent('should handle array collection', () => {
+ const collection = [1, 2, 3, 4, 5]
+ const collectionString =
+ typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
+ expect(collectionString).toBe('[1,2,3,4,5]')
+ })
+ })
+
+ describe('Preview Mode Handling', () => {
+ it.concurrent('should handle preview mode for loops', () => {
+ const previewProps = {
+ ...defaultProps,
+ data: { ...defaultProps.data, isPreview: true },
+ iterationType: 'loop' as const,
+ }
+
+ expect(previewProps.data.isPreview).toBe(true)
+ // In preview mode, collaborative functions shouldn't be called
+ expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).not.toHaveBeenCalled()
+ })
+
+ it.concurrent('should handle preview mode for parallels', () => {
+ const previewProps = {
+ ...defaultProps,
+ data: { ...defaultProps.data, isPreview: true },
+ iterationType: 'parallel' as const,
+ }
+
+ expect(previewProps.data.isPreview).toBe(true)
+ // In preview mode, collaborative functions shouldn't be called
+ expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Store Integration', () => {
+ it.concurrent('should access loops store for loop iteration type', () => {
+ const nodeId = 'loop-node-1'
+ ;(mockStoreData.loops as any)[nodeId] = { iterations: 10 }
+
+ const nodeConfig = (mockStoreData.loops as any)[nodeId]
+ expect(nodeConfig).toBeDefined()
+ expect(nodeConfig.iterations).toBe(10)
+ })
+
+ it.concurrent('should access parallels store for parallel iteration type', () => {
+ const nodeId = 'parallel-node-1'
+ ;(mockStoreData.parallels as any)[nodeId] = { count: 5 }
+
+ const nodeConfig = (mockStoreData.parallels as any)[nodeId]
+ expect(nodeConfig).toBeDefined()
+ expect(nodeConfig.count).toBe(5)
+ })
+
+ it.concurrent('should handle missing node configuration gracefully', () => {
+ const nodeId = 'missing-node'
+ const nodeConfig = (mockStoreData.loops as any)[nodeId]
+ expect(nodeConfig).toBeUndefined()
+ })
+ })
+
+ describe('Max Iterations Limits', () => {
+ it.concurrent('should enforce max iterations for loops (100)', () => {
+ const maxIterations = 100
+ const testValue = 150
+ const clampedValue = Math.min(maxIterations, testValue)
+ expect(clampedValue).toBe(100)
+ })
+
+ it.concurrent('should enforce max iterations for parallels (20)', () => {
+ const maxIterations = 20
+ const testValue = 50
+ const clampedValue = Math.min(maxIterations, testValue)
+ expect(clampedValue).toBe(20)
+ })
+
+ it.concurrent('should allow values within limits', () => {
+ const loopMaxIterations = 100
+ const parallelMaxIterations = 20
+
+ expect(Math.min(loopMaxIterations, 50)).toBe(50)
+ expect(Math.min(parallelMaxIterations, 10)).toBe(10)
+ })
+ })
+
+ describe('Collaborative Update Functions', () => {
+ it.concurrent('should have the correct collaborative functions available', () => {
+ expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toBeDefined()
+ expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toBeDefined()
+ expect(mockCollaborativeUpdates.collaborativeUpdateIterationCount).toBeDefined()
+ expect(mockCollaborativeUpdates.collaborativeUpdateIterationCollection).toBeDefined()
+ })
+
+ it.concurrent('should call correct function for loop type updates', () => {
+ const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
+ if (iterationType === 'loop') {
+ mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
+ } else {
+ mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
+ }
+ }
+
+ handleTypeChange('forEach', 'loop', 'test-node')
+ expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toHaveBeenCalledWith(
+ 'test-node',
+ 'forEach'
+ )
+ })
+
+ it.concurrent('should call correct function for parallel type updates', () => {
+ const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
+ if (iterationType === 'loop') {
+ mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
+ } else {
+ mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
+ }
+ }
+
+ handleTypeChange('collection', 'parallel', 'test-node')
+ expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toHaveBeenCalledWith(
+ 'test-node',
+ 'collection'
+ )
+ })
+ })
+
+ describe('Input Sanitization', () => {
+ it.concurrent('should sanitize numeric input by removing non-digits', () => {
+ const testInput = 'abc123def456'
+ const sanitized = testInput.replace(/[^0-9]/g, '')
+ expect(sanitized).toBe('123456')
+ })
+
+ it.concurrent('should handle empty input', () => {
+ const testInput = ''
+ const sanitized = testInput.replace(/[^0-9]/g, '')
+ expect(sanitized).toBe('')
+ })
+
+ it.concurrent('should preserve valid numeric input', () => {
+ const testInput = '42'
+ const sanitized = testInput.replace(/[^0-9]/g, '')
+ expect(sanitized).toBe('42')
+ })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.test.tsx
deleted file mode 100644
index 1ba47193e2..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.test.tsx
+++ /dev/null
@@ -1,452 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-
-vi.mock('@/stores/workflows/workflow/store', () => ({
- useWorkflowStore: vi.fn(),
-}))
-
-vi.mock('@/lib/logs/logger', () => ({
- createLogger: vi.fn(() => ({
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- })),
-}))
-
-vi.mock('reactflow', () => ({
- Handle: ({ id, type, position }: any) => ({ id, type, position }),
- Position: {
- Top: 'top',
- Bottom: 'bottom',
- Left: 'left',
- Right: 'right',
- },
- useReactFlow: () => ({
- getNodes: vi.fn(() => []),
- }),
- NodeResizer: ({ isVisible }: any) => ({ isVisible }),
- memo: (component: any) => component,
-}))
-
-vi.mock('react', async () => {
- const actual = await vi.importActual('react')
- return {
- ...actual,
- memo: (component: any) => component,
- useMemo: (fn: any) => fn(),
- useRef: () => ({ current: null }),
- }
-})
-
-vi.mock('@/components/ui/button', () => ({
- Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
-}))
-
-vi.mock('@/components/ui/card', () => ({
- Card: ({ children, ...props }: any) => ({ children, ...props }),
-}))
-
-vi.mock('@/components/icons', async (importOriginal) => {
- const actual = (await importOriginal()) as any
- return {
- ...actual,
- // Override specific icons if needed for testing
- StartIcon: ({ className }: any) => ({ className }),
- }
-})
-
-vi.mock('@/lib/utils', () => ({
- cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
-}))
-
-vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-badges', () => ({
- LoopBadges: ({ loopId }: any) => ({ loopId }),
-}))
-
-describe('LoopNodeComponent', () => {
- const mockRemoveBlock = vi.fn()
- const mockGetNodes = vi.fn()
- const defaultProps = {
- id: 'loop-1',
- type: 'loopNode',
- data: {
- width: 500,
- height: 300,
- state: 'valid',
- },
- selected: false,
- zIndex: 1,
- isConnectable: true,
- xPos: 0,
- yPos: 0,
- dragging: false,
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
-
- ;(useWorkflowStore as any).mockImplementation((selector: any) => {
- const state = {
- removeBlock: mockRemoveBlock,
- }
- return selector(state)
- })
-
- mockGetNodes.mockReturnValue([])
- })
-
- describe('Component Definition and Structure', () => {
- it('should be defined as a function component', () => {
- expect(LoopNodeComponent).toBeDefined()
- expect(typeof LoopNodeComponent).toBe('function')
- })
-
- it('should have correct display name', () => {
- expect(LoopNodeComponent.displayName).toBe('LoopNodeComponent')
- })
-
- it('should be a memoized component', () => {
- expect(LoopNodeComponent).toBeDefined()
- })
- })
-
- describe('Props Validation and Type Safety', () => {
- it('should accept NodeProps interface', () => {
- const validProps = {
- id: 'test-id',
- type: 'loopNode' as const,
- data: {
- width: 400,
- height: 300,
- state: 'valid' as const,
- },
- selected: false,
- zIndex: 1,
- isConnectable: true,
- xPos: 0,
- yPos: 0,
- dragging: false,
- }
-
- expect(() => {
- const _component: typeof LoopNodeComponent = LoopNodeComponent
- expect(_component).toBeDefined()
- }).not.toThrow()
- })
-
- it('should handle different data configurations', () => {
- const configurations = [
- { width: 500, height: 300, state: 'valid' },
- { width: 800, height: 600, state: 'invalid' },
- { width: 0, height: 0, state: 'pending' },
- {},
- ]
-
- configurations.forEach((data) => {
- const props = { ...defaultProps, data }
- expect(() => {
- const _component: typeof LoopNodeComponent = LoopNodeComponent
- expect(_component).toBeDefined()
- }).not.toThrow()
- })
- })
- })
-
- describe('Store Integration', () => {
- it('should integrate with workflow store', () => {
- expect(useWorkflowStore).toBeDefined()
-
- const mockState = { removeBlock: mockRemoveBlock }
- const selector = vi.fn((state) => state.removeBlock)
-
- expect(() => {
- selector(mockState)
- }).not.toThrow()
-
- expect(selector(mockState)).toBe(mockRemoveBlock)
- })
-
- it('should handle removeBlock function', () => {
- expect(mockRemoveBlock).toBeDefined()
- expect(typeof mockRemoveBlock).toBe('function')
-
- mockRemoveBlock('test-id')
- expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
- })
- })
-
- describe('Component Logic Tests', () => {
- it('should handle nesting level calculation logic', () => {
- const testCases = [
- { nodes: [], parentId: undefined, expectedLevel: 0 },
- { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
- {
- nodes: [
- { id: 'parent', data: { parentId: 'grandparent' } },
- { id: 'grandparent', data: {} },
- ],
- parentId: 'parent',
- expectedLevel: 2,
- },
- ]
-
- testCases.forEach(({ nodes, parentId, expectedLevel }) => {
- mockGetNodes.mockReturnValue(nodes)
-
- // Simulate the nesting level calculation logic
- let level = 0
- let currentParentId = parentId
-
- while (currentParentId) {
- level++
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
- currentParentId = parentNode.data?.parentId
- }
-
- expect(level).toBe(expectedLevel)
- })
- })
-
- it('should handle nested styles generation', () => {
- // Test the nested styles logic
- const testCases = [
- { nestingLevel: 0, state: 'valid', expectedBg: 'rgba(34,197,94,0.05)' },
- { nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
- { nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
- { nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
- ]
-
- testCases.forEach(({ nestingLevel, state, expectedBg }) => {
- // Simulate the getNestedStyles logic
- const styles: Record = {
- backgroundColor: state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent',
- }
-
- if (nestingLevel > 0) {
- const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
- const colorIndex = (nestingLevel - 1) % colors.length
- styles.backgroundColor = `${colors[colorIndex]}30`
- }
-
- expect(styles.backgroundColor).toBe(expectedBg)
- })
- })
- })
-
- describe('Component Configuration', () => {
- it('should handle different dimensions', () => {
- const dimensionTests = [
- { width: 500, height: 300 },
- { width: 800, height: 600 },
- { width: 0, height: 0 },
- { width: 10000, height: 10000 },
- ]
-
- dimensionTests.forEach(({ width, height }) => {
- const data = { width, height, state: 'valid' }
- expect(data.width).toBe(width)
- expect(data.height).toBe(height)
- })
- })
-
- it('should handle different states', () => {
- const stateTests = ['valid', 'invalid', 'pending', 'executing']
-
- stateTests.forEach((state) => {
- const data = { width: 500, height: 300, state }
- expect(data.state).toBe(state)
- })
- })
- })
-
- describe('Event Handling Logic', () => {
- it('should handle delete button click logic', () => {
- const mockEvent = {
- stopPropagation: vi.fn(),
- }
-
- // Simulate the delete button click handler
- const handleDelete = (e: any, nodeId: string) => {
- e.stopPropagation()
- mockRemoveBlock(nodeId)
- }
-
- handleDelete(mockEvent, 'test-id')
-
- expect(mockEvent.stopPropagation).toHaveBeenCalled()
- expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
- })
-
- it('should handle event propagation prevention', () => {
- const mockEvent = {
- stopPropagation: vi.fn(),
- }
-
- // Test that stopPropagation is called
- mockEvent.stopPropagation()
- expect(mockEvent.stopPropagation).toHaveBeenCalled()
- })
- })
-
- describe('Component Data Handling', () => {
- it('should handle missing data properties gracefully', () => {
- const testCases = [
- undefined,
- {},
- { width: 500 },
- { height: 300 },
- { state: 'valid' },
- { width: 500, height: 300 },
- ]
-
- testCases.forEach((data) => {
- const props = { ...defaultProps, data }
-
- // Test default values logic
- const width = Math.max(0, data?.width || 500)
- const height = Math.max(0, data?.height || 300)
-
- expect(width).toBeGreaterThanOrEqual(0)
- expect(height).toBeGreaterThanOrEqual(0)
- })
- })
-
- it('should handle parent ID relationships', () => {
- const testCases = [
- { parentId: undefined, hasParent: false },
- { parentId: 'parent-1', hasParent: true },
- { parentId: '', hasParent: false },
- ]
-
- testCases.forEach(({ parentId, hasParent }) => {
- const data = { ...defaultProps.data, parentId }
- expect(Boolean(data.parentId)).toBe(hasParent)
- })
- })
- })
-
- describe('Edge Cases and Error Handling', () => {
- it('should handle circular parent references', () => {
- // Test circular reference prevention
- const nodes = [
- { id: 'node1', data: { parentId: 'node2' } },
- { id: 'node2', data: { parentId: 'node1' } },
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- // Test the actual component's nesting level calculation logic
- // This simulates the real useMemo logic from the component
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- // This is the actual logic pattern used in the component
- while (currentParentId) {
- // If we've seen this parent before, we have a cycle - break immediately
- if (visited.has(currentParentId)) {
- break
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // With proper circular reference detection, we should stop at level 2
- // (node1 -> node2, then detect cycle when trying to go back to node1)
- expect(level).toBe(2)
- expect(visited.has('node1')).toBe(true)
- expect(visited.has('node2')).toBe(true)
- })
-
- it('should handle complex circular reference chains', () => {
- // Test more complex circular reference scenarios
- const nodes = [
- { id: 'node1', data: { parentId: 'node2' } },
- { id: 'node2', data: { parentId: 'node3' } },
- { id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- while (currentParentId) {
- if (visited.has(currentParentId)) {
- break // Cycle detected
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // Should traverse node1 -> node2 -> node3, then detect cycle
- expect(level).toBe(3)
- expect(visited.size).toBe(3)
- })
-
- it('should handle self-referencing nodes', () => {
- // Test node that references itself
- const nodes = [
- { id: 'node1', data: { parentId: 'node1' } }, // Self-reference
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- while (currentParentId) {
- if (visited.has(currentParentId)) {
- break // Cycle detected immediately
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // Should detect self-reference immediately after first iteration
- expect(level).toBe(1)
- expect(visited.has('node1')).toBe(true)
- })
-
- it('should handle extreme values', () => {
- const extremeValues = [
- { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
- { width: -1, height: -1 },
- { width: 0, height: 0 },
- { width: null, height: null },
- ]
-
- extremeValues.forEach((data) => {
- expect(() => {
- const width = data.width || 500
- const height = data.height || 300
- expect(typeof width).toBe('number')
- expect(typeof height).toBe('number')
- }).not.toThrow()
- })
- })
- })
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.test.tsx
deleted file mode 100644
index 3bcaf3d3e9..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.test.tsx
+++ /dev/null
@@ -1,585 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-
-vi.mock('@/stores/workflows/workflow/store', () => ({
- useWorkflowStore: vi.fn(),
-}))
-
-vi.mock('@/lib/logs/logger', () => ({
- createLogger: vi.fn(() => ({
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- })),
-}))
-
-vi.mock('reactflow', () => ({
- Handle: ({ id, type, position }: any) => ({ id, type, position }),
- Position: {
- Top: 'top',
- Bottom: 'bottom',
- Left: 'left',
- Right: 'right',
- },
- useReactFlow: () => ({
- getNodes: vi.fn(() => []),
- }),
- NodeResizer: ({ isVisible }: any) => ({ isVisible }),
- memo: (component: any) => component,
-}))
-
-vi.mock('react', async () => {
- const actual = await vi.importActual('react')
- return {
- ...actual,
- memo: (component: any) => component,
- useMemo: (fn: any) => fn(),
- useRef: () => ({ current: null }),
- }
-})
-
-vi.mock('@/components/ui/button', () => ({
- Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
-}))
-
-vi.mock('@/components/ui/card', () => ({
- Card: ({ children, ...props }: any) => ({ children, ...props }),
-}))
-
-vi.mock('@/blocks/registry', () => ({
- getBlock: vi.fn(() => ({
- name: 'Mock Block',
- description: 'Mock block description',
- icon: () => null,
- subBlocks: [],
- outputs: {},
- })),
- getAllBlocks: vi.fn(() => ({})),
-}))
-
-vi.mock('@/lib/utils', () => ({
- cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
-}))
-
-vi.mock(
- '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges',
- () => ({
- ParallelBadges: ({ parallelId }: any) => ({ parallelId }),
- })
-)
-
-describe('ParallelNodeComponent', () => {
- const mockRemoveBlock = vi.fn()
- const mockGetNodes = vi.fn()
- const defaultProps = {
- id: 'parallel-1',
- type: 'parallelNode',
- data: {
- width: 500,
- height: 300,
- state: 'valid',
- },
- selected: false,
- zIndex: 1,
- isConnectable: true,
- xPos: 0,
- yPos: 0,
- dragging: false,
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
-
- ;(useWorkflowStore as any).mockImplementation((selector: any) => {
- const state = {
- removeBlock: mockRemoveBlock,
- }
- return selector(state)
- })
-
- mockGetNodes.mockReturnValue([])
- })
-
- describe('Component Definition and Structure', () => {
- it.concurrent('should be defined as a function component', () => {
- expect(ParallelNodeComponent).toBeDefined()
- expect(typeof ParallelNodeComponent).toBe('function')
- })
-
- it.concurrent('should have correct display name', () => {
- expect(ParallelNodeComponent.displayName).toBe('ParallelNodeComponent')
- })
-
- it.concurrent('should be a memoized component', () => {
- expect(ParallelNodeComponent).toBeDefined()
- })
- })
-
- describe('Props Validation and Type Safety', () => {
- it.concurrent('should accept NodeProps interface', () => {
- expect(() => {
- const _component: typeof ParallelNodeComponent = ParallelNodeComponent
- expect(_component).toBeDefined()
- }).not.toThrow()
- })
-
- it.concurrent('should handle different data configurations', () => {
- const configurations = [
- { width: 500, height: 300, state: 'valid' },
- { width: 800, height: 600, state: 'invalid' },
- { width: 0, height: 0, state: 'pending' },
- {},
- ]
-
- configurations.forEach((data) => {
- const props = { ...defaultProps, data }
- expect(() => {
- const _component: typeof ParallelNodeComponent = ParallelNodeComponent
- expect(_component).toBeDefined()
- }).not.toThrow()
- })
- })
- })
-
- describe('Store Integration', () => {
- it.concurrent('should integrate with workflow store', () => {
- expect(useWorkflowStore).toBeDefined()
-
- const mockState = { removeBlock: mockRemoveBlock }
- const selector = vi.fn((state) => state.removeBlock)
-
- expect(() => {
- selector(mockState)
- }).not.toThrow()
-
- expect(selector(mockState)).toBe(mockRemoveBlock)
- })
-
- it.concurrent('should handle removeBlock function', () => {
- expect(mockRemoveBlock).toBeDefined()
- expect(typeof mockRemoveBlock).toBe('function')
-
- mockRemoveBlock('test-id')
- expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
- })
- })
-
- describe('Component Logic Tests', () => {
- it.concurrent('should handle nesting level calculation logic', () => {
- const testCases = [
- { nodes: [], parentId: undefined, expectedLevel: 0 },
- { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
- {
- nodes: [
- { id: 'parent', data: { parentId: 'grandparent' } },
- { id: 'grandparent', data: {} },
- ],
- parentId: 'parent',
- expectedLevel: 2,
- },
- ]
-
- testCases.forEach(({ nodes, parentId, expectedLevel }) => {
- mockGetNodes.mockReturnValue(nodes)
-
- let level = 0
- let currentParentId = parentId
-
- while (currentParentId) {
- level++
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
- currentParentId = parentNode.data?.parentId
- }
-
- expect(level).toBe(expectedLevel)
- })
- })
-
- it.concurrent('should handle nested styles generation for parallel nodes', () => {
- const testCases = [
- { nestingLevel: 0, state: 'valid', expectedBg: 'rgba(254,225,43,0.05)' },
- { nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
- { nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
- { nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
- ]
-
- testCases.forEach(({ nestingLevel, state, expectedBg }) => {
- const styles: Record = {
- backgroundColor: state === 'valid' ? 'rgba(254,225,43,0.05)' : 'transparent',
- }
-
- if (nestingLevel > 0) {
- const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
- const colorIndex = (nestingLevel - 1) % colors.length
- styles.backgroundColor = `${colors[colorIndex]}30`
- }
-
- expect(styles.backgroundColor).toBe(expectedBg)
- })
- })
- })
-
- describe('Parallel-Specific Features', () => {
- it.concurrent('should handle parallel execution states', () => {
- const parallelStates = ['valid', 'invalid', 'executing', 'completed', 'pending']
-
- parallelStates.forEach((state) => {
- const data = { width: 500, height: 300, state }
- expect(data.state).toBe(state)
-
- const isExecuting = state === 'executing'
- const isCompleted = state === 'completed'
-
- expect(typeof isExecuting).toBe('boolean')
- expect(typeof isCompleted).toBe('boolean')
- })
- })
-
- it.concurrent('should handle parallel node color scheme', () => {
- const parallelColors = {
- background: 'rgba(254,225,43,0.05)',
- ring: '#FEE12B',
- startIcon: '#FEE12B',
- }
-
- expect(parallelColors.background).toContain('254,225,43')
- expect(parallelColors.ring).toBe('#FEE12B')
- expect(parallelColors.startIcon).toBe('#FEE12B')
- })
-
- it.concurrent('should differentiate from loop node styling', () => {
- const loopColors = {
- background: 'rgba(34,197,94,0.05)',
- ring: '#2FB3FF',
- startIcon: '#2FB3FF',
- }
-
- const parallelColors = {
- background: 'rgba(254,225,43,0.05)',
- ring: '#FEE12B',
- startIcon: '#FEE12B',
- }
-
- expect(parallelColors.background).not.toBe(loopColors.background)
- expect(parallelColors.ring).not.toBe(loopColors.ring)
- expect(parallelColors.startIcon).not.toBe(loopColors.startIcon)
- })
- })
-
- describe('Component Configuration', () => {
- it.concurrent('should handle different dimensions', () => {
- const dimensionTests = [
- { width: 500, height: 300 },
- { width: 800, height: 600 },
- { width: 0, height: 0 },
- { width: 10000, height: 10000 },
- ]
-
- dimensionTests.forEach(({ width, height }) => {
- const data = { width, height, state: 'valid' }
- expect(data.width).toBe(width)
- expect(data.height).toBe(height)
- })
- })
-
- it.concurrent('should handle different states', () => {
- const stateTests = ['valid', 'invalid', 'pending', 'executing', 'completed']
-
- stateTests.forEach((state) => {
- const data = { width: 500, height: 300, state }
- expect(data.state).toBe(state)
- })
- })
- })
-
- describe('Event Handling Logic', () => {
- it.concurrent('should handle delete button click logic', () => {
- const mockEvent = {
- stopPropagation: vi.fn(),
- }
-
- const handleDelete = (e: any, nodeId: string) => {
- e.stopPropagation()
- mockRemoveBlock(nodeId)
- }
-
- handleDelete(mockEvent, 'test-id')
-
- expect(mockEvent.stopPropagation).toHaveBeenCalled()
- expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
- })
-
- it.concurrent('should handle event propagation prevention', () => {
- const mockEvent = {
- stopPropagation: vi.fn(),
- }
-
- mockEvent.stopPropagation()
- expect(mockEvent.stopPropagation).toHaveBeenCalled()
- })
- })
-
- describe('Component Data Handling', () => {
- it.concurrent('should handle missing data properties gracefully', () => {
- const testCases = [
- undefined,
- {},
- { width: 500 },
- { height: 300 },
- { state: 'valid' },
- { width: 500, height: 300 },
- ]
-
- testCases.forEach((data) => {
- const props = { ...defaultProps, data }
-
- // Test default values logic
- const width = data?.width || 500
- const height = data?.height || 300
-
- expect(width).toBeGreaterThanOrEqual(0)
- expect(height).toBeGreaterThanOrEqual(0)
- })
- })
-
- it.concurrent('should handle parent ID relationships', () => {
- const testCases = [
- { parentId: undefined, hasParent: false },
- { parentId: 'parent-1', hasParent: true },
- { parentId: '', hasParent: false },
- ]
-
- testCases.forEach(({ parentId, hasParent }) => {
- const data = { ...defaultProps.data, parentId }
- expect(Boolean(data.parentId)).toBe(hasParent)
- })
- })
- })
-
- describe('Handle Configuration', () => {
- it.concurrent('should have correct handle IDs for parallel nodes', () => {
- const handleIds = {
- startSource: 'parallel-start-source',
- endSource: 'parallel-end-source',
- }
-
- expect(handleIds.startSource).toContain('parallel')
- expect(handleIds.endSource).toContain('parallel')
- expect(handleIds.startSource).not.toContain('loop')
- expect(handleIds.endSource).not.toContain('loop')
- })
-
- it.concurrent('should handle different handle positions', () => {
- const positions = {
- left: 'left',
- right: 'right',
- top: 'top',
- bottom: 'bottom',
- }
-
- Object.values(positions).forEach((position) => {
- expect(typeof position).toBe('string')
- expect(position.length).toBeGreaterThan(0)
- })
- })
- })
-
- describe('Edge Cases and Error Handling', () => {
- it.concurrent('should handle circular parent references', () => {
- // Test circular reference prevention
- const nodes = [
- { id: 'node1', data: { parentId: 'node2' } },
- { id: 'node2', data: { parentId: 'node1' } },
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- // Test the actual component's nesting level calculation logic
- // This simulates the real useMemo logic from the component
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- // This is the actual logic pattern used in the component
- while (currentParentId) {
- // If we've seen this parent before, we have a cycle - break immediately
- if (visited.has(currentParentId)) {
- break
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // With proper circular reference detection, we should stop at level 2
- // (node1 -> node2, then detect cycle when trying to go back to node1)
- expect(level).toBe(2)
- expect(visited.has('node1')).toBe(true)
- expect(visited.has('node2')).toBe(true)
- })
-
- it.concurrent('should handle complex circular reference chains', () => {
- // Test more complex circular reference scenarios
- const nodes = [
- { id: 'node1', data: { parentId: 'node2' } },
- { id: 'node2', data: { parentId: 'node3' } },
- { id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- while (currentParentId) {
- if (visited.has(currentParentId)) {
- break // Cycle detected
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // Should traverse node1 -> node2 -> node3, then detect cycle
- expect(level).toBe(3)
- expect(visited.size).toBe(3)
- })
-
- it.concurrent('should handle self-referencing nodes', () => {
- // Test node that references itself
- const nodes = [
- { id: 'node1', data: { parentId: 'node1' } }, // Self-reference
- ]
-
- mockGetNodes.mockReturnValue(nodes)
-
- let level = 0
- let currentParentId = 'node1'
- const visited = new Set()
-
- while (currentParentId) {
- if (visited.has(currentParentId)) {
- break // Cycle detected immediately
- }
-
- visited.add(currentParentId)
- level++
-
- const parentNode = nodes.find((n) => n.id === currentParentId)
- if (!parentNode) break
-
- currentParentId = parentNode.data?.parentId
- }
-
- // Should detect self-reference immediately after first iteration
- expect(level).toBe(1)
- expect(visited.has('node1')).toBe(true)
- })
-
- it.concurrent('should handle extreme values', () => {
- const extremeValues = [
- { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
- { width: -1, height: -1 },
- { width: 0, height: 0 },
- { width: null, height: null },
- ]
-
- extremeValues.forEach((data) => {
- expect(() => {
- const width = data.width || 500
- const height = data.height || 300
- expect(typeof width).toBe('number')
- expect(typeof height).toBe('number')
- }).not.toThrow()
- })
- })
-
- it.concurrent('should handle negative position values', () => {
- const positions = [
- { xPos: -100, yPos: -200 },
- { xPos: 0, yPos: 0 },
- { xPos: 1000, yPos: 2000 },
- ]
-
- positions.forEach(({ xPos, yPos }) => {
- const props = { ...defaultProps, xPos, yPos }
- expect(props.xPos).toBe(xPos)
- expect(props.yPos).toBe(yPos)
- expect(typeof props.xPos).toBe('number')
- expect(typeof props.yPos).toBe('number')
- })
- })
- })
-
- describe('Component Comparison with Loop Node', () => {
- it.concurrent('should have similar structure to loop node but different type', () => {
- expect(defaultProps.type).toBe('parallelNode')
- expect(defaultProps.id).toContain('parallel')
-
- // Should not be a loop node
- expect(defaultProps.type).not.toBe('loopNode')
- expect(defaultProps.id).not.toContain('loop')
- })
-
- it.concurrent('should handle the same prop structure as loop node', () => {
- // Test that parallel node accepts the same prop structure as loop node
- const sharedPropStructure = {
- id: 'test-parallel',
- type: 'parallelNode' as const,
- data: {
- width: 400,
- height: 300,
- state: 'valid' as const,
- },
- selected: false,
- zIndex: 1,
- isConnectable: true,
- xPos: 0,
- yPos: 0,
- dragging: false,
- }
-
- expect(() => {
- const _component: typeof ParallelNodeComponent = ParallelNodeComponent
- expect(_component).toBeDefined()
- }).not.toThrow()
-
- // Verify the structure
- expect(sharedPropStructure.type).toBe('parallelNode')
- expect(sharedPropStructure.data.width).toBe(400)
- expect(sharedPropStructure.data.height).toBe(300)
- })
-
- it.concurrent('should maintain consistency with loop node interface', () => {
- const baseProps = [
- 'id',
- 'type',
- 'data',
- 'selected',
- 'zIndex',
- 'isConnectable',
- 'xPos',
- 'yPos',
- 'dragging',
- ]
-
- baseProps.forEach((prop) => {
- expect(defaultProps).toHaveProperty(prop)
- })
- })
- })
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.tsx
deleted file mode 100644
index c20de54cb9..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import type React from 'react'
-import { memo, useMemo, useRef } from 'react'
-import { Trash2 } from 'lucide-react'
-import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
-import { StartIcon } from '@/components/icons'
-import { Button } from '@/components/ui/button'
-import { Card } from '@/components/ui/card'
-import { cn } from '@/lib/utils'
-import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
-import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
-import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
-
-const ParallelNodeStyles: React.FC = () => {
- return (
-
- )
-}
-
-export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => {
- const { getNodes } = useReactFlow()
- const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
- const blockRef = useRef(null)
-
- // Use the clean abstraction for current workflow state
- const currentWorkflow = useCurrentWorkflow()
- const currentBlock = currentWorkflow.getBlockById(id)
- const diffStatus =
- currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
-
- // Check if this is preview mode
- const isPreview = data?.isPreview || false
-
- // Determine nesting level by counting parents
- const nestingLevel = useMemo(() => {
- const maxDepth = 100 // Prevent infinite loops
- let level = 0
- let currentParentId = data?.parentId
-
- while (currentParentId && level < maxDepth) {
- level++
- const parentNode = getNodes().find((n) => n.id === currentParentId)
- if (!parentNode) break
- currentParentId = parentNode.data?.parentId
- }
-
- return level
- }, [id, data?.parentId, getNodes])
-
- // Generate different background styles based on nesting level
- const getNestedStyles = () => {
- // Base styles
- const styles: Record = {
- backgroundColor: 'rgba(0, 0, 0, 0.02)',
- }
-
- // Apply nested styles
- if (nestingLevel > 0) {
- // Each nesting level gets a different color
- const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
- const colorIndex = (nestingLevel - 1) % colors.length
-
- styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background
- }
-
- return styles
- }
-
- const nestedStyles = getNestedStyles()
-
- return (
- <>
-
-
-
0 &&
- `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
- data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
- // Diff highlighting
- diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
- diffStatus === 'edited' &&
- 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
- )}
- style={{
- width: data.width || 500,
- height: data.height || 300,
- position: 'relative',
- overflow: 'visible',
- ...nestedStyles,
- pointerEvents: isPreview ? 'none' : 'all',
- }}
- data-node-id={id}
- data-type='parallelNode'
- data-nesting-level={nestingLevel}
- >
- {/* Critical drag handle that controls only the parallel node movement */}
- {!isPreview && (
-
- )}
-
- {/* Custom visible resize handle */}
- {!isPreview && (
-
- )}
-
- {/* Child nodes container - Set pointerEvents to allow dragging of children */}
-
- {/* Delete button - styled like in action-bar.tsx */}
- {!isPreview && (
-
- )}
-
- {/* Parallel Start Block */}
-
-
-
-
-
-
-
- {/* Input handle on left middle */}
-
-
- {/* Output handle on right middle */}
-
-
- {/* Parallel Configuration Badges */}
-
-
-
- >
- )
-})
-
-ParallelNodeComponent.displayName = 'ParallelNodeComponent'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.test.tsx
new file mode 100644
index 0000000000..d740b12e6d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.test.tsx
@@ -0,0 +1,579 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
+
+// Shared spies used across mocks
+const mockRemoveBlock = vi.fn()
+const mockGetNodes = vi.fn()
+
+// Mocks
+vi.mock('@/hooks/use-collaborative-workflow', () => ({
+ useCollaborativeWorkflow: vi.fn(() => ({
+ collaborativeRemoveBlock: mockRemoveBlock,
+ })),
+}))
+
+vi.mock('@/lib/logs/console/logger', () => ({
+ createLogger: vi.fn(() => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ })),
+}))
+
+vi.mock('reactflow', () => ({
+ Handle: ({ id, type, position }: any) => ({ id, type, position }),
+ Position: {
+ Top: 'top',
+ Bottom: 'bottom',
+ Left: 'left',
+ Right: 'right',
+ },
+ useReactFlow: () => ({
+ getNodes: mockGetNodes,
+ }),
+ memo: (component: any) => component,
+}))
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react')
+ return {
+ ...actual,
+ memo: (component: any) => component,
+ useMemo: (fn: any) => fn(),
+ useRef: () => ({ current: null }),
+ }
+})
+
+vi.mock('@/components/ui/button', () => ({
+ Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
+}))
+
+vi.mock('@/components/ui/card', () => ({
+ Card: ({ children, ...props }: any) => ({ children, ...props }),
+}))
+
+vi.mock('@/components/icons', async (importOriginal) => {
+ const actual = (await importOriginal()) as any
+ return {
+ ...actual,
+ StartIcon: ({ className }: any) => ({ className }),
+ }
+})
+
+vi.mock('@/lib/utils', () => ({
+ cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
+}))
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges',
+ () => ({
+ IterationBadges: ({ nodeId, iterationType }: any) => ({ nodeId, iterationType }),
+ })
+)
+
+describe('SubflowNodeComponent', () => {
+ const defaultProps = {
+ id: 'subflow-1',
+ type: 'subflowNode',
+ data: {
+ width: 500,
+ height: 300,
+ isPreview: false,
+ kind: 'loop' as const,
+ },
+ selected: false,
+ zIndex: 1,
+ isConnectable: true,
+ xPos: 0,
+ yPos: 0,
+ dragging: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetNodes.mockReturnValue([])
+ })
+
+ describe('Component Definition and Structure', () => {
+ it.concurrent('should be defined as a function component', () => {
+ expect(SubflowNodeComponent).toBeDefined()
+ expect(typeof SubflowNodeComponent).toBe('function')
+ })
+
+ it.concurrent('should have correct display name', () => {
+ expect(SubflowNodeComponent.displayName).toBe('SubflowNodeComponent')
+ })
+
+ it.concurrent('should be a memoized component', () => {
+ expect(SubflowNodeComponent).toBeDefined()
+ })
+ })
+
+ describe('Props Validation and Type Safety', () => {
+ it.concurrent('should accept NodeProps interface', () => {
+ const validProps = {
+ id: 'test-id',
+ type: 'subflowNode' as const,
+ data: {
+ width: 400,
+ height: 300,
+ isPreview: true,
+ kind: 'parallel' as const,
+ },
+ selected: false,
+ zIndex: 1,
+ isConnectable: true,
+ xPos: 0,
+ yPos: 0,
+ dragging: false,
+ }
+
+ expect(() => {
+ const _component: typeof SubflowNodeComponent = SubflowNodeComponent
+ expect(_component).toBeDefined()
+ expect(validProps.type).toBe('subflowNode')
+ }).not.toThrow()
+ })
+
+ it.concurrent('should handle different data configurations', () => {
+ const configurations = [
+ { width: 500, height: 300, isPreview: false, kind: 'loop' as const },
+ { width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
+ { width: 0, height: 0, isPreview: false, kind: 'loop' as const },
+ { kind: 'loop' as const },
+ ]
+
+ configurations.forEach((data) => {
+ const props = { ...defaultProps, data }
+ expect(() => {
+ const _component: typeof SubflowNodeComponent = SubflowNodeComponent
+ expect(_component).toBeDefined()
+ expect(props.data).toBeDefined()
+ }).not.toThrow()
+ })
+ })
+ })
+
+ describe('Hook Integration', () => {
+ it.concurrent('should provide collaborativeRemoveBlock', () => {
+ expect(mockRemoveBlock).toBeDefined()
+ expect(typeof mockRemoveBlock).toBe('function')
+ mockRemoveBlock('test-id')
+ expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
+ })
+ })
+
+ describe('Component Logic Tests', () => {
+ it.concurrent('should handle nesting level calculation logic', () => {
+ const testCases = [
+ { nodes: [], parentId: undefined, expectedLevel: 0 },
+ { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
+ {
+ nodes: [
+ { id: 'parent', data: { parentId: 'grandparent' } },
+ { id: 'grandparent', data: {} },
+ ],
+ parentId: 'parent',
+ expectedLevel: 2,
+ },
+ ]
+
+ testCases.forEach(({ nodes, parentId, expectedLevel }) => {
+ mockGetNodes.mockReturnValue(nodes)
+
+ // Simulate the nesting level calculation logic
+ let level = 0
+ let currentParentId = parentId
+
+ while (currentParentId) {
+ level++
+ const parentNode = nodes.find((n) => n.id === currentParentId)
+ if (!parentNode) break
+ currentParentId = parentNode.data?.parentId
+ }
+
+ expect(level).toBe(expectedLevel)
+ })
+ })
+
+ it.concurrent('should handle nested styles generation', () => {
+ // Test the nested styles logic
+ const testCases = [
+ { nestingLevel: 0, expectedBg: 'rgba(34,197,94,0.05)' },
+ { nestingLevel: 1, expectedBg: '#e2e8f030' },
+ { nestingLevel: 2, expectedBg: '#cbd5e130' },
+ ]
+
+ testCases.forEach(({ nestingLevel, expectedBg }) => {
+ // Simulate the getNestedStyles logic
+ const styles: Record = {
+ backgroundColor: 'rgba(34,197,94,0.05)',
+ }
+
+ if (nestingLevel > 0) {
+ const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
+ const colorIndex = (nestingLevel - 1) % colors.length
+ styles.backgroundColor = `${colors[colorIndex]}30`
+ }
+
+ expect(styles.backgroundColor).toBe(expectedBg)
+ })
+ })
+ })
+
+ describe('Component Configuration', () => {
+ it.concurrent('should handle different dimensions', () => {
+ const dimensionTests = [
+ { width: 500, height: 300 },
+ { width: 800, height: 600 },
+ { width: 0, height: 0 },
+ { width: 10000, height: 10000 },
+ ]
+
+ dimensionTests.forEach(({ width, height }) => {
+ const data = { width, height }
+ expect(data.width).toBe(width)
+ expect(data.height).toBe(height)
+ })
+ })
+ })
+
+ describe('Event Handling Logic', () => {
+ it.concurrent('should handle delete button click logic (simulated)', () => {
+ const mockEvent = { stopPropagation: vi.fn() }
+
+ const handleDelete = (e: any, nodeId: string) => {
+ e.stopPropagation()
+ mockRemoveBlock(nodeId)
+ }
+
+ handleDelete(mockEvent, 'test-id')
+
+ expect(mockEvent.stopPropagation).toHaveBeenCalled()
+ expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
+ })
+
+ it.concurrent('should handle event propagation prevention', () => {
+ const mockEvent = { stopPropagation: vi.fn() }
+ mockEvent.stopPropagation()
+ expect(mockEvent.stopPropagation).toHaveBeenCalled()
+ })
+ })
+
+ describe('Component Data Handling', () => {
+ it.concurrent('should handle missing data properties gracefully', () => {
+ const testCases = [
+ undefined,
+ {},
+ { width: 500 },
+ { height: 300 },
+ { width: 500, height: 300 },
+ ]
+
+ testCases.forEach((data: any) => {
+ const props = { ...defaultProps, data }
+ const width = Math.max(0, data?.width || 500)
+ const height = Math.max(0, data?.height || 300)
+ expect(width).toBeGreaterThanOrEqual(0)
+ expect(height).toBeGreaterThanOrEqual(0)
+ expect(props.type).toBe('subflowNode')
+ })
+ })
+
+ it.concurrent('should handle parent ID relationships', () => {
+ const testCases = [
+ { parentId: undefined, hasParent: false },
+ { parentId: 'parent-1', hasParent: true },
+ { parentId: '', hasParent: false },
+ ]
+
+ testCases.forEach(({ parentId, hasParent }) => {
+ const data = { ...defaultProps.data, parentId }
+ expect(Boolean(data.parentId)).toBe(hasParent)
+ })
+ })
+ })
+
+ describe('Loop vs Parallel Kind Specific Tests', () => {
+ it.concurrent('should generate correct handle IDs for loop kind', () => {
+ const loopData = { ...defaultProps.data, kind: 'loop' as const }
+ const startHandleId = loopData.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = loopData.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
+
+ expect(startHandleId).toBe('loop-start-source')
+ expect(endHandleId).toBe('loop-end-source')
+ })
+
+ it.concurrent('should generate correct handle IDs for parallel kind', () => {
+ type SubflowKind = 'loop' | 'parallel'
+ const testHandleGeneration = (kind: SubflowKind) => {
+ const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
+ return { startHandleId, endHandleId }
+ }
+
+ const result = testHandleGeneration('parallel')
+ expect(result.startHandleId).toBe('parallel-start-source')
+ expect(result.endHandleId).toBe('parallel-end-source')
+ })
+
+ it.concurrent('should generate correct background colors for loop kind', () => {
+ const loopData = { ...defaultProps.data, kind: 'loop' as const }
+ const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
+
+ expect(startBg).toBe('#2FB3FF')
+ })
+
+ it.concurrent('should generate correct background colors for parallel kind', () => {
+ type SubflowKind = 'loop' | 'parallel'
+ const testBgGeneration = (kind: SubflowKind) => {
+ return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
+ }
+
+ const startBg = testBgGeneration('parallel')
+ expect(startBg).toBe('#FEE12B')
+ })
+
+ it.concurrent('should demonstrate handle ID generation for any kind', () => {
+ type SubflowKind = 'loop' | 'parallel'
+ const testKind = (kind: SubflowKind) => {
+ const data = { kind }
+ const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
+ return { startHandleId, endHandleId }
+ }
+
+ const loopResult = testKind('loop')
+ expect(loopResult.startHandleId).toBe('loop-start-source')
+ expect(loopResult.endHandleId).toBe('loop-end-source')
+
+ const parallelResult = testKind('parallel')
+ expect(parallelResult.startHandleId).toBe('parallel-start-source')
+ expect(parallelResult.endHandleId).toBe('parallel-end-source')
+ })
+
+ it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
+ const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
+ // Mock IterationBadges should receive the kind as iterationType
+ expect(loopProps.data.kind).toBe('loop')
+ })
+
+ it.concurrent('should pass correct iterationType to IterationBadges for parallel', () => {
+ const parallelProps = {
+ ...defaultProps,
+ data: { ...defaultProps.data, kind: 'parallel' as const },
+ }
+ // Mock IterationBadges should receive the kind as iterationType
+ expect(parallelProps.data.kind).toBe('parallel')
+ })
+
+ it.concurrent('should handle both kinds in configuration arrays', () => {
+ const bothKinds = ['loop', 'parallel'] as const
+ bothKinds.forEach((kind) => {
+ const data = { ...defaultProps.data, kind }
+ expect(['loop', 'parallel']).toContain(data.kind)
+
+ // Test handle ID generation for both kinds
+ const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
+ const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
+
+ if (kind === 'loop') {
+ expect(startHandleId).toBe('loop-start-source')
+ expect(endHandleId).toBe('loop-end-source')
+ expect(startBg).toBe('#2FB3FF')
+ } else {
+ expect(startHandleId).toBe('parallel-start-source')
+ expect(endHandleId).toBe('parallel-end-source')
+ expect(startBg).toBe('#FEE12B')
+ }
+ })
+ })
+
+ it.concurrent('should maintain consistent styling behavior across both kinds', () => {
+ const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
+ const parallelProps = {
+ ...defaultProps,
+ data: { ...defaultProps.data, kind: 'parallel' as const },
+ }
+
+ // Both should have same base properties except kind-specific ones
+ expect(loopProps.data.width).toBe(parallelProps.data.width)
+ expect(loopProps.data.height).toBe(parallelProps.data.height)
+ expect(loopProps.data.isPreview).toBe(parallelProps.data.isPreview)
+
+ // But different kinds
+ expect(loopProps.data.kind).toBe('loop')
+ expect(parallelProps.data.kind).toBe('parallel')
+ })
+ })
+
+ describe('Integration with IterationBadges', () => {
+ it.concurrent('should pass nodeId to IterationBadges', () => {
+ const testId = 'test-subflow-123'
+ const props = { ...defaultProps, id: testId }
+
+ // Verify the props would be passed correctly
+ expect(props.id).toBe(testId)
+ })
+
+ it.concurrent('should pass data object to IterationBadges', () => {
+ const testData = { ...defaultProps.data, customProperty: 'test' }
+ const props = { ...defaultProps, data: testData }
+
+ // Verify the data object structure
+ expect(props.data).toEqual(testData)
+ expect(props.data.kind).toBeDefined()
+ })
+
+ it.concurrent('should pass iterationType matching the kind', () => {
+ const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
+ const parallelProps = {
+ ...defaultProps,
+ data: { ...defaultProps.data, kind: 'parallel' as const },
+ }
+
+ // The iterationType should match the kind
+ expect(loopProps.data.kind).toBe('loop')
+ expect(parallelProps.data.kind).toBe('parallel')
+ })
+ })
+
+ describe('CSS Class Generation', () => {
+ it.concurrent('should generate proper CSS classes for nested loops', () => {
+ const nestingLevel = 2
+ const expectedBorderClass =
+ nestingLevel > 0 &&
+ `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
+
+ expect(expectedBorderClass).toBeTruthy()
+ expect(expectedBorderClass).toContain('border-slate-300/60') // even nesting level
+ })
+
+ it.concurrent('should generate proper CSS classes for odd nested levels', () => {
+ const nestingLevel = 3
+ const expectedBorderClass =
+ nestingLevel > 0 &&
+ `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
+
+ expect(expectedBorderClass).toBeTruthy()
+ expect(expectedBorderClass).toContain('border-slate-400/60') // odd nesting level
+ })
+
+ it.concurrent('should handle error state styling', () => {
+ const hasNestedError = true
+ const errorClasses = hasNestedError && 'border-2 border-red-500 bg-red-50/50'
+
+ expect(errorClasses).toBe('border-2 border-red-500 bg-red-50/50')
+ })
+
+ it.concurrent('should handle diff status styling', () => {
+ const diffStatuses = ['new', 'edited'] as const
+
+ diffStatuses.forEach((status) => {
+ let diffClass = ''
+ if (status === 'new') {
+ diffClass = 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10'
+ } else if (status === 'edited') {
+ diffClass = 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
+ }
+
+ expect(diffClass).toBeTruthy()
+ if (status === 'new') {
+ expect(diffClass).toContain('ring-green-500')
+ } else {
+ expect(diffClass).toContain('ring-orange-500')
+ }
+ })
+ })
+ })
+
+ describe('Edge Cases and Error Handling', () => {
+ it.concurrent('should handle circular parent references', () => {
+ const nodes = [
+ { id: 'node1', data: { parentId: 'node2' } },
+ { id: 'node2', data: { parentId: 'node1' } },
+ ]
+
+ mockGetNodes.mockReturnValue(nodes)
+
+ let level = 0
+ let currentParentId = 'node1'
+ const visited = new Set()
+
+ while (currentParentId) {
+ if (visited.has(currentParentId)) {
+ break
+ }
+
+ visited.add(currentParentId)
+ level++
+
+ const parentNode = nodes.find((n) => n.id === currentParentId)
+ if (!parentNode) break
+ currentParentId = parentNode.data?.parentId
+ }
+
+ expect(level).toBe(2)
+ expect(visited.has('node1')).toBe(true)
+ expect(visited.has('node2')).toBe(true)
+ })
+
+ it.concurrent('should handle complex circular reference chains', () => {
+ const nodes = [
+ { id: 'node1', data: { parentId: 'node2' } },
+ { id: 'node2', data: { parentId: 'node3' } },
+ { id: 'node3', data: { parentId: 'node1' } },
+ ]
+
+ mockGetNodes.mockReturnValue(nodes)
+
+ let level = 0
+ let currentParentId = 'node1'
+ const visited = new Set()
+
+ while (currentParentId) {
+ if (visited.has(currentParentId)) {
+ break
+ }
+
+ visited.add(currentParentId)
+ level++
+
+ const parentNode = nodes.find((n) => n.id === currentParentId)
+ if (!parentNode) break
+ currentParentId = parentNode.data?.parentId
+ }
+
+ expect(level).toBe(3)
+ expect(visited.size).toBe(3)
+ })
+
+ it.concurrent('should handle self-referencing nodes', () => {
+ const nodes = [{ id: 'node1', data: { parentId: 'node1' } }]
+
+ mockGetNodes.mockReturnValue(nodes)
+
+ let level = 0
+ let currentParentId = 'node1'
+ const visited = new Set()
+
+ while (currentParentId) {
+ if (visited.has(currentParentId)) {
+ break
+ }
+
+ visited.add(currentParentId)
+ level++
+
+ const parentNode = nodes.find((n) => n.id === currentParentId)
+ if (!parentNode) break
+ currentParentId = parentNode.data?.parentId
+ }
+
+ expect(level).toBe(1)
+ expect(visited.has('node1')).toBe(true)
+ })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
similarity index 75%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
index ffe413d195..c4ea4c6c65 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
@@ -6,60 +6,54 @@ import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
+import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
-// Add these styles to your existing global CSS file or create a separate CSS module
-const LoopNodeStyles: React.FC = () => {
+const SubflowNodeStyles: React.FC = () => {
return (