From 4d994fc566eeb4d5cbfd5c1b944ad062eefa8d6b Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 18 Sep 2025 17:43:42 +0200 Subject: [PATCH] Improve edge path algorithm (#14600) https://github.com/user-attachments/assets/795b8293-60d2-44de-b261-7db093716e14 Closes https://github.com/twentyhq/core-team-issues/issues/1470 --- .../workflow-diagram/types/WorkflowDiagram.ts | 5 + .../generateWorkflowRunDiagram.test.ts | 9 ++ .../utils/__tests__/isLastStepOfLoop.test.ts | 117 +++++++++++++++++ .../generateNodesAndEdgesForDefaultNode.ts | 42 ++++-- .../generateNodesAndEdgesForIteratorNode.ts | 60 +++++---- .../utils/generateWorkflowDiagram.ts | 2 + .../utils/getEdgePathStrategy.ts | 30 +++++ .../utils/isLastStepOfLoop.ts | 51 ++++++++ .../components/WorkflowDiagramBlankEdge.tsx | 28 ++-- .../WorkflowDiagramDefaultEdgeEditable.tsx | 36 ++++-- .../WorkflowDiagramDefaultEdgeReadonly.tsx | 30 +++-- .../WorkflowDiagramDefaultEdgeRun.tsx | 27 +++- .../workflow-edges/utils/getEdgePath.ts | 122 ++++++++++++++++++ 13 files changed, 488 insertions(+), 71 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isLastStepOfLoop.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getEdgePathStrategy.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isLastStepOfLoop.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/utils/getEdgePath.ts 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 73ec0d284e..3c6b9e75d7 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 @@ -104,10 +104,15 @@ export type WorkflowDiagramEdgeLabelOptions = { label: MessageDescriptor; }; +export type WorkflowDiagramEdgePathStrategy = + | 'smooth-step-path-to-target' + | 'bypass-source-node-on-right-side'; + export type WorkflowDiagramDefaultEdgeData = { edgeType: 'default'; edgeExecutionStatus?: StepStatus; labelOptions?: WorkflowDiagramEdgeLabelOptions; + edgePathStrategy?: WorkflowDiagramEdgePathStrategy; }; export type WorkflowDiagramEdgeData = WorkflowDiagramDefaultEdgeData; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts index 3cb6cc27d0..68e8ff2383 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts @@ -130,6 +130,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "FAILED", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -147,6 +148,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "NOT_STARTED", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -367,6 +369,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "SUCCESS", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -384,6 +387,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "SUCCESS", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -604,6 +608,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "RUNNING", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -621,6 +626,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "NOT_STARTED", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -860,6 +866,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "SUCCESS", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -877,6 +884,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "RUNNING", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, @@ -894,6 +902,7 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "edgeExecutionStatus": "NOT_STARTED", + "edgePathStrategy": undefined, "edgeType": "default", }, "deletable": false, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isLastStepOfLoop.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isLastStepOfLoop.test.ts new file mode 100644 index 0000000000..0301190841 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isLastStepOfLoop.test.ts @@ -0,0 +1,117 @@ +import { + type WorkflowIteratorAction, + type WorkflowStep, +} from '@/workflow/types/Workflow'; +import { isLastStepOfLoop } from '../isLastStepOfLoop'; + +describe('isLastStepOfLoop', () => { + const makeIterator = ( + id: string, + initialLoopStepIds: string[], + ): WorkflowIteratorAction => ({ + id, + type: 'ITERATOR', + settings: { + input: { initialLoopStepIds }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + name: 'Iterator', + valid: true, + }); + + const makeStep = (id: string, nextStepIds: string[] = []): WorkflowStep => + ({ + id, + type: 'CODE', + nextStepIds, + name: id, + valid: true, + settings: {}, + }) as WorkflowStep; + + it('returns true when step is last before looping back to iterator', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(true); + }); + + it('returns false when step is not last before looping back', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['C']), + makeStep('C', ['iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(false); + }); + + it('returns false if step is not in loop', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'X', steps })).toBe(false); + }); + + it('returns false if loop never reaches step', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [makeStep('A', ['iterator']), makeStep('iterator', [])]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(false); + }); + + it('returns false if step does not point to iterator', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['C']), + makeStep('C', ['iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(false); + }); + + it('handles cycles and does not infinite loop, still finds last step', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['A', 'iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(true); + }); + + it('continues processing even if revisiting iterator node', () => { + const iterator = makeIterator('iterator', ['A']); + const steps = [ + makeStep('A', ['iterator', 'B']), + makeStep('B', ['iterator']), + makeStep('iterator', ['A']), + ]; + // B is reachable after revisiting iterator, and points to iterator + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(true); + }); + + it('returns true if multiple initialLoopStepIds and one is last', () => { + const iterator = makeIterator('iterator', ['A', 'X']); + const steps = [ + makeStep('A', ['B']), + makeStep('B', ['iterator']), + makeStep('X', ['Y']), + makeStep('Y', ['iterator']), + makeStep('iterator', []), + ]; + expect(isLastStepOfLoop({ iterator, stepId: 'B', steps })).toBe(true); + expect(isLastStepOfLoop({ iterator, stepId: 'Y', steps })).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForDefaultNode.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForDefaultNode.ts index a5398f0871..d6cfdf9e69 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForDefaultNode.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForDefaultNode.ts @@ -5,15 +5,18 @@ import { type WorkflowDiagramNode, type WorkflowDiagramStepNodeData, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getEdgePathStrategy } from '@/workflow/workflow-diagram/utils/getEdgePathStrategy'; import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes'; import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration'; 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 { isNonEmptyArray } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; export const generateNodesAndEdgesForDefaultNode = ({ step, + steps, xPos, yPos, nodes, @@ -21,6 +24,7 @@ export const generateNodesAndEdgesForDefaultNode = ({ workflowContext, }: { step: WorkflowStep; + steps: WorkflowStep[]; yPos: number; xPos: number; nodes: readonly WorkflowDiagramNode[]; @@ -57,20 +61,30 @@ export const generateNodesAndEdgesForDefaultNode = ({ }, }); - step.nextStepIds?.forEach((child) => { - updatedEdges.push({ - ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, - type: edgeTypeBetweenTwoNodes, - id: v4(), - source: step.id, - sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID, - target: child, - targetHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID, - ...(edgeTypeBetweenTwoNodes.includes('editable') - ? { deletable: true, selectable: true } - : {}), - }); - }); + if (isNonEmptyArray(step.nextStepIds)) { + for (const nextStepId of step.nextStepIds) { + updatedEdges.push({ + ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, + type: edgeTypeBetweenTwoNodes, + id: v4(), + source: step.id, + sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID, + target: nextStepId, + targetHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_TARGET_HANDLE_ID, + data: { + ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION.data, + edgePathStrategy: getEdgePathStrategy({ + step, + steps, + nextStepId, + }), + }, + ...(edgeTypeBetweenTwoNodes.includes('editable') + ? { deletable: true, selectable: true } + : {}), + }); + } + } return { nodes: updatedNodes, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForIteratorNode.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForIteratorNode.ts index 1553e40ac4..9fac66faa8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForIteratorNode.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateNodesAndEdgesForIteratorNode.ts @@ -1,30 +1,36 @@ -import { type WorkflowStep } from '@/workflow/types/Workflow'; +import { + type WorkflowIteratorAction, + type WorkflowStep, +} from '@/workflow/types/Workflow'; import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext'; import { type WorkflowDiagramEdge, type WorkflowDiagramNode, type WorkflowDiagramStepNodeData, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getEdgePathStrategy } from '@/workflow/workflow-diagram/utils/getEdgePathStrategy'; import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes'; import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration'; 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 { isNonEmptyString } from '@sniptt/guards'; +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; import { Position } from '@xyflow/react'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; export const generateNodesAndEdgesForIteratorNode = ({ step, + steps, xPos, yPos, nodes, edges, workflowContext, }: { - step: WorkflowStep & { type: 'ITERATOR' }; + step: WorkflowIteratorAction; + steps: WorkflowStep[]; yPos: number; xPos: number; nodes: readonly WorkflowDiagramNode[]; @@ -95,31 +101,39 @@ export const generateNodesAndEdgesForIteratorNode = ({ position: Position.Right, label: msg`loop`, }, + edgePathStrategy: 'smooth-step-path-to-target', }, }); } - step.nextStepIds?.forEach((child) => { - updatedEdges.push({ - ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, - type: edgeTypeBetweenTwoNodes, - id: v4(), - source: step.id, - sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID, - target: child, - ...(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.Bottom, - label: msg`completed`, + if (isNonEmptyArray(step.nextStepIds)) { + for (const nextStepId of step.nextStepIds) { + updatedEdges.push({ + ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, + type: edgeTypeBetweenTwoNodes, + id: v4(), + source: step.id, + sourceHandle: WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID, + target: nextStepId, + ...(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.Bottom, + label: msg`completed`, + }, + edgePathStrategy: getEdgePathStrategy({ + step, + steps, + nextStepId, + }), }, - }, - }); - }); + }); + } + } return { nodes: updatedNodes, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts index 0623bde424..a3c588f8cd 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts @@ -70,6 +70,7 @@ export const generateWorkflowDiagram = ({ const { nodes: iteratorNodes, edges: iteratorEdges } = generateNodesAndEdgesForIteratorNode({ step, + steps, xPos, yPos: levelYPos, nodes, @@ -86,6 +87,7 @@ export const generateWorkflowDiagram = ({ const { nodes: defaultNodes, edges: defaultEdges } = generateNodesAndEdgesForDefaultNode({ step, + steps, xPos, yPos: levelYPos, nodes, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getEdgePathStrategy.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getEdgePathStrategy.ts new file mode 100644 index 0000000000..71bb0b3967 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getEdgePathStrategy.ts @@ -0,0 +1,30 @@ +import { type WorkflowStep } from '@/workflow/types/Workflow'; +import { isLastStepOfLoop } from '@/workflow/workflow-diagram/utils/isLastStepOfLoop'; +import { isDefined } from 'twenty-shared/utils'; + +export const getEdgePathStrategy = ({ + step, + nextStepId, + steps, +}: { + step: WorkflowStep; + nextStepId: string; + steps: WorkflowStep[]; +}) => { + const nextStep = steps.find((s) => s.id === nextStepId); + if (!isDefined(nextStep)) { + throw new Error('Expected to find step defined in nextStepIds'); + } + + const useLoopBackToIteratorStyle = + nextStep.type === 'ITERATOR' && + isLastStepOfLoop({ + iterator: nextStep, + stepId: step.id, + steps, + }); + + return useLoopBackToIteratorStyle + ? 'bypass-source-node-on-right-side' + : undefined; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isLastStepOfLoop.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isLastStepOfLoop.ts new file mode 100644 index 0000000000..9a4d133e6b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isLastStepOfLoop.ts @@ -0,0 +1,51 @@ +import { + type WorkflowIteratorAction, + type WorkflowStep, +} from '@/workflow/types/Workflow'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +export const isLastStepOfLoop = ({ + iterator, + stepId, + steps, +}: { + iterator: WorkflowIteratorAction; + stepId: string; + steps: WorkflowStep[]; +}): boolean => { + const stepMap = new Map(steps.map((s) => [s.id, s])); + const visited = new Set(); + const queue = [...(iterator.settings.input?.initialLoopStepIds ?? [])]; + + while (queue.length > 0) { + const currentId = queue.shift(); + + if (!isDefined(currentId) || visited.has(currentId)) { + continue; + } + + visited.add(currentId); + + const currentStep = stepMap.get(currentId); + + if (!isDefined(currentStep)) { + continue; + } + + if ( + currentId === stepId && + currentStep.nextStepIds?.includes(iterator.id) === true + ) { + return true; + } + + if (isNonEmptyArray(currentStep.nextStepIds)) { + for (const nextId of currentStep.nextStepIds) { + queue.push(nextId); + } + } + } + + return false; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge.tsx index 86c3914773..041c60725b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge.tsx @@ -1,6 +1,7 @@ import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getEdgePath } from '@/workflow/workflow-diagram/workflow-edges/utils/getEdgePath'; import { useTheme } from '@emotion/react'; -import { BaseEdge, type EdgeProps, getBezierPath } from '@xyflow/react'; +import { BaseEdge, type EdgeProps } from '@xyflow/react'; type WorkflowDiagramBlankEdgeProps = EdgeProps; @@ -9,24 +10,35 @@ export const WorkflowDiagramBlankEdge = ({ markerEnd, sourceY, sourceX, + sourcePosition, targetY, targetX, + targetPosition, }: WorkflowDiagramBlankEdgeProps) => { const theme = useTheme(); - const [edgePath] = getBezierPath({ + const { segments } = getEdgePath({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, + markerStart, + markerEnd, }); return ( - + <> + {segments.map((segment) => ( + + ))} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx index 160ca1c8fe..0bbf18f164 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx @@ -9,9 +9,10 @@ import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workf 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 { getEdgePath } from '@/workflow/workflow-diagram/workflow-edges/utils/getEdgePath'; import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge'; import { useLingui } from '@lingui/react/macro'; -import { EdgeLabelRenderer, getBezierPath } from '@xyflow/react'; +import { EdgeLabelRenderer } from '@xyflow/react'; import { type MouseEvent } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { IconPlus, IconTrash } from 'twenty-ui/display'; @@ -22,8 +23,10 @@ type WorkflowDiagramDefaultEdgeEditableProps = export const WorkflowDiagramDefaultEdgeEditable = ({ source, sourceHandleId, + sourcePosition, target, targetHandleId, + targetPosition, sourceX, sourceY, targetX, @@ -36,11 +39,19 @@ export const WorkflowDiagramDefaultEdgeEditable = ({ const { isEdgeHovered } = useEdgeState(); - const [edgePath, labelX, labelY] = getBezierPath({ + const { + segments, + labelPosition: [labelX, labelY], + } = getEdgePath({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, + markerStart, + markerEnd, + strategy: data?.edgePathStrategy, }); const { deleteEdge } = useDeleteEdge(); @@ -77,15 +88,18 @@ export const WorkflowDiagramDefaultEdgeEditable = ({ return ( <> - + {segments.map((segment) => ( + + ))} {isDefined(data?.labelOptions) && ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeReadonly.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeReadonly.tsx index 2d160621e4..53eb282976 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeReadonly.tsx @@ -1,6 +1,7 @@ import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps'; +import { getEdgePath } from '@/workflow/workflow-diagram/workflow-edges/utils/getEdgePath'; import { useTheme } from '@emotion/react'; -import { BaseEdge, getBezierPath } from '@xyflow/react'; +import { BaseEdge } from '@xyflow/react'; type WorkflowDiagramDefaultEdgeReadonlyProps = WorkflowDiagramEdgeComponentProps; @@ -8,26 +9,39 @@ type WorkflowDiagramDefaultEdgeReadonlyProps = export const WorkflowDiagramDefaultEdgeReadonly = ({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, markerStart, markerEnd, + data, }: WorkflowDiagramDefaultEdgeReadonlyProps) => { const theme = useTheme(); - const [edgePath] = getBezierPath({ + const { segments } = getEdgePath({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, + markerStart, + markerEnd, + strategy: data?.edgePathStrategy, }); return ( - + <> + {segments.map((segment) => ( + + ))} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeRun.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeRun.tsx index ba03fa9f6c..4a4e0a3286 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeRun.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeRun.tsx @@ -1,29 +1,42 @@ import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowRunDiagramBaseEdge'; import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps'; -import { getBezierPath } from '@xyflow/react'; +import { getEdgePath } from '@/workflow/workflow-diagram/workflow-edges/utils/getEdgePath'; type WorkflowDiagramDefaultEdgeRunProps = WorkflowDiagramEdgeComponentProps; export const WorkflowDiagramDefaultEdgeRun = ({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, markerStart, markerEnd, + data, }: WorkflowDiagramDefaultEdgeRunProps) => { - const [edgePath] = getBezierPath({ + const { segments } = getEdgePath({ sourceX, sourceY, + sourcePosition, targetX, targetY, + targetPosition, + markerStart, + markerEnd, + strategy: data?.edgePathStrategy, }); return ( - + <> + {segments.map((segment) => ( + + ))} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/utils/getEdgePath.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/utils/getEdgePath.ts new file mode 100644 index 0000000000..eedd7c1549 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/utils/getEdgePath.ts @@ -0,0 +1,122 @@ +import { type WorkflowDiagramEdgePathStrategy } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps'; +import { getBezierPath, getSmoothStepPath, Position } from '@xyflow/react'; + +const EDGE_PADDING_BOTTOM = 40; +const EDGE_PADDING_X = 40; +const EDGE_BORDER_RADIUS = 16; + +type GetEdgePathParams = Pick< + WorkflowDiagramEdgeComponentProps, + | 'sourceX' + | 'sourceY' + | 'sourcePosition' + | 'targetX' + | 'targetY' + | 'targetPosition' + | 'markerStart' + | 'markerEnd' +> & { + strategy?: WorkflowDiagramEdgePathStrategy; +}; + +export const getEdgePath = ({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + markerStart, + markerEnd, + strategy, +}: GetEdgePathParams) => { + if (strategy === 'smooth-step-path-to-target') { + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: EDGE_BORDER_RADIUS, + offset: EDGE_PADDING_X, + }); + + return { + segments: [ + { + path, + markerStart, + markerEnd, + }, + ], + labelPosition: [labelX, labelY], + }; + } + + if (sourceY < targetY) { + const [path, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return { + segments: [ + { + path, + markerStart, + markerEnd, + }, + ], + labelPosition: [labelX, labelY], + }; + } + + const firstSegmentTargetX = + strategy === 'bypass-source-node-on-right-side' + ? sourceX + 200 + : (sourceX + targetX) / 2; + const firstSegmentTargetY = sourceY + EDGE_PADDING_BOTTOM; + const firstSegment = getSmoothStepPath({ + sourceX, + sourceY, + targetX: firstSegmentTargetX, + targetY: firstSegmentTargetY, + sourcePosition, + targetPosition: Position.Bottom, + borderRadius: EDGE_BORDER_RADIUS, + offset: EDGE_PADDING_X, + }); + + const secondSegment = getSmoothStepPath({ + sourceX: firstSegmentTargetX, + sourceY: firstSegmentTargetY, + targetX, + targetY, + sourcePosition: Position.Top, + targetPosition, + borderRadius: EDGE_BORDER_RADIUS, + offset: EDGE_PADDING_X, + }); + + return { + segments: [ + { + path: firstSegment[0], + markerStart, + markerEnd: undefined, + }, + { + path: secondSegment[0], + markerStart: undefined, + markerEnd, + }, + ], + labelPosition: [firstSegmentTargetX, firstSegmentTargetY], + }; +};