diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index 61ef5165f2..d4a6177672 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -7,12 +7,15 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { WorkflowDiagramRightClickCommandMenu } from '@/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu'; +import { WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyNodeDefinition'; import { useResetWorkflowInsertStepIds } from '@/workflow/workflow-diagram/hooks/useResetWorkflowInsertStepIds'; +import { useWorkflowDiagramScreenToFlowPosition } from '@/workflow/workflow-diagram/hooks/useWorkflowDiagramScreenToFlowPosition'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState'; import { workflowDiagramWaitingNodesDimensionsComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { + type StartNodeCreationParams, type WorkflowConnection, type WorkflowDiagram, type WorkflowDiagramEdge, @@ -23,8 +26,13 @@ import { import { assertWorkflowConnectionOrThrow } from '@/workflow/workflow-diagram/utils/assertWorkflowConnectionOrThrow'; import { WorkflowDiagramConnection } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramConnection'; import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramCustomMarkers'; +import { EDGE_BRANCH_ARROW_MARKER } from '@/workflow/workflow-diagram/workflow-edges/constants/EdgeBranchArrowMarker'; import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState'; import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps'; +import { getConnectionOptionsForSourceHandle } from '@/workflow/workflow-diagram/workflow-edges/utils/getConnectionOptionsForSourceHandle'; +import { WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramNodeDefaultSourceHandleId'; +import { WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramNodeDefaultTargetHandleId'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -39,6 +47,7 @@ import { type NodeChange, type NodeProps, type OnBeforeDelete, + type OnConnectStartParams, type OnDelete, type OnNodeDrag, type OnReconnect, @@ -105,6 +114,7 @@ export const WorkflowDiagramCanvasBase = ({ onReconnect, onReconnectStart, onReconnectEnd, + startNodeCreation, handlePaneContextMenu, nodesConnectable = false, nodesDraggable = false, @@ -142,6 +152,7 @@ export const WorkflowDiagramCanvasBase = ({ onReconnect?: OnReconnect; onReconnectStart?: () => void; onReconnectEnd?: () => void; + startNodeCreation?: (params: StartNodeCreationParams) => void; nodesConnectable?: boolean; nodesDraggable?: boolean; handlePaneContextMenu?: ({ @@ -182,17 +193,67 @@ export const WorkflowDiagramCanvasBase = ({ workflowDiagramWaitingNodesDimensionsComponentState, ); + const workflowInsertStepIds = useRecoilComponentValue( + workflowInsertStepIdsComponentState, + ); + + const { workflowDiagramScreenToFlowPosition } = + useWorkflowDiagramScreenToFlowPosition(); + const { setEdgeHovered, clearEdgeHover } = useEdgeState(); const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] = useState(false); + const [connectionStartInfo, setConnectionStartInfo] = useState<{ + nodeId: string; + handleId: string; + } | null>(null); + const { nodes, edges } = useMemo(() => { - if (isDefined(workflowDiagram)) { - return workflowDiagram; + if (!isDefined(workflowDiagram)) { + return { nodes: [], edges: [] }; } - return { nodes: [], edges: [] }; - }, [workflowDiagram]); + + const nodes = [...workflowDiagram.nodes]; + const edges = [...workflowDiagram.edges]; + + if ( + isDefined(workflowInsertStepIds.position) && + !isDefined(workflowInsertStepIds.nextStepId) + ) { + const emptyNode = { + ...WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION, + position: workflowInsertStepIds.position, + data: { + ...WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION.data, + position: workflowInsertStepIds.position, + }, + }; + + nodes.push(emptyNode); + + if (isDefined(workflowInsertStepIds.parentStepId)) { + edges.push({ + id: 'empty-edge', + type: 'blank', + source: workflowInsertStepIds.parentStepId, + sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID, + target: WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION.id, + targetHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID, + markerStart: undefined, + ...EDGE_BRANCH_ARROW_MARKER.Default, + deletable: false, + selectable: false, + data: { + edgeType: 'default', + }, + }); + } + } + + return { nodes, edges }; + }, [workflowDiagram, workflowInsertStepIds]); const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); const { isInRightDrawer } = useContext(ActionMenuContext); @@ -328,11 +389,20 @@ export const WorkflowDiagramCanvasBase = ({ snapshot, workflowDiagramState, ); + + const filteredChanges = changes.filter( + (change) => + !( + 'id' in change && + change.id === WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION.id + ), + ); + let updatedWorkflowDiagram = workflowDiagram; - if (isDefined(workflowDiagram)) { + if (isDefined(workflowDiagram) && filteredChanges.length > 0) { updatedWorkflowDiagram = { ...workflowDiagram, - nodes: applyNodeChanges(changes, workflowDiagram.nodes), + nodes: applyNodeChanges(filteredChanges, workflowDiagram.nodes), }; } @@ -430,12 +500,70 @@ export const WorkflowDiagramCanvasBase = ({ clearEdgeHover(); }, [clearEdgeHover]); + const handleConnectStart = ( + _: MouseEvent | TouchEvent, + params: OnConnectStartParams, + ) => { + if (isDefined(params.nodeId) && isDefined(params.handleId)) { + setConnectionStartInfo({ + nodeId: params.nodeId, + handleId: params.handleId, + }); + } + }; + const handleConnect = (connection: Connection) => { assertWorkflowConnectionOrThrow(connection); - + setConnectionStartInfo(null); onConnect?.(connection); }; + const handleConnectEnd = (event: MouseEvent | TouchEvent) => { + let startInfo = connectionStartInfo; + + setConnectionStartInfo((prev) => { + startInfo = prev; + return null; + }); + + if ( + !isDefined(startInfo) || + !isDefined(startNodeCreation) || + !(event instanceof MouseEvent) || + !isDefined(containerRef.current) + ) { + return; + } + + const bounds = containerRef.current.getBoundingClientRect(); + + const screenPosition = { + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }; + + const flowPosition = workflowDiagramScreenToFlowPosition(screenPosition); + + if (!isDefined(flowPosition)) { + return; + } + + const DEFAULT_NODE_WIDTH = 200; + const adjustedPosition = { + x: flowPosition.x - DEFAULT_NODE_WIDTH / 2, + y: flowPosition.y + 50, + }; + + startNodeCreation({ + parentStepId: startInfo.nodeId, + nextStepId: undefined, + position: adjustedPosition, + connectionOptions: getConnectionOptionsForSourceHandle({ + sourceHandleId: startInfo.handleId, + }), + }); + }; + return ( @@ -455,6 +583,8 @@ export const WorkflowDiagramCanvasBase = ({ onNodesChange={handleNodesChanges} onEdgesChange={handleEdgesChange} onConnect={handleConnect} + onConnectStart={handleConnectStart} + onConnectEnd={handleConnectEnd} onReconnect={onReconnect} onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx index 297b4a69b5..6cb128cb6d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx @@ -4,6 +4,7 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase'; import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect'; +import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState'; import { @@ -57,6 +58,8 @@ export const WorkflowDiagramCanvasEditable = () => { const { updateTrigger } = useUpdateWorkflowVersionTrigger(); + const { startNodeCreation } = useStartNodeCreation(); + const onConnect = (edgeConnect: WorkflowConnection) => { setWorkflowDiagram((diagram) => { if (isDefined(diagram) === false) { @@ -162,6 +165,7 @@ export const WorkflowDiagramCanvasEditable = () => { nodeTypes={{ default: WorkflowDiagramStepNodeEditable, 'empty-trigger': WorkflowDiagramEmptyTriggerEditable, + empty: WorkflowDiagramStepNodeEditable, }} edgeTypes={{ blank: WorkflowDiagramBlankEdge, @@ -177,6 +181,7 @@ export const WorkflowDiagramCanvasEditable = () => { nodesConnectable nodesDraggable onDeleteEdge={onDeleteEdge} + startNodeCreation={startNodeCreation} /> diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/EmptyNodeId.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/EmptyNodeId.ts new file mode 100644 index 0000000000..d988338df2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/EmptyNodeId.ts @@ -0,0 +1 @@ +export const EMPTY_NODE_ID = 'empty-node'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyNodeDefinition.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyNodeDefinition.ts new file mode 100644 index 0000000000..16f63b383c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyNodeDefinition.ts @@ -0,0 +1,28 @@ +import { EMPTY_NODE_ID } from '@/workflow/workflow-diagram/constants/EmptyNodeId'; +import { type WorkflowDiagramStepNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; + +export const WORKFLOW_DIAGRAM_EMPTY_NODE_DEFINITION = { + id: EMPTY_NODE_ID, + type: 'empty', + selected: false, + selectable: false, + draggable: false, + deletable: false, + connectable: true, + focusable: false, + data: { + nodeType: 'action', + actionType: 'EMPTY', + name: 'Add an Action', + hasNextStepIds: false, + stepId: EMPTY_NODE_ID, + position: { + x: 0, + y: 0, + }, + }, + position: { + x: 0, + y: 0, + }, +} satisfies WorkflowDiagramStepNode; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts index 4b1b75d9fa..3addeaa693 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts @@ -6,7 +6,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; -import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions'; +import { type StartNodeCreationParams } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { useCallback, useContext } from 'react'; import { useSetRecoilState } from 'recoil'; @@ -42,12 +42,7 @@ export const useStartNodeCreation = () => { nextStepId, position, connectionOptions, - }: { - parentStepId: string | undefined; - nextStepId: string | undefined; - position?: { x: number; y: number }; - connectionOptions?: WorkflowStepConnectionOptions; - }) => { + }: StartNodeCreationParams) => { setWorkflowInsertStepIds({ parentStepId, nextStepId, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts index 6de051b386..ac336d3ab3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts @@ -3,6 +3,7 @@ import { type WorkflowRunStepStatus, type WorkflowTriggerType, } from '@/workflow/types/Workflow'; +import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions'; import { type MessageDescriptor } from '@lingui/core'; import { type Connection, @@ -117,6 +118,13 @@ export type WorkflowDiagramDefaultEdgeData = { export type WorkflowDiagramEdgeData = WorkflowDiagramDefaultEdgeData; -export type WorkflowDiagramNodeType = 'default' | 'empty-trigger'; +export type WorkflowDiagramNodeType = 'default' | 'empty-trigger' | 'empty'; export type WorkflowDiagramEdgeType = 'blank' | 'editable' | 'readonly'; + +export type StartNodeCreationParams = { + parentStepId?: string; + nextStepId?: string; + position?: { x: number; y: number }; + connectionOptions?: WorkflowStepConnectionOptions; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditableContent.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditableContent.tsx index 9027f4e5c2..723a30c8f6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditableContent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditableContent.tsx @@ -1,4 +1,6 @@ +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement'; +import { EMPTY_NODE_ID } from '@/workflow/workflow-diagram/constants/EmptyNodeId'; import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; @@ -16,6 +18,7 @@ import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/co import { WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramNodeDefaultSourceHandleId'; import { useConnectionState } from '@/workflow/workflow-diagram/workflow-nodes/hooks/useConnectionState'; import { isNodeTitleHighlighted } from '@/workflow/workflow-diagram/workflow-nodes/utils/isNodeTitleHighlighted'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { Position } from '@xyflow/react'; @@ -61,6 +64,10 @@ export const WorkflowDiagramStepNodeEditableContent = ({ const { isNodeCreationStarted } = useStartNodeCreation(); + const workflowInsertStepIds = useRecoilComponentValue( + workflowInsertStepIdsComponentState, + ); + const { isConnectable, isConnectingSource, isConnectionInProgress } = useConnectionState(data.nodeType); @@ -79,6 +86,11 @@ export const WorkflowDiagramStepNodeEditableContent = ({ actionType: data.nodeType === 'action' ? data.actionType : undefined, }); + const isCreatingEmptyNodeFromThisNode = + isDefined(workflowInsertStepIds.position) && + !isDefined(workflowInsertStepIds.nextStepId) && + workflowInsertStepIds.parentStepId === data.stepId; + return ( <> - {!data.hasNextStepIds && !isConnectionInProgress && ( - - - ) : undefined + {!data.hasNextStepIds && + !isConnectionInProgress && + !isCreatingEmptyNodeFromThisNode && ( + - + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onClick={handleAddStepButtonContainerClick} + > + + ) : undefined + } + /> + + )} + + {id !== EMPTY_NODE_ID && ( + )} - - - {isDefined(data.rightHandleOptions) && ( + {id !== EMPTY_NODE_ID && isDefined(data.rightHandleOptions) && (