diff --git a/app/w/[id]/components/workflow-block/workflow-block.tsx b/app/w/[id]/components/workflow-block/workflow-block.tsx index a8361b21c7..a5010384df 100644 --- a/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/app/w/[id]/components/workflow-block/workflow-block.tsx @@ -1,51 +1,56 @@ import { useEffect, useRef, useState } from 'react' import { RectangleHorizontal, RectangleVertical } from 'lucide-react' -import { Handle, Position } from 'reactflow' -import { useUpdateNodeInternals } from 'reactflow' +import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useWorkflowStore } from '@/stores/workflow/store' -import { BlockConfig, SubBlockConfig } from '../../../../../blocks/types' +import { BlockConfig, SubBlockConfig } from '@/blocks/types' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { SubBlock } from './components/sub-block/sub-block' -interface WorkflowBlockProps { - id: string +interface WorkflowBlockData { type: string - position: { x: number; y: number } config: BlockConfig name: string - selected?: boolean } -export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBlockProps) { +// Combine both interfaces into a single component +export function WorkflowBlock({ id, data, selected }: NodeProps) { + const { type, config, name } = data const { toolbar, workflow } = config + + // State management const [isConnecting, setIsConnecting] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState('') + + // Refs + const blockRef = useRef(null) + const updateNodeInternals = useUpdateNodeInternals() + + // Store selectors const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true) const horizontalHandles = useWorkflowStore( (state) => state.blocks[id]?.horizontalHandles ?? false ) - const [isEditing, setIsEditing] = useState(false) - const [editedName, setEditedName] = useState('') - const updateBlockName = useWorkflowStore((state) => state.updateBlockName) - const blockRef = useRef(null) - const updateNodeInternals = useUpdateNodeInternals() const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false) + + // Store actions + const updateBlockName = useWorkflowStore((state) => state.updateBlockName) const toggleBlockWide = useWorkflowStore((state) => state.toggleBlockWide) - // Add effect to update node internals when handles change + // Update node internals when handles change useEffect(() => { updateNodeInternals(id) - }, [id, horizontalHandles]) + }, [id, horizontalHandles, updateNodeInternals]) + // SubBlock layout management function groupSubBlocks(subBlocks: SubBlockConfig[]) { - // Filter out hidden subblocks const visibleSubBlocks = subBlocks.filter((block) => !block.hidden) - const rows: SubBlockConfig[][] = [] let currentRow: SubBlockConfig[] = [] let currentRowWidth = 0 @@ -71,6 +76,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc const subBlockRows = groupSubBlocks(workflow.subBlocks) + // Name editing handlers const handleNameClick = () => { setEditedName(name) setIsEditing(true) @@ -104,6 +110,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc {selected && } + {/* Input Handle */} + {/* Block Header */}
+ {/* Block Content */}
{subBlockRows.map((row, rowIndex) => (
@@ -190,7 +199,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc ))}
- {/* Main output handle - only render if not a condition block */} + {/* Output Handle */} {type !== 'condition' && ( (null) - const [selectedEdgeId, setSelectedEdgeId] = useState(null) - - // Store references and state management hooks - const { isExecuting, executionResult, handleRunWorkflow } = useWorkflowExecution() - const { - blocks, - edges, - addBlock, - updateBlockPosition, - addEdge, - removeEdge, - canUndo, - canRedo, - undo, - redo, - } = useWorkflowStore() - const { addNotification } = useNotificationStore() - const { project, setViewport } = useReactFlow() - const { loops } = useWorkflowStore() - - // Transform blocks and loops into ReactFlow node format - const nodes = useMemo(() => { - const nodeArray: any[] = [] - - // Add loop group nodes first - Object.entries(loops).forEach(([loopId, loop]) => { - const loopNode = createLoopNode({ loopId, loop, blocks }) - if (loopNode) { - nodeArray.push(loopNode) - } - }) - - // Add block nodes with relative positions if they're in a loop - Object.entries(blocks).forEach(([blockId, block]) => { - // Skip loop position entries that don't have proper block structure - if (!block.type || !block.name) { - console.log('Skipping invalid block:', blockId, block) - return - } - - // Get block configuration - const blockConfig = getBlock(block.type) - if (!blockConfig) { - console.error(`No configuration found for block type: ${block.type}`) - return - } - - // Find if block belongs to any loop - const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id)) - - let position = block.position - if (parentLoop) { - const [loopId] = parentLoop - const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`) - if (loopNode) { - position = getRelativeLoopPosition(block.position, loopNode.position) - } - } - - nodeArray.push({ - id: block.id, - type: 'workflowBlock', - position, - parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined, - dragHandle: '.workflow-drag-handle', - selected: block.id === selectedBlockId, - data: { - type: block.type, - config: blockConfig, - name: block.name, - }, - }) - }) - - return nodeArray - }, [blocks, loops, selectedBlockId]) - - // Update node position handler - const onNodesChange = useCallback( - (changes: any) => { - changes.forEach((change: any) => { - if (change.type === 'position' && change.position) { - const node = nodes.find((n) => n.id === change.id) - if (!node) return - - // If node is part of a loop, convert position back to absolute - if (node.parentId) { - const loopNode = nodes.find((n) => n.id === node.parentId) - if (loopNode) { - const absolutePosition = { - x: change.position.x + loopNode.position.x, - y: change.position.y + loopNode.position.y, - } - updateBlockPosition(change.id, absolutePosition) - } - } else { - updateBlockPosition(change.id, change.position) - } - } - }) - }, - [nodes, updateBlockPosition] - ) - - // Handle edge removal and updates - const onEdgesChange = useCallback( - (changes: any) => { - changes.forEach((change: any) => { - if (change.type === 'remove') { - removeEdge(change.id) - } - }) - }, - [removeEdge] - ) - - // Create new edges when nodes are connected - const onConnect = useCallback( - (connection: any) => { - // Use ReactFlow's addEdge utility - const newEdge = { - ...connection, - id: crypto.randomUUID(), - type: 'custom', - } - - // Validate connection before adding - if (newEdge.source && newEdge.target) { - addEdge(newEdge) - } - }, - [addEdge] - ) - - // Handle new block creation from toolbar drag and drop - const onDrop = useCallback( - (event: React.DragEvent) => { - event.preventDefault() - - try { - const data = JSON.parse(event.dataTransfer.getData('application/json')) - if (data.type === 'connectionBlock') return - - const reactFlowBounds = event.currentTarget.getBoundingClientRect() - const position = project({ - x: event.clientX - reactFlowBounds.left, - y: event.clientY - reactFlowBounds.top, - }) - - const blockConfig = getBlock(data.type) - if (!blockConfig) { - console.error('Invalid block type:', data.type) - return - } - - const id = crypto.randomUUID() - const name = `${blockConfig.toolbar.title} ${ - Object.values(blocks).filter((b) => b.type === data.type).length + 1 - }` - - addBlock(id, data.type, name, position) - } catch (err) { - console.error('Error dropping block:', err) - } - }, - [project, blocks, addBlock] - ) - - // Update selection state when clicking nodes - const onNodeClick = useCallback((event: React.MouseEvent, node: any) => { - event.stopPropagation() - setSelectedBlockId(node.id) - setSelectedEdgeId(null) - }, []) - - // Update onPaneClick to clear selections - const onPaneClick = useCallback((event: React.MouseEvent) => { - setSelectedBlockId(null) - setSelectedEdgeId(null) - }, []) - - // Update selected edge when clicking on connections - const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { - setSelectedEdgeId(edge.id) - setSelectedBlockId(null) - }, []) - - // Handle keyboard shortcuts for edge deletion - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) { - removeEdge(selectedEdgeId) - setSelectedEdgeId(null) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEdgeId, removeEdge]) - - // Initialize state logging for debugging - useEffect(() => { - initializeStateLogger() - }, []) - - const edgesWithSelection = edges.map((edge) => ({ - ...edge, - type: edge.type || 'custom', - data: { - selectedEdgeId, - onDelete: (edgeId: string) => { - removeEdge(edgeId) - setSelectedEdgeId(null) - }, - }, - })) - - // Replace useViewport with useOnViewportChange - useOnViewportChange({ - onStart: ({ x, y, zoom }) => { - console.log('Viewport change start:', { - x: Math.round(x), - y: Math.round(y), - zoom: zoom.toFixed(2), - }) - }, - onEnd: ({ x, y, zoom }) => { - console.log('Viewport change end:', { - x: Math.round(x), - y: Math.round(y), - zoom: zoom.toFixed(2), - }) - }, - }) - - // For the random movement function, we can use setViewport from useReactFlow - const moveToRandomLocation = useCallback(() => { - const randomX = Math.random() * 1000 - 500 - const randomY = Math.random() * 1000 - 500 - const randomZoom = Math.random() * 0.5 + 0.5 - - setViewport( - { - x: randomX, - y: randomY, - zoom: randomZoom, - }, - { duration: 500 } - ) - }, [setViewport]) - - return ( -
- - e.preventDefault()} - fitView - maxZoom={1} - panOnScroll - defaultEdgeOptions={{ type: 'custom' }} - edgeTypes={edgeTypes} - proOptions={{ hideAttribution: true }} - connectionLineStyle={{ - stroke: '#94a3b8', - strokeWidth: 2, - strokeDasharray: '5,5', - }} - connectionLineType={ConnectionLineType.SmoothStep} - onNodeClick={onNodeClick} - onPaneClick={onPaneClick} - onEdgeClick={onEdgeClick} - elementsSelectable={true} - selectNodesOnDrag={false} - nodesConnectable={true} - nodesDraggable={true} - draggable={false} - noWheelClassName="allow-scroll" - edgesFocusable={true} - edgesUpdatable={true} - > - - -
- ) -} diff --git a/app/w/[id]/components/workflow-node/workflow-node.tsx b/app/w/[id]/components/workflow-node/workflow-node.tsx deleted file mode 100644 index 585a57cfc1..0000000000 --- a/app/w/[id]/components/workflow-node/workflow-node.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from 'react' -import { NodeProps } from 'reactflow' -import { useWorkflowStore } from '@/stores/workflow/store' -import { BlockConfig } from '../../../../../blocks/types' -import { WorkflowBlock } from '../workflow-block/workflow-block' - -interface WorkflowNodeData { - type: string - config: BlockConfig - name: string -} - -export const WorkflowNode = ({ data, id, xPos, yPos, selected }: NodeProps) => { - const [key, setKey] = useState(0) - const horizontalHandles = useWorkflowStore( - (state) => state.blocks[id]?.horizontalHandles ?? false - ) - - // Add effect to trigger immediate re-render after handle toggle - useEffect(() => { - setKey((prev) => prev + 1) - }, [horizontalHandles]) - - return ( - - ) -} diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index 708e35d238..ac621008d7 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -1,21 +1,50 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import { ReactFlowProvider } from 'reactflow' +import ReactFlow, { + Background, + ConnectionLineType, + EdgeTypes, + NodeTypes, + ReactFlowProvider, + useOnViewportChange, + useReactFlow, +} from 'reactflow' import 'reactflow/dist/style.css' +import { useNotificationStore } from '@/stores/notifications/store' +import { initializeStateLogger } from '@/stores/workflow/logger' import { useWorkflowRegistry } from '@/stores/workflow/registry' -import { WorkflowCanvas } from './components/workflow-canvas/workflow-canvas' +import { useWorkflowStore } from '@/stores/workflow/store' +import { NotificationList } from '@/app/w/components/notifications/notifications' +import { getBlock } from '../../../blocks' +import { useWorkflowExecution } from '../hooks/use-workflow-execution' +import { CustomEdge } from './components/custom-edge/custom-edge' +import { WorkflowBlock } from './components/workflow-block/workflow-block' +import { createLoopNode, getRelativeLoopPosition } from './components/workflow-loop/workflow-loop' -export default function Workflow() { - // Track if initial data loading is complete +// Define custom node and edge types +const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock } +const edgeTypes: EdgeTypes = { custom: CustomEdge } + +function WorkflowContent() { + // State + const [selectedBlockId, setSelectedBlockId] = useState(null) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [isInitialized, setIsInitialized] = useState(false) + // Hooks const params = useParams() const router = useRouter() - const { workflows, setActiveWorkflow, addWorkflow } = useWorkflowRegistry() + const { project, setViewport } = useReactFlow() - // Load saved workflows from localStorage on component mount + // Store access + const { addNotification } = useNotificationStore() + const { workflows, setActiveWorkflow, addWorkflow } = useWorkflowRegistry() + const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } = + useWorkflowStore() + + // Initialize workflow useEffect(() => { if (typeof window !== 'undefined') { const savedRegistry = localStorage.getItem('workflow-registry') @@ -26,11 +55,10 @@ export default function Workflow() { } }, []) - // Handle workflow initialization and navigation + // Init workflow useEffect(() => { if (!isInitialized) return - // Create a new workflow with default values const createInitialWorkflow = () => { const id = crypto.randomUUID() const newWorkflow = { @@ -44,19 +72,16 @@ export default function Workflow() { return id } - // Ensure valid workflow ID and redirect if necessary const validateAndNavigate = () => { const workflowIds = Object.keys(workflows) const currentId = params.id as string - // Create first workflow if none exist if (workflowIds.length === 0) { const newId = createInitialWorkflow() router.replace(`/w/${newId}`) return } - // Redirect to first workflow if current ID is invalid if (!workflows[currentId]) { router.replace(`/w/${workflowIds[0]}`) return @@ -68,14 +93,243 @@ export default function Workflow() { validateAndNavigate() }, [params.id, workflows, setActiveWorkflow, addWorkflow, router, isInitialized]) - // Don't render until initial data is loaded - if (!isInitialized) { - return null - } + // Transform blocks and loops into ReactFlow nodes + const nodes = useMemo(() => { + const nodeArray: any[] = [] + + // Add loop group nodes + Object.entries(loops).forEach(([loopId, loop]) => { + const loopNode = createLoopNode({ loopId, loop, blocks }) + if (loopNode) nodeArray.push(loopNode) + }) + + // Add block nodes + Object.entries(blocks).forEach(([blockId, block]) => { + if (!block.type || !block.name) { + console.log('Skipping invalid block:', blockId, block) + return + } + + const blockConfig = getBlock(block.type) + if (!blockConfig) { + console.error(`No configuration found for block type: ${block.type}`) + return + } + + const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id)) + let position = block.position + + if (parentLoop) { + const [loopId] = parentLoop + const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`) + if (loopNode) { + position = getRelativeLoopPosition(block.position, loopNode.position) + } + } + + nodeArray.push({ + id: block.id, + type: 'workflowBlock', + position, + parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined, + dragHandle: '.workflow-drag-handle', + selected: block.id === selectedBlockId, + data: { + type: block.type, + config: blockConfig, + name: block.name, + }, + }) + }) + + return nodeArray + }, [blocks, loops, selectedBlockId]) + + // Update nodes + const onNodesChange = useCallback( + (changes: any) => { + changes.forEach((change: any) => { + if (change.type === 'position' && change.position) { + const node = nodes.find((n) => n.id === change.id) + if (!node) return + + if (node.parentId) { + const loopNode = nodes.find((n) => n.id === node.parentId) + if (loopNode) { + const absolutePosition = { + x: change.position.x + loopNode.position.x, + y: change.position.y + loopNode.position.y, + } + updateBlockPosition(change.id, absolutePosition) + } + } else { + updateBlockPosition(change.id, change.position) + } + } + }) + }, + [nodes, updateBlockPosition] + ) + + // Update edges + const onEdgesChange = useCallback( + (changes: any) => { + changes.forEach((change: any) => { + if (change.type === 'remove') { + removeEdge(change.id) + } + }) + }, + [removeEdge] + ) + + // Handle connections + const onConnect = useCallback( + (connection: any) => { + if (connection.source && connection.target) { + addEdge({ + ...connection, + id: crypto.randomUUID(), + type: 'custom', + }) + } + }, + [addEdge] + ) + + // Handle drops + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault() + + try { + const data = JSON.parse(event.dataTransfer.getData('application/json')) + if (data.type === 'connectionBlock') return + + const reactFlowBounds = event.currentTarget.getBoundingClientRect() + const position = project({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }) + + const blockConfig = getBlock(data.type) + if (!blockConfig) { + console.error('Invalid block type:', data.type) + return + } + + const id = crypto.randomUUID() + const name = `${blockConfig.toolbar.title} ${ + Object.values(blocks).filter((b) => b.type === data.type).length + 1 + }` + + addBlock(id, data.type, name, position) + } catch (err) { + console.error('Error dropping block:', err) + } + }, + [project, blocks, addBlock] + ) + + // Node selection + const onNodeClick = useCallback((event: React.MouseEvent, node: any) => { + event.stopPropagation() + setSelectedBlockId(node.id) + setSelectedEdgeId(null) + }, []) + + // Clear selection + const onPaneClick = useCallback(() => { + setSelectedBlockId(null) + setSelectedEdgeId(null) + }, []) + + // Edge selection + const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { + setSelectedEdgeId(edge.id) + setSelectedBlockId(null) + }, []) + + // Transform edges to include selection state + const edgesWithSelection = edges.map((edge) => ({ + ...edge, + type: edge.type || 'custom', + data: { + selectedEdgeId, + onDelete: (edgeId: string) => { + removeEdge(edgeId) + setSelectedEdgeId(null) + }, + }, + })) + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) { + removeEdge(selectedEdgeId) + setSelectedEdgeId(null) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedEdgeId, removeEdge]) + + // Initialize state logging + useEffect(() => { + initializeStateLogger() + }, []) + + if (!isInitialized) return null + return ( +
+ + e.preventDefault()} + fitView + maxZoom={1} + panOnScroll + defaultEdgeOptions={{ type: 'custom' }} + proOptions={{ hideAttribution: true }} + connectionLineStyle={{ + stroke: '#94a3b8', + strokeWidth: 2, + strokeDasharray: '5,5', + }} + connectionLineType={ConnectionLineType.SmoothStep} + onNodeClick={onNodeClick} + onPaneClick={onPaneClick} + onEdgeClick={onEdgeClick} + elementsSelectable={true} + selectNodesOnDrag={false} + nodesConnectable={true} + nodesDraggable={true} + draggable={false} + noWheelClassName="allow-scroll" + edgesFocusable={true} + edgesUpdatable={true} + > + + +
+ ) +} + +// Workflow wrapper +export default function Workflow() { return ( - + ) }