fix(subflow): add ability to remove block from subflow and refactor to consolidate subflow code (#983)

* added logic to remove blocks from subflows

* refactored logic into just subflow-node

* bun run lint

* added subflow test

* added a safety check for data.parentId

* added state update logic

* bun run lint

* removed old logic

* removed any

* added tests

* added type safety

* removed test script

* type safety

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
Adam Gough
2025-08-17 22:25:31 -07:00
committed by GitHub
parent bd38062705
commit 5c16e7d390
21 changed files with 1241 additions and 1469 deletions

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -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) => (
<div data-testid='badge' {...props}>
{children}
</div>
),
}))
vi.mock('@/components/ui/input', () => ({
Input: (props: any) => <input data-testid='input' {...props} />,
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: any) => <div data-testid='popover'>{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid='popover-content'>{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid='popover-trigger'>{children}</div>,
}))
vi.mock('@/components/ui/tag-dropdown', () => ({
checkTagTrigger: vi.fn(() => ({ show: false })),
TagDropdown: ({ children }: any) => <div data-testid='tag-dropdown'>{children}</div>,
}))
vi.mock('react-simple-code-editor', () => ({
default: (props: any) => <textarea data-testid='code-editor' {...props} />,
}))
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')
})
})
})

View File

@@ -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<string, string> = {
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<string>()
// 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<string>()
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<string>()
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()
})
})
})
})

View File

@@ -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<string, string> = {
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<string>()
// 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<string>()
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<string>()
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)
})
})
})
})

View File

@@ -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 (
<style jsx global>{`
@keyframes parallel-node-pulse {
0% {
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3);
}
70% {
box-shadow: 0 0 0 6px rgba(139, 195, 74, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0);
}
}
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1)
infinite;
border-style: solid !important;
background-color: rgba(139, 195, 74, 0.08) !important;
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
/* Make resizer handles more visible */
.react-flow__resize-control {
z-index: 10;
pointer-events: all !important;
}
/* Ensure parent borders are visible when hovering over resize controls */
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* React Flow position transitions within parallel blocks */
.react-flow__node[data-parent-node-id] {
transition: transform 0.05s ease;
pointer-events: all;
}
/* Prevent jumpy drag behavior */
.parallel-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(139, 195, 74, 0.05);
transition: all 0.2s ease-in-out;
}
`}</style>
)
}
export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(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<string, string> = {
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 (
<>
<ParallelNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid',
nestingLevel > 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 && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
collaborativeRemoveBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Parallel Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='parallel-start'
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
<Handle
type='source'
position={Position.Right}
id='parallel-start-source'
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
style={{
left: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='parallel-end-source'
/>
{/* Parallel Configuration Badges */}
<IterationBadges nodeId={id} data={data} iterationType='parallel' />
</Card>
</div>
</>
)
})
ParallelNodeComponent.displayName = 'ParallelNodeComponent'

View File

@@ -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<any>('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<string, string> = {
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<string>()
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<string>()
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<string>()
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)
})
})
})

View File

