mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
apps/sim/lib/workflows/diff/types.ts
Normal file
18
apps/sim/lib/workflows/diff/types.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 || {}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user