Consolidated workflow and workflow-block for simplicity and clarity with ReactFlow

This commit is contained in:
Emir Karabeg
2025-02-10 23:54:02 -08:00
parent ff4fee0cb8
commit 8469601d0b
4 changed files with 298 additions and 392 deletions

View File

@@ -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<WorkflowBlockData>) {
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<HTMLDivElement>(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<HTMLDivElement>(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 && <ActionBar blockId={id} />}
<ConnectionBlocks blockId={id} setIsConnecting={setIsConnecting} />
{/* Input Handle */}
<Handle
type="target"
position={horizontalHandles ? Position.Left : Position.Top}
@@ -121,6 +128,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
isConnectableEnd={true}
/>
{/* Block Header */}
<div className="flex items-center justify-between p-3 border-b workflow-drag-handle cursor-grab [&:active]:cursor-grabbing">
<div className="flex items-center gap-3">
<div
@@ -175,6 +183,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
</div>
</div>
{/* Block Content */}
<div className="px-4 pt-3 pb-4 space-y-4 cursor-pointer">
{subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-4">
@@ -190,7 +199,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
))}
</div>
{/* Main output handle - only render if not a condition block */}
{/* Output Handle */}
{type !== 'condition' && (
<Handle
type="source"

View File

@@ -1,322 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import ReactFlow, {
Background,
ConnectionLineType,
EdgeTypes,
NodeTypes,
useOnViewportChange,
useReactFlow,
} from 'reactflow'
import { useNotificationStore } from '@/stores/notifications/store'
import { initializeStateLogger } from '@/stores/workflow/logger'
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 '../custom-edge/custom-edge'
import { createLoopNode, getRelativeLoopPosition } from '../workflow-loop/workflow-loop'
import { WorkflowNode } from '../workflow-node/workflow-node'
// Define custom node and edge types for ReactFlow
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowNode,
}
const edgeTypes: EdgeTypes = { custom: CustomEdge }
export function WorkflowCanvas() {
// Track selected elements in the workflow
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(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 (
<div className="relative w-full h-[calc(100vh-4rem)]">
<NotificationList />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
onDrop={onDrop}
onDragOver={(e) => 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}
>
<Background />
</ReactFlow>
</div>
)
}

View File

@@ -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<WorkflowNodeData>) => {
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 (
<WorkflowBlock
key={key}
id={id}
type={data.type}
position={{ x: xPos, y: yPos }}
config={data.config}
name={data.name}
selected={selected}
/>
)
}

View File

@@ -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<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(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 (
<div className="relative w-full h-[calc(100vh-4rem)]">
<NotificationList />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onDragOver={(e) => 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}
>
<Background />
</ReactFlow>
</div>
)
}
// Workflow wrapper
export default function Workflow() {
return (
<ReactFlowProvider>
<WorkflowCanvas />
<WorkflowContent />
</ReactFlowProvider>
)
}