feat: Add drag-to-create node with placeholder preview in workflow diagram (#16873)

Closes #14951

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdul Rahman
2026-01-05 17:39:09 +05:30
committed by GitHub
parent cc7fd99879
commit fe5c90ed42
7 changed files with 245 additions and 62 deletions
@@ -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<boolean>(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 (
<StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers />
@@ -455,6 +583,8 @@ export const WorkflowDiagramCanvasBase = ({
onNodesChange={handleNodesChanges}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onConnectStart={handleConnectStart}
onConnectEnd={handleConnectEnd}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
@@ -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}
/>
<WorkflowDiagramCanvasEditableEffect />
@@ -0,0 +1 @@
export const EMPTY_NODE_ID = 'empty-node';
@@ -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;
@@ -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,
@@ -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;
};
@@ -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 (
<>
<WorkflowNodeContainer
@@ -111,56 +123,60 @@ export const WorkflowDiagramStepNodeEditableContent = ({
</WorkflowNodeRightPart>
</WorkflowNodeContainer>
{!data.hasNextStepIds && !isConnectionInProgress && (
<StyledAddStepButtonContainer
shouldDisplay={
data.nodeType === 'trigger' ||
isHovered ||
selected ||
isNodeCreationStarted({ parentStepId: data.stepId })
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleAddStepButtonContainerClick}
>
<WorkflowDiagramCreateStepElement
data={data}
Label={
isDefined(data.defaultHandleOptions?.label) ? (
<WorkflowDiagramEdgeLabel
label={i18n._(data.defaultHandleOptions.label)}
/>
) : undefined
{!data.hasNextStepIds &&
!isConnectionInProgress &&
!isCreatingEmptyNodeFromThisNode && (
<StyledAddStepButtonContainer
shouldDisplay={
data.nodeType === 'trigger' ||
isHovered ||
selected ||
isNodeCreationStarted({ parentStepId: data.stepId })
}
/>
</StyledAddStepButtonContainer>
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleAddStepButtonContainerClick}
>
<WorkflowDiagramCreateStepElement
data={data}
Label={
isDefined(data.defaultHandleOptions?.label) ? (
<WorkflowDiagramEdgeLabel
label={i18n._(data.defaultHandleOptions.label)}
/>
) : undefined
}
/>
</StyledAddStepButtonContainer>
)}
{id !== EMPTY_NODE_ID && (
<WorkflowDiagramHandleSource
id={WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID}
type="source"
position={Position.Bottom}
selected={
isSourceSelected({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) ||
selected ||
isConnectingSource({
nodeId: id,
sourceHandleId: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) ||
(isNodeConnectable && isHovered)
}
hovered={
isSourceHovered({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) || isHovered
}
/>
)}
<WorkflowDiagramHandleSource
id={WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID}
type="source"
position={Position.Bottom}
selected={
isSourceSelected({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) ||
selected ||
isConnectingSource({
nodeId: id,
sourceHandleId: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) ||
(isNodeConnectable && isHovered)
}
hovered={
isSourceHovered({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) || isHovered
}
/>
{isDefined(data.rightHandleOptions) && (
{id !== EMPTY_NODE_ID && isDefined(data.rightHandleOptions) && (
<WorkflowDiagramHandleSource
id={data.rightHandleOptions.id}
type="source"