Implement creating node before initial step + deleting loop edge (#14525)

https://github.com/user-attachments/assets/d4ef0fa1-89e0-4848-9a4d-93dee81254f7

## Remaining to handle in future PRs

- It would be easier for the frontend if the loop edge was stored in the
backend instead of being an edge case the frontend must handle
- Optimistic rendering when deleting loop edge
This commit is contained in:
Baptiste Devessier
2025-09-16 13:50:56 +02:00
committed by GitHub
parent e505e9cf9f
commit 60de57f469
14 changed files with 74 additions and 178 deletions
@@ -49,13 +49,15 @@ export const CommandMenuWorkflowSelectActionContent = () => {
);
}
const { parentStepId, nextStepId, position } = workflowInsertStepIds;
const { parentStepId, nextStepId, position, connectionOptions } =
workflowInsertStepIds;
const createdStep = await createStep({
newStepType: actionType,
parentStepId,
nextStepId,
position,
connectionOptions,
});
if (!isDefined(createdStep)) {
@@ -16,7 +16,6 @@ import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/workflow-e
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramIteratorEmptyActionEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramIteratorEmptyActionEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable';
import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
@@ -72,8 +71,6 @@ export const WorkflowDiagramCanvasEditable = () => {
await deleteEdge({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
});
};
@@ -128,7 +125,6 @@ export const WorkflowDiagramCanvasEditable = () => {
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'empty-trigger': WorkflowDiagramEmptyTriggerEditable,
'iterator-empty-action': WorkflowDiagramIteratorEmptyActionEditable,
}}
edgeTypes={{
blank: WorkflowDiagramBlankEdge,
@@ -4,6 +4,7 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { useCallback, useContext } from 'react';
import { useSetRecoilState } from 'recoil';
@@ -34,12 +35,19 @@ export const useStartNodeCreation = () => {
parentStepId,
nextStepId,
position,
connectionOptions,
}: {
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
connectionOptions?: WorkflowStepConnectionOptions;
}) => {
setWorkflowInsertStepIds({ parentStepId, nextStepId, position });
setWorkflowInsertStepIds({
parentStepId,
nextStepId,
position,
connectionOptions,
});
if (!isDefined(workflowVisualizerWorkflowId)) {
return;
@@ -3,7 +3,6 @@ import {
type WorkflowRunStepStatus,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { type FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { type MessageDescriptor } from '@lingui/core';
import {
@@ -94,8 +93,7 @@ export type WorkflowDiagramEmptyTriggerNodeData = {
export type WorkflowDiagramNodeData =
| WorkflowDiagramStepNodeData
| WorkflowDiagramEmptyTriggerNodeData
| WorkflowDiagramIteratorEmptyActionNodeData;
| WorkflowDiagramEmptyTriggerNodeData;
export type WorkflowRunDiagramNodeData = Exclude<
WorkflowDiagramStepNodeData,
@@ -127,10 +125,7 @@ export type WorkflowDiagramEdgeData =
| WorkflowDiagramFilterEdgeData
| WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'
| 'iterator-empty-action';
export type WorkflowDiagramNodeType = 'default' | 'empty-trigger';
export type WorkflowDiagramEdgeType =
| 'blank'
@@ -7,12 +7,11 @@ import {
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramIteratorNodeLoopHandleId';
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 { msg } from '@lingui/core/macro';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isNonEmptyString } from '@sniptt/guards';
import { Position } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -78,56 +77,14 @@ export const generateNodesAndEdgesForIteratorNode = ({
? [step.settings.input.initialLoopStepIds]
: [];
if (isNonEmptyArray(initialLoopStepIds)) {
for (const initialLoopStepId of initialLoopStepIds) {
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
sourceHandle: WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID,
target: initialLoopStepId,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
targetHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID,
data: {
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION.data,
labelOptions: {
position: Position.Right,
label: msg`loop`,
},
},
});
}
} else {
const emptyNodeId = `${step.id}-empty-loop`;
updatedNodes.push({
id: emptyNodeId,
type: 'iterator-empty-action',
data: {
nodeType: 'iterator-empty-action',
parentIteratorStepId: step.id,
position: {
x: iteratorNode.position.x + 175,
y: iteratorNode.position.y + 75,
},
} satisfies WorkflowDiagramIteratorEmptyActionNodeData,
position: {
x: iteratorNode.position.x + 175,
y: iteratorNode.position.y + 75,
},
draggable: false,
});
for (const initialLoopStepId of initialLoopStepIds) {
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
sourceHandle: WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID,
target: emptyNodeId,
target: initialLoopStepId,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
@@ -140,19 +97,6 @@ export const generateNodesAndEdgesForIteratorNode = ({
},
},
});
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: emptyNodeId,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
target: step.id,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
targetHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID,
});
}
step.nextStepIds?.forEach((child) => {
@@ -11,6 +11,7 @@ import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-di
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
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 { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useLingui } from '@lingui/react/macro';
@@ -93,6 +94,9 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
parentStepId: source,
nextStepId: target,
position: { x: labelX, y: labelY },
connectionOptions: getConnectionOptionsForSourceHandle({
sourceHandleId,
}),
});
};
@@ -101,9 +105,10 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
await deleteEdge({
source,
sourceHandle: sourceHandleId,
target,
targetHandle: targetHandleId,
sourceConnectionOptions: getConnectionOptionsForSourceHandle({
sourceHandleId,
}),
});
};
@@ -177,9 +177,7 @@ export const WorkflowDiagramFilterEdgeEditable = ({
await deleteEdge({
source,
sourceHandle: sourceHandleId,
target,
targetHandle: targetHandleId,
});
};
@@ -0,0 +1,22 @@
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
import { WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramIteratorNodeLoopHandleId';
export const getConnectionOptionsForSourceHandle = ({
sourceHandleId,
}: {
sourceHandleId: string;
}): WorkflowStepConnectionOptions | undefined => {
switch (sourceHandleId) {
case WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID: {
return {
connectedStepType: 'ITERATOR',
settings: {
isConnectedToLoop: true,
},
};
}
default: {
return undefined;
}
}
};
@@ -1,8 +0,0 @@
export type WorkflowDiagramIteratorEmptyActionNodeData = {
nodeType: 'iterator-empty-action';
parentIteratorStepId: string;
position: {
x: number;
y: number;
};
};
@@ -0,0 +1,11 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
type WorkflowIteratorStepConnectionOptions = {
connectedStepType: Extract<WorkflowActionType, 'ITERATOR'>;
settings: {
isConnectedToLoop: boolean;
};
};
export type WorkflowStepConnectionOptions =
WorkflowIteratorStepConnectionOptions;
@@ -1,92 +0,0 @@
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { useStartIteratorFirstNodeCreation } from '@/workflow/workflow-diagram/workflow-iterator/hooks/useStartIteratorFirstNodeCreation';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
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 { useLingui } from '@lingui/react/macro';
import { Position } from '@xyflow/react';
import { useState } from 'react';
export const WorkflowDiagramIteratorEmptyActionEditable = ({
id,
selected,
}: {
id: string;
selected: boolean;
data: WorkflowDiagramIteratorEmptyActionNodeData;
}) => {
const { t } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const { isConnectable, isSourceConnected } = useConnectionState('action');
const { isSourceSelected, isSourceHovered } = useEdgeState();
const { startIteratorFirstNodeCreation } =
useStartIteratorFirstNodeCreation();
const handleClick = () => {
startIteratorFirstNodeCreation();
};
return (
<WorkflowNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowDiagramHandleTarget isConnectable={false} />
<WorkflowNodeIconContainer />
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<WorkflowNodeLabel>{t`Action`}</WorkflowNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<WorkflowNodeTitle>{t`Add an Action`}</WorkflowNodeTitle>
</WorkflowNodeRightPart>
<WorkflowDiagramHandleSource
id={WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID}
type="source"
position={Position.Bottom}
disableHoverEffect
selected={
isSourceSelected({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) ||
selected ||
isSourceConnected(id) ||
(isConnectable(id) && isHovered)
}
hovered={
isSourceHovered({
nodeId: id,
sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID,
}) || isHovered
}
/>
</WorkflowNodeContainer>
);
};
@@ -3,6 +3,7 @@ import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUp
import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState';
import { type WorkflowStepType } from '@/workflow/types/Workflow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -25,11 +26,13 @@ export const useCreateStep = () => {
parentStepId,
nextStepId,
position,
connectionOptions,
}: {
newStepType: WorkflowStepType;
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
connectionOptions?: WorkflowStepConnectionOptions;
}) => {
if (isLoading === true) {
return;
@@ -47,6 +50,7 @@ export const useCreateStep = () => {
parentStepId,
nextStepId,
position,
parentStepConnectionOptions: connectionOptions,
})
)?.data?.createWorkflowVersionStep;
@@ -1,8 +1,14 @@
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { type WorkflowDiagramEdgeDescriptor } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeDescriptor';
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
import { useDeleteWorkflowVersionEdge } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge';
import { useState } from 'react';
type DeleteEdgeParams = {
source: string;
target: string;
sourceConnectionOptions?: WorkflowStepConnectionOptions;
};
export const useDeleteEdge = () => {
const [isLoading, setIsLoading] = useState(false);
@@ -14,7 +20,8 @@ export const useDeleteEdge = () => {
const deleteEdge = async ({
source,
target,
}: WorkflowDiagramEdgeDescriptor) => {
sourceConnectionOptions,
}: DeleteEdgeParams) => {
if (isLoading) {
return;
}
@@ -29,6 +36,7 @@ export const useDeleteEdge = () => {
workflowVersionId,
source,
target,
sourceConnectionOptions,
})
)?.data?.deleteWorkflowVersionEdge;
@@ -1,10 +1,12 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
type WorkflowInsertStepIdsState = {
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
connectionOptions?: WorkflowStepConnectionOptions;
};
export const workflowInsertStepIdsComponentState =
@@ -14,6 +16,7 @@ export const workflowInsertStepIdsComponentState =
parentStepId: undefined,
nextStepId: undefined,
position: undefined,
connectionOptions: undefined,
},
componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
});