/** * This file implements a React Flow-based workflow editor component * that allows users to create, connect, and manage workflow nodes * in a visual graph interface. */ // External Dependencies import shortid from 'short-unique-id'; import { diff } from 'deep-object-diff'; import React, { useState, useCallback, useEffect, useRef } from 'react'; import { ReactFlow, Background, BackgroundVariant, useKeyPress, useNodesState, useReactFlow, useUpdateNodeInternals, ReactFlowProvider } from '@xyflow/react'; // Internal Components import Confirm from '../Confirm'; // Signal Management and Utilities import ApiSignal from '../../signals/Api'; import useFlowEdges from '../../hooks/useFlowEdges'; import useFlowNodes from '../../hooks/useFlowNodes'; import Signal, { useSignal } from '../../signal'; import NodesSignal, { ComponentsSignal } from '../../signals/Nodes'; import { fromXYFlow, toXYFlow, isValidConnection } from '../../utilities/workflow'; /** * Configuration for UUID generation * Used for creating unique identifiers for new nodes */ const UUID_CONFIG = { length : 10, dictionary : 'hex' }; // Initialize UUID generator const uid = new shortid(UUID_CONFIG); /** * Global signal states for managing output and selection * These are accessible throughout the application */ export const output = new Signal.State(null); export const selected = new Signal.State(null); // debouncer let timeout; const debounce = (fn, delay = 500) => { clearTimeout(timeout); timeout = setTimeout(fn, delay); }; /** * Main Workflow Component * Provides the visual interface for creating and managing workflow nodes * * @param {Object} props - Component props * @param {string} props.value - YAML string containing workflow data * @param {Function} props.onChange - Callback for workflow changes * @param {any} props.output - Output value to be managed by signal */ const WorkflowComponent = ({ value, onChange, output: outputProp }) => { // ReactFlow Hooks const updateNodeInternals = useUpdateNodeInternals(); const { screenToFlowPosition } = useReactFlow(); const deletePressed = useKeyPress('Delete'); // Refs for managing flow state const flowRef = useRef(null); const reactFlowWrapper = useRef(null); const movingNodeId = useRef(null); const connectedNodeId = useRef(null); const connectingNodeId = useRef(null); // Component State const [ready, setReady] = useState(false); const [remove, setRemove] = useState(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); // Signal State const [api] = useSignal(ApiSignal); const [sNodes] = useSignal(NodesSignal); const [sSelected] = useSignal(selected); const [,setOutput] = useSignal(output); const [sComponents] = useSignal(ComponentsSignal); // Custom hooks for managing flow state const { onNodeChange } = useFlowNodes(value, setNodes, updateNodeInternals); const edges = useFlowEdges(nodes, value, sNodes); /** * Handles node removal * Removes the selected node and updates the workflow */ const handleNodeRemove = useCallback(() => { if (!remove?.id) return; const newNodes = nodes.filter((n) => n.id !== remove.id); onChange(newNodes.map(fromXYFlow)); setRemove(null); }, [nodes, remove?.id, onChange]); /** * Creates a new node with given parameters * * @param {Object} position - X,Y coordinates for the new node * @param {string} type - Node type (defaults to 'choose') * @param {Object} additionalData - Extra data to be included in the node * @returns {Object} New node configuration */ const createNewNode = useCallback((position, type = 'choose', additionalData = {}) => { const id = uid.rnd(); return { id, type, width: 280, position, origin: [0.0, 0.0], data: { ...additionalData, onChange: (key, value) => onNodeChange(id, key, value), onUpdate: () => updateNodeInternals(id), } }; }, [onNodeChange, updateNodeInternals]); /** * Handles double-click events on the flow canvas * Creates a new node at the clicked position */ const handleDoubleClick = useCallback((event) => { // Validate click target if (!event.target.classList.contains('react-flow__pane') || event.target.classList.contains('react-flow__handle') || event.target.classList.contains('react-flow__node')) return; event.preventDefault(); event.stopPropagation(); // Convert screen coordinates to flow coordinates const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, }); // Create and add new node const newNode = createNewNode(position); const newNodes = [...nodes, newNode]; onChange(newNodes.map(fromXYFlow)); }, [nodes, screenToFlowPosition, createNewNode, onChange]); // handle paste const handlePaste = useCallback((event) => { // try/catch json try { // get pasted data const pastedData = event.clipboardData.getData('text'); // check starts with { if (!pastedData.startsWith('{')) return; // parse json const parsedData = JSON.parse(pastedData); // Create and add new node const newNode = { ...parsedData, // update id and position id : uid.rnd(), position : { x : parsedData.position.x + 10, y : parsedData.position.y + 10 } }; // new nodes const newNodes = [...nodes, toXYFlow(newNode, onNodeChange, updateNodeInternals)]; onChange(newNodes.map(fromXYFlow)); } catch (e) { // log error console.error(`error pasting: ${e}`); } }, [nodes, setNodes]); /** * Handles node connections * Updates node input connections when nodes are connected */ const handleConnect = useCallback((params) => { connectedNodeId.current = params.target; if (!params.targetHandle) return; onNodeChange( params.target, `input.${params.targetHandle.split('-')[2]}`, params.sourceHandle.split('-').slice(1).join('-') ); }, [onNodeChange]); /** * Handles the start of a connection * Tracks which node is being connected from */ const handleConnectStart = useCallback((_, data) => { connectingNodeId.current = data.handleId.split('-').slice(1).join('-'); }, []); /** * Handles the start of connection movement * Tracks which node's connection is being moved */ const handleConnectMoveStart = useCallback((_, data) => { movingNodeId.current = data.id; }, []); /** * Handles the end of a connection attempt * Creates new node if dropped on canvas or updates existing connections */ const handleConnectEnd = useCallback((event) => { // Handle existing connection updates if (connectedNodeId.current) { connectedNodeId.current = null; return; } // Handle moving existing connection if (movingNodeId.current) { const actualMovingNodeId = `${movingNodeId.current}`; // Remove the input connection onNodeChange( actualMovingNodeId.split('-')[0], `input.${actualMovingNodeId.split('-')[1]}`, null ); movingNodeId.current = null; connectingNodeId.current = null; return; } // Handle new connection creation if (!connectingNodeId.current) return; if (!event.target.classList.contains('react-flow__pane')) return; if (event.target.classList.contains('react-flow__handle')) return; // Create new node at connection drop position const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, }); const newNode = createNewNode(position); const newNodes = [...nodes, newNode]; // on change onChange(newNodes.map(fromXYFlow)); }, [nodes, screenToFlowPosition, createNewNode, onChange, onNodeChange]); // handle paste useEffect(() => { // Add the paste event listener when the component mounts document.addEventListener('paste', handlePaste); // Remove the paste event listener when the component unmounts return () => { document.removeEventListener('paste', handlePaste); }; }, [handlePaste]); // Effect: Initialize ready state useEffect(() => { const timeout = setTimeout(() => setReady(true), 1000); return () => clearTimeout(timeout); }, []); // Effect: Handle delete key press useEffect(() => { if (sSelected && deletePressed) { const node = nodes.find((n) => n?.id === sSelected); setRemove(node); } }, [deletePressed, nodes, sSelected]); // Effect: Update output signal useEffect(() => { setOutput(outputProp); }, [outputProp, setOutput]); // Effect: Update nodes when YAML changes useEffect(() => { // check diff length if (!Object.keys(diff(value, (nodes || []).map(fromXYFlow))).length) return; // update nodes setNodes(value.map((item) => toXYFlow(item, onNodeChange, updateNodeInternals) )); }, [value]); useEffect(() => { // log nodes changed debounce(() => { // on change onChange((nodes || []).map(fromXYFlow)); }, 1000); }, [JSON.stringify((nodes || []).map(fromXYFlow))]); // Return null if component isn't ready or has no nodes if (!Object.keys(sComponents || {})?.length || (!value?.length && !ready)) { return null; } return (