From 9defa59c729307c8a4e2eb2643e871289dd44236 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 30 Jan 2025 11:21:33 -0800 Subject: [PATCH] Separated workflow into components --- .../components/custom-edge/custom-edge.tsx | 65 ++++ .../workflow-canvas/workflow-canvas.tsx | 209 ++++++++++ .../workflow-node/workflow-node.tsx | 26 ++ app/w/[id]/workflow.tsx | 364 +----------------- 4 files changed, 304 insertions(+), 360 deletions(-) create mode 100644 app/w/[id]/components/custom-edge/custom-edge.tsx create mode 100644 app/w/[id]/components/workflow-canvas/workflow-canvas.tsx create mode 100644 app/w/[id]/components/workflow-node/workflow-node.tsx diff --git a/app/w/[id]/components/custom-edge/custom-edge.tsx b/app/w/[id]/components/custom-edge/custom-edge.tsx new file mode 100644 index 0000000000..67dfd39d1d --- /dev/null +++ b/app/w/[id]/components/custom-edge/custom-edge.tsx @@ -0,0 +1,65 @@ +import { EdgeProps, getSmoothStepPath } from 'reactflow' +import { X } from 'lucide-react' + +export const CustomEdge = (props: EdgeProps) => { + const [edgePath] = getSmoothStepPath({ + sourceX: props.sourceX, + sourceY: props.sourceY, + targetX: props.targetX, + targetY: props.targetY, + }) + + const midPoint = { + x: (props.sourceX + props.targetX) / 2, + y: (props.sourceY + props.targetY) / 2, + } + + const isSelected = props.id === props.data?.selectedEdgeId + + return ( + + + + + + + {isSelected && ( + +
{ + e.stopPropagation() + props.data?.onDelete?.(props.id) + }} + > + +
+
+ )} +
+ ) +} diff --git a/app/w/[id]/components/workflow-canvas/workflow-canvas.tsx b/app/w/[id]/components/workflow-canvas/workflow-canvas.tsx new file mode 100644 index 0000000000..3c1ae18602 --- /dev/null +++ b/app/w/[id]/components/workflow-canvas/workflow-canvas.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import ReactFlow, { + Background, + NodeTypes, + EdgeTypes, + useReactFlow, + ConnectionLineType, +} from 'reactflow' +import { getBlock } from '../../../../../blocks' +import { useWorkflowStore } from '@/stores/workflow/workflow-store' +import { useNotificationStore } from '@/stores/notifications/notifications-store' +import { useWorkflowExecution } from '../../../hooks/use-workflow-execution' +import { NotificationList } from '@/app/w/components/notifications/notifications' +import { WorkflowNode } from '../workflow-node/workflow-node' +import { CustomEdge } from '../custom-edge/custom-edge' +import { initializeStateLogger } from '@/stores/workflow/state-logger' + +const nodeTypes: NodeTypes = { workflowBlock: WorkflowNode } +const edgeTypes: EdgeTypes = { custom: CustomEdge } + +export function WorkflowCanvas() { + const [selectedBlockId, setSelectedBlockId] = useState(null) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) + const { isExecuting, executionResult, handleRunWorkflow } = + useWorkflowExecution() + + const { + blocks, + edges, + addBlock, + updateBlockPosition, + addEdge, + removeEdge, + canUndo, + canRedo, + undo, + redo, + } = useWorkflowStore() + const { addNotification } = useNotificationStore() + const { project } = useReactFlow() + + // Convert blocks to ReactFlow nodes + const nodes = Object.values(blocks).map((block) => ({ + id: block.id, + type: 'workflowBlock', + position: block.position, + selected: block.id === selectedBlockId, + dragHandle: '.workflow-drag-handle', + data: { + type: block.type, + config: getBlock(block.type), + name: block.name, + }, + })) + + const onNodesChange = useCallback( + (changes: any) => { + changes.forEach((change: any) => { + if (change.type === 'position' && change.position) { + updateBlockPosition(change.id, change.position) + } + }) + }, + [updateBlockPosition] + ) + + const onEdgesChange = useCallback( + (changes: any) => { + changes.forEach((change: any) => { + if (change.type === 'remove') { + removeEdge(change.id) + } + }) + }, + [removeEdge] + ) + + const onConnect = useCallback( + (connection: any) => { + addEdge({ + ...connection, + id: crypto.randomUUID(), + type: 'custom', + }) + }, + [addEdge] + ) + + 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] + ) + + const onNodeClick = useCallback((event: React.MouseEvent, node: any) => { + event.stopPropagation() + setSelectedBlockId(node.id) + setSelectedEdgeId(null) + }, []) + + const onPaneClick = useCallback(() => { + setSelectedBlockId(null) + setSelectedEdgeId(null) + }, []) + + const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { + setSelectedEdgeId(edge.id) + }, []) + + 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]) + + useEffect(() => { + initializeStateLogger() + }, []) + + const edgesWithSelection = edges.map((edge) => ({ + ...edge, + data: { + selectedEdgeId, + onDelete: (edgeId: string) => { + removeEdge(edgeId) + setSelectedEdgeId(null) + }, + }, + })) + + 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 new file mode 100644 index 0000000000..e194f45021 --- /dev/null +++ b/app/w/[id]/components/workflow-node/workflow-node.tsx @@ -0,0 +1,26 @@ +import { NodeProps } from 'reactflow' +import { WorkflowBlock } from '../../../components/workflow-block/workflow-block' +import { BlockConfig } from '../../../../../blocks/types' + +interface WorkflowNodeData { + type: string + config: BlockConfig + name: string +} + +export const WorkflowNode = ({ + data, + id, + xPos, + yPos, + selected, +}: NodeProps) => ( + +) diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index 8ab503283b..d7ab7d3046 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -1,361 +1,12 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import ReactFlow, { - Background, - NodeProps, - NodeTypes, - EdgeTypes, - Connection, - addEdge, - useNodesState, - useEdgesState, - useReactFlow, - ReactFlowProvider, - ConnectionLineType, - BaseEdge, - EdgeProps, - getSmoothStepPath, -} from 'reactflow' +import { useEffect, useState } from 'react' +import { ReactFlowProvider } from 'reactflow' import 'reactflow/dist/style.css' -import { getBlock } from '../../../blocks' -import { WorkflowBlock } from '../components/workflow-block/workflow-block' -import { BlockConfig } from '../../../blocks/types' -import { useWorkflowStore } from '@/stores/workflow/workflow-store' -import { initializeStateLogger } from '@/stores/workflow/state-logger' -import { BlockState } from '@/stores/workflow/types' -import { NotificationList } from '@/app/w/components/notifications/notifications' -import { useNotificationStore } from '@/stores/notifications/notifications-store' -import { executeWorkflow } from '@/lib/workflow' -import { useWorkflowExecution } from '../hooks/use-workflow-execution' import { useWorkflowRegistry } from '@/stores/workflow/workflow-registry' import { useParams, useRouter } from 'next/navigation' -import { X } from 'lucide-react' +import { WorkflowCanvas } from './components/workflow-canvas/workflow-canvas' -/** - * Represents the data structure for a workflow node - */ -interface WorkflowNodeData { - type: string - config: BlockConfig - name: string -} - -/** - * Custom node component for rendering workflow blocks in the workflow editor - */ -const WorkflowNode = ({ - data, - id, - xPos, - yPos, - selected, -}: NodeProps) => ( - -) - -/** - * Custom edge component that renders an animated dashed line between nodes - */ -const CustomEdge = (props: EdgeProps) => { - const [edgePath] = getSmoothStepPath({ - sourceX: props.sourceX, - sourceY: props.sourceY, - targetX: props.targetX, - targetY: props.targetY, - }) - - const midPoint = { - x: (props.sourceX + props.targetX) / 2, - y: (props.sourceY + props.targetY) / 2, - } - - const isSelected = props.id === props.data?.selectedEdgeId - - return ( - - {/* Invisible wider path for better click interaction */} - - {/* Visible animated path */} - - - - - {/* Delete button - only show when edge is selected */} - {isSelected && ( - -
{ - e.stopPropagation() - props.data?.onDelete?.(props.id) - }} - > - -
-
- )} -
- ) -} - -/** - * Component type definitions for ReactFlow nodes and edges - */ -const nodeTypes: NodeTypes = { workflowBlock: WorkflowNode } -const edgeTypes: EdgeTypes = { custom: CustomEdge } - -/** - * Main canvas component that handles the interactive workflow editor functionality - * including drag and drop, node connections, and position updates - */ -function WorkflowCanvas() { - const [selectedBlockId, setSelectedBlockId] = useState(null) - const [selectedEdgeId, setSelectedEdgeId] = useState(null) - const { isExecuting, executionResult, handleRunWorkflow } = - useWorkflowExecution() - - const { - blocks, - edges, - addBlock, - updateBlockPosition, - addEdge, - removeEdge, - canUndo, - canRedo, - undo, - redo, - } = useWorkflowStore() - const { addNotification } = useNotificationStore() - - const params = useParams() - - // Convert blocks to ReactFlow nodes using local selectedBlockId - const nodes = Object.values(blocks).map((block) => ({ - id: block.id, - type: 'workflowBlock', - position: block.position, - selected: block.id === selectedBlockId, - dragHandle: '.workflow-drag-handle', - data: { - type: block.type, - config: getBlock(block.type), - name: block.name, - }, - })) - - const { project } = useReactFlow() - - /** - * Handles updating node positions when they are dragged - */ - const onNodesChange = useCallback( - (changes: any) => { - changes.forEach((change: any) => { - if (change.type === 'position' && change.position) { - updateBlockPosition(change.id, change.position) - } - }) - }, - [updateBlockPosition] - ) - - /** - * Handles edge removal when they are deleted - */ - const onEdgesChange = useCallback( - (changes: any) => { - changes.forEach((change: any) => { - if (change.type === 'remove') { - removeEdge(change.id) - } - }) - }, - [removeEdge] - ) - - /** - * Handles creating new connections between nodes - */ - const onConnect = useCallback( - (connection: any) => { - addEdge({ - ...connection, - id: crypto.randomUUID(), - type: 'custom', - }) - }, - [addEdge] - ) - - /** - * Handles the drop event when a new block is dragged onto the canvas - */ - const onDrop = useCallback( - (event: React.DragEvent) => { - event.preventDefault() - - try { - const data = JSON.parse(event.dataTransfer.getData('application/json')) - - // Early return if this is a connection block drag - 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] - ) - - // Handler for node clicks - const onNodeClick = useCallback((event: React.MouseEvent, node: any) => { - event.stopPropagation() - setSelectedBlockId(node.id) - setSelectedEdgeId(null) - }, []) - - // Handler for clicks on the empty canvas - const onPaneClick = useCallback((event: React.MouseEvent) => { - setSelectedBlockId(null) - setSelectedEdgeId(null) - }, []) - - // Add this new handler - const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { - setSelectedEdgeId(edge.id) - }, []) - - // Add keyboard event handler - 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]) - - useEffect(() => { - initializeStateLogger() - }, []) - - // Update the edges to include both selectedEdgeId and onDelete handler - const edgesWithSelection = edges.map((edge) => ({ - ...edge, - data: { - selectedEdgeId, - onDelete: (edgeId: string) => { - removeEdge(edgeId) - setSelectedEdgeId(null) - }, - }, - })) - - 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} - > - - -
- ) -} - -/** - * Root workflow component that provides the ReactFlow context to the canvas - */ export default function Workflow() { const params = useParams() const router = useRouter() @@ -363,7 +14,6 @@ export default function Workflow() { const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { - // First, initialize the registry and wait for it if (typeof window !== 'undefined') { const savedRegistry = localStorage.getItem('workflow-registry') if (savedRegistry) { @@ -374,10 +24,8 @@ export default function Workflow() { }, []) useEffect(() => { - // Only proceed if we've initialized the registry if (!isInitialized) return - // Helper function to create a new workflow const createInitialWorkflow = () => { const id = crypto.randomUUID() const newWorkflow = { @@ -391,25 +39,21 @@ export default function Workflow() { return id } - // Validate and handle workflow navigation const validateAndNavigate = () => { const workflowIds = Object.keys(workflows) const currentId = params.id as string - // No workflows exist - create one and navigate if (workflowIds.length === 0) { const newId = createInitialWorkflow() router.replace(`/w/${newId}`) return } - // Current workflow is invalid - navigate to first available if (!workflows[currentId]) { router.replace(`/w/${workflowIds[0]}`) return } - // Valid workflow - set it as active setActiveWorkflow(currentId) } @@ -424,7 +68,7 @@ export default function Workflow() { ]) if (!isInitialized) { - return null // or a loading spinner + return null } return (