@@ -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 (
<style jsx global>{`
@keyframes loop-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); }
100% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); }
0% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(47, 179, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0); }
}
@keyframes parallel-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(139, 195, 74, 0); }
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
}
.loop-node-drag-over {
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(47, 179, 255, 0.08) !important;
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
}
/* Ensure parent borders are visible when hovering over resize controls */
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(139, 195, 74, 0.08) !important;
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* Prevent jumpy drag behavior */
.loop-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(34,197,94,0.05);
transition: all 0.2s ease-in-out;
@@ -68,21 +62,30 @@ const LoopNodeStyles: React.FC = () => {
)
}
export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
export interface SubflowNodeData {
width?: number
height?: number
parentId?: string
extent?: 'parent'
hasNestedError?: boolean
isPreview?: boolean
kind: 'loop' | 'parallel'
}
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(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
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.is_diff
: undefined
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
let level = 0
let currentParentId = data?.parentId
@@ -97,42 +100,37 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
return level
}, [id, data?.parentId, getNodes])
// Generate different background styles based on nesting level
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
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
styles.backgroundColor = `${colors[colorIndex]}30`
}
return styles
}
const nestedStyles = getNestedStyles()
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'
return (
<>
<LoopNodeStyles />
<SubflowNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
' relative cursor-default select-none',
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid',
nestingLevel > 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'
@@ -146,10 +144,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='loopNode'
data-type='subflowNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the loop node movement */}
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
@@ -157,7 +154,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
/>
)}
{/* Custom visible resize handle */}
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
@@ -165,7 +161,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
/>
)}
{/* Child nodes container - Enable pointer events to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
@@ -175,7 +170,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
{!isPreview && (
<Button
variant='ghost'
@@ -191,12 +185,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
</Button>
)}
{/* Loop Start Block */}
{/* Subflow Start */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto', backgroundColor: startBg }}
data-parent-id={id}
data-node-role='loop-start'
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
@@ -204,7 +198,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
<Handle
type='source'
position={Position.Right}
id='loop-start-source'
id={startHandleId}
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
@@ -241,15 +235,14 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='loop-end-source'
id={endHandleId}
/>
{/* Loop Configuration Badges */}
<IterationBadges nodeId={id} data={data} iterationType='loop' />
<IterationBadges nodeId={id} data={data} iterationType={data.kind} />
</Card>
</div>
</>
)
})
LoopNodeComponent.displayName = 'LoopNodeComponent'
SubflowNodeComponent.displayName = 'SubflowNodeComponent'

View File

@@ -1,4 +1,4 @@
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, LogOut, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -23,6 +23,10 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
const horizontalHandles = useWorkflowStore(
(state) => state.blocks[blockId]?.horizontalHandles ?? false
)
const parentId = useWorkflowStore((state) => state.blocks[blockId]?.data?.parentId)
const parentType = useWorkflowStore((state) =>
parentId ? state.blocks[parentId]?.type : undefined
)
const userPermissions = useUserPermissionsContext()
const isStarterBlock = blockType === 'starter'
@@ -102,6 +106,33 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
</Tooltip>
)}
{/* Remove from subflow - only show when inside loop/parallel */}
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { AlertTriangle, Info } from 'lucide-react'
import { Label, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { cn } from '@/lib/utils'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import {
ChannelSelectorInput,
CheckboxList,
@@ -43,7 +44,7 @@ interface SubBlockProps {
isPreview?: boolean
subBlockValues?: Record<string, any>
disabled?: boolean
fieldDiffStatus?: 'changed' | 'unchanged'
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
}

View File

@@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, validateName } from '@/lib/utils'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -76,12 +77,16 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
: (currentBlock?.enabled ?? true)
// Get diff status from the block itself (set by diff engine)
const diffStatus =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.is_diff
: undefined
// Get field-level diff information
// Get field-level diff information for this specific block
const fieldDiff =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diffs : undefined
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.field_diffs?.[id]
: undefined
// Debug: Log diff status for this block
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useCurrentWorkflow } from '../../hooks'
@@ -114,7 +115,7 @@ export const WorkflowEdge = ({
}, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff])
// Determine edge diff status
let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | null = null
let edgeDiffStatus: EdgeDiffStatus = null
// Only attempt to determine diff status if all required data is available
if (diffAnalysis?.edge_diff && edgeIdentifier && isDiffReady) {

View File

@@ -14,7 +14,8 @@ const isContainerType = (blockType: string): boolean => {
blockType === 'loop' ||
blockType === 'parallel' ||
blockType === 'loopNode' ||
blockType === 'parallelNode'
blockType === 'parallelNode' ||
blockType === 'subflowNode'
)
}
@@ -325,7 +326,10 @@ export const updateNodeParent = (
} else if (currentParentId) {
const absolutePosition = getNodeAbsolutePosition(nodeId, getNodes)
// First set the absolute position so the node visually stays in place
updateBlockPosition(nodeId, absolutePosition)
// Then clear the parent relationship in the store (empty string removes parentId/extent)
updateParentId(nodeId, '', 'parent')
}
resizeLoopNodes()

View File

@@ -18,8 +18,7 @@ import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/compone
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -48,8 +47,7 @@ const logger = createLogger('Workflow')
// Define custom node and edge types
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
loopNode: LoopNodeComponent,
parallelNode: ParallelNodeComponent,
subflowNode: SubflowNodeComponent,
}
const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge }
@@ -343,6 +341,48 @@ const WorkflowContent = React.memo(() => {
}
}, [debouncedAutoLayout])
// Listen for explicit remove-from-subflow actions from ActionBar
useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => {
const customEvent = event as CustomEvent<{ blockId: string }>
const { blockId } = customEvent.detail || ({} as any)
if (!blockId) return
try {
// Remove parent-child relationship while preserving absolute position
updateNodeParent(blockId, null)
// Clean up any edges that now cross container boundaries for this block
const rfNodes = getNodes()
const sourceOrTargetEdges = edgesForDisplay.filter(
(e) => e.source === blockId || e.target === blockId
)
sourceOrTargetEdges.forEach((edge) => {
const sourceNode = rfNodes.find((n) => n.id === edge.source)
const targetNode = rfNodes.find((n) => n.id === edge.target)
const sourceParent = sourceNode?.parentId
const targetParent = targetNode?.parentId
const crossesBoundary =
(sourceParent && !targetParent) ||
(!sourceParent && targetParent) ||
(sourceParent && targetParent && sourceParent !== targetParent)
if (crossesBoundary) {
removeEdge(edge.id)
}
})
} catch (err) {
logger.error('Failed to remove from subflow', { err })
}
}
window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
return () =>
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [getNodes, updateNodeParent, removeEdge, edgesForDisplay])
// Handle drops
const findClosestOutput = useCallback(
(newNodePosition: { x: number; y: number }): BlockData | null => {
@@ -451,7 +491,7 @@ const WorkflowContent = React.memo(() => {
{
width: 500,
height: 300,
type: type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
},
undefined,
undefined,
@@ -571,7 +611,7 @@ const WorkflowContent = React.memo(() => {
addBlock(id, data.type, name, relativePosition, {
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
parentId: containerInfo.loopId,
extent: 'parent',
})
@@ -607,7 +647,7 @@ const WorkflowContent = React.memo(() => {
{
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
},
undefined,
undefined,
@@ -657,10 +697,12 @@ const WorkflowContent = React.memo(() => {
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const containerType = containerNode?.type
if (containerType === 'loopNode' || containerType === 'parallelNode') {
if (containerType === 'subflowNode') {
// Connect from the container's start node to the new block
const startSourceHandle =
containerType === 'loopNode' ? 'loop-start-source' : 'parallel-start-source'
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
addEdge({
id: crypto.randomUUID(),
@@ -781,9 +823,15 @@ const WorkflowContent = React.memo(() => {
if (containerElement) {
// Determine the type of container node for appropriate styling
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (containerNode?.type === 'loopNode') {
if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (containerNode?.type === 'parallelNode') {
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
@@ -918,31 +966,11 @@ const WorkflowContent = React.memo(() => {
}
// Handle container nodes differently
if (block.type === 'loop') {
if (block.type === 'loop' || block.type === 'parallel') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
type: 'loopNode',
position: block.position,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle',
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
},
})
return
}
// Handle parallel nodes
if (block.type === 'parallel') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
type: 'parallelNode',
type: 'subflowNode',
position: block.position,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
@@ -952,6 +980,7 @@ const WorkflowContent = React.memo(() => {
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
kind: block.type === 'loop' ? 'loop' : 'parallel',
},
})
return
@@ -1191,13 +1220,13 @@ const WorkflowContent = React.memo(() => {
const intersectingNodes = getNodes()
.filter((n) => {
// Only consider container nodes that aren't the dragged node
if ((n.type !== 'loopNode' && n.type !== 'parallelNode') || n.id === node.id) return false
if (n.type !== 'subflowNode' || n.id === node.id) return false
// Skip if this container is already the parent of the node being dragged
if (n.id === currentParentId) return false
// Skip self-nesting: prevent a container from becoming its own descendant
if (node.type === 'loopNode' || node.type === 'parallelNode') {
if (node.type === 'subflowNode') {
// Get the full hierarchy of the potential parent
const hierarchy = getNodeHierarchyWrapper(n.id)
@@ -1212,14 +1241,14 @@ const WorkflowContent = React.memo(() => {
// Get dimensions based on node type
const nodeWidth =
node.type === 'loopNode' || node.type === 'parallelNode'
node.type === 'subflowNode'
? node.data?.width || 500
: node.type === 'condition'
? 250
: 350
const nodeHeight =
node.type === 'loopNode' || node.type === 'parallelNode'
node.type === 'subflowNode'
? node.data?.height || 300
: node.type === 'condition'
? 150
@@ -1286,9 +1315,15 @@ const WorkflowContent = React.memo(() => {
)
if (containerElement) {
// Apply appropriate class based on container type
if (bestContainerMatch.container.type === 'loopNode') {
if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (bestContainerMatch.container.type === 'parallelNode') {
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
@@ -1356,7 +1391,7 @@ const WorkflowContent = React.memo(() => {
}
// If we're dragging a container node, do additional checks to prevent circular references
if ((node.type === 'loopNode' || node.type === 'parallelNode') && potentialParentId) {
if (node.type === 'subflowNode' && potentialParentId) {
// Get the hierarchy of the potential parent container
const parentHierarchy = getNodeHierarchyWrapper(potentialParentId)

View File

@@ -15,8 +15,7 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { getBlock } from '@/blocks'
@@ -39,8 +38,7 @@ interface WorkflowPreviewProps {
// Define node types - the components now handle preview mode internally
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
loopNode: LoopNodeComponent,
parallelNode: ParallelNodeComponent,
subflowNode: SubflowNodeComponent,
}
// Define edge types
@@ -131,7 +129,7 @@ export function WorkflowPreview({
if (block.type === 'loop') {
nodeArray.push({
id: block.id,
type: 'loopNode',
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
@@ -142,6 +140,7 @@ export function WorkflowPreview({
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
kind: 'loop',
},
})
return
@@ -150,7 +149,7 @@ export function WorkflowPreview({
if (block.type === 'parallel') {
nodeArray.push({
id: block.id,
type: 'parallelNode',
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
@@ -161,6 +160,7 @@ export function WorkflowPreview({
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
kind: 'parallel',
},
})
return

View File

@@ -5,12 +5,14 @@ import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { Serializer } from '@/serializer'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
import { getTriggersByProvider } from '@/triggers'
@@ -51,8 +53,8 @@ export const checkTagTrigger = (text: string, cursorPosition: number): { show: b
const BLOCK_COLORS = {
VARIABLE: '#2F8BFF',
DEFAULT: '#2F55FF',
LOOP: '#8857E6',
PARALLEL: '#FF5757',
LOOP: '#2FB3FF',
PARALLEL: '#FEE12B',
} as const
const TAG_PREFIXES = {
@@ -73,11 +75,11 @@ const getSubBlockValue = (blockId: string, property: string): any => {
const createTagEventHandlers = (
tag: string,
group: any,
group: BlockTagGroup | undefined,
tagIndex: number,
handleTagSelect: (tag: string, group?: any) => void,
handleTagSelect: (tag: string, group?: BlockTagGroup) => void,
setSelectedIndex: (index: number) => void,
setHoveredNested: (value: any) => void
setHoveredNested: (value: { tag: string; index: number } | null) => void
) => ({
onMouseEnter: () => {
setSelectedIndex(tagIndex >= 0 ? tagIndex : 0)
@@ -96,8 +98,8 @@ const createTagEventHandlers = (
})
const getOutputTypeForPath = (
block: any,
blockConfig: any,
block: BlockState,
blockConfig: BlockConfig | null,
blockId: string,
outputPath: string
): string => {
@@ -137,7 +139,9 @@ const getOutputTypeForPath = (
// For API mode, check inputFormat for custom field types
const inputFormatValue = getSubBlockValue(blockId, 'inputFormat')
if (inputFormatValue && Array.isArray(inputFormatValue)) {
const field = inputFormatValue.find((f: any) => f.name === outputPath)
const field = inputFormatValue.find(
(f: { name?: string; type?: string }) => f.name === outputPath
)
if (field?.type) {
return field.type
}
@@ -224,7 +228,7 @@ const generateOutputPathsWithTypes = (
return paths
}
const generateToolOutputPaths = (blockConfig: any, operation: string): string[] => {
const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => {
if (!blockConfig?.tools?.config?.tool) return []
try {
@@ -244,7 +248,7 @@ const generateToolOutputPaths = (blockConfig: any, operation: string): string[]
}
}
const getToolOutputType = (blockConfig: any, operation: string, path: string): string => {
const getToolOutputType = (blockConfig: BlockConfig, operation: string, path: string): string => {
if (!blockConfig?.tools?.config?.tool) return 'any'
try {
@@ -366,9 +370,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const metricsValue = getSubBlockValue(activeSourceBlockId, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: any) => metric?.name)
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
blockTags = validMetrics.map(
(metric: any) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
)
} else {
const outputPaths = generateOutputPaths(blockConfig.outputs)
@@ -402,8 +406,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
inputFormatValue.length > 0
) {
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = [normalizedBlockName]
}
@@ -556,9 +560,14 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
)
let containingParallelBlockId: string | null = null
if (containingParallel) {
const [parallelId] = containingParallel
const [parallelId, parallel] = containingParallel
containingParallelBlockId = parallelId
const contextualTags: string[] = ['index', 'currentItem', 'items']
const parallelType = parallel.parallelType || 'count'
const contextualTags: string[] = ['index']
if (parallelType === 'collection') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingParallelBlock = blocks[parallelId]
if (containingParallelBlock) {
@@ -629,9 +638,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: any) => metric?.name)
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
blockTags = validMetrics.map(
(metric: any) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
)
} else {
const outputPaths = generateOutputPaths(blockConfig.outputs)
@@ -665,8 +674,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
inputFormatValue.length > 0
) {
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
} else {
blockTags = [normalizedBlockName]
}
@@ -880,8 +889,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
liveCursor = activeEl.selectionStart ?? cursorPosition
// Prefer the active element value if present. This ensures we include the most
// recently typed character(s) that might not yet be reflected in React state.
if (typeof (activeEl as any).value === 'string') {
liveValue = (activeEl as any).value
if ('value' in activeEl && typeof activeEl.value === 'string') {
liveValue = activeEl.value
}
}
}
@@ -1289,7 +1298,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
tagDescription = getOutputTypeForPath(
block,
blockConfig,
blockConfig || null,
group.blockId,
outputPath
)
@@ -1429,7 +1438,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
childType = getOutputTypeForPath(
block,
blockConfig,
blockConfig || null,
group.blockId,
childOutputPath
)

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import type { BlockWithDiff } from './types'
const logger = createLogger('WorkflowDiffEngine')
@@ -334,10 +335,10 @@ export class WorkflowDiffEngine {
for (const [blockId, block] of Object.entries(state.blocks)) {
const cleanBlock: BlockState = { ...block }
// Remove diff markers using bracket notation to avoid TypeScript errors
;(cleanBlock as any).is_diff = undefined
;(cleanBlock as any).field_diff = undefined
// Remove diff markers using proper typing
const blockWithDiff = cleanBlock as BlockState & BlockWithDiff
blockWithDiff.is_diff = undefined
blockWithDiff.field_diffs = undefined
// Ensure outputs is never null/undefined
if (cleanBlock.outputs === undefined || cleanBlock.outputs === null) {

View File

@@ -0,0 +1,18 @@
/**
* Type definitions for workflow diff functionality
*/
export type DiffStatus = 'new' | 'edited' | undefined
export type FieldDiffStatus = 'changed' | 'unchanged'
export type EdgeDiffStatus = 'new' | 'deleted' | 'unchanged' | null
export interface BlockWithDiff {
is_diff?: DiffStatus
field_diffs?: Record<string, { changed_fields: string[]; unchanged_fields: string[] }>
}
export function hasDiffStatus(block: any): block is BlockWithDiff {
return block && typeof block === 'object' && ('is_diff' in block || 'field_diffs' in block)
}

View File

@@ -490,11 +490,25 @@ async function handleBlockOperationTx(
throw new Error('Missing block ID for update parent operation')
}
// Fetch current parent to update subflow node list when detaching or reparenting
const [existing] = await tx
.select({
id: workflowBlocks.id,
parentId: workflowBlocks.parentId,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
const isRemovingFromParent = !payload.parentId
const updateResult = await tx
.update(workflowBlocks)
.set({
parentId: payload.parentId || null,
extent: payload.extent || null,
parentId: isRemovingFromParent ? null : payload.parentId || null,
extent: isRemovingFromParent ? null : payload.extent || null,
// When removing from a subflow, also clear data JSON entirely
...(isRemovingFromParent ? { data: {} } : {}),
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
@@ -504,13 +518,19 @@ async function handleBlockOperationTx(
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
}
// If the block now has a parent, update the parent's subflow node list
// If the block now has a parent, update the new parent's subflow node list
if (payload.parentId) {
await updateSubflowNodeList(tx, workflowId, payload.parentId)
}
// If the block had a previous parent, update that parent's node list as well
if (existing?.parentId && existing.parentId !== payload.parentId) {
await updateSubflowNodeList(tx, workflowId, existing.parentId)
}
logger.debug(
`Updated block parent: ${payload.id} -> parent: ${payload.parentId}, extent: ${payload.extent}`
`Updated block parent: ${payload.id} -> parent: ${payload.parentId || 'null'}, extent: ${payload.extent || 'null'}${
isRemovingFromParent ? ' (cleared data JSON)' : ''
}`
)
break
}
@@ -807,7 +827,7 @@ async function handleSubflowOperationTx(
collection: payload.config.forEachItems,
width: 500,
height: 300,
type: 'loopNode',
type: 'subflowNode',
},
updatedAt: new Date(),
})
@@ -818,7 +838,7 @@ async function handleSubflowOperationTx(
...payload.config,
width: 500,
height: 300,
type: 'parallelNode',
type: 'subflowNode',
}
// Include count if provided

View File

@@ -264,19 +264,16 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
const absolutePosition = { ...block.position }
// Handle empty or null parentId (removing from parent)
// On removal, clear the data JSON entirely per normalized DB contract
const newData = !parentId
? { ...block.data } // Remove parentId and extent if empty
? {}
: {
...block.data,
parentId,
extent,
}
// Remove parentId and extent properties for empty parent ID
if (!parentId && newData.parentId) {
newData.parentId = undefined
newData.extent = undefined
}
// For removal we already set data to {}; for setting a parent keep as-is
const newState = {
blocks: {

View File

@@ -338,7 +338,7 @@ export function convertYamlToWorkflow(yamlWorkflow: YamlWorkflow): ImportResult
importedBlock.data = {
width: 500,
height: 300,
type: yamlBlock.type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
// Map YAML inputs to data properties for loop/parallel blocks
...(yamlBlock.inputs || {}),
}