From cf01faf2767f6e2b74a0679f18bd2dcbf618fcd5 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 12 Jun 2025 14:14:21 +0200 Subject: [PATCH] 965 flow control arrow menu 1/3 add insert step button (#12519) Add insert step button to workflow edges https://github.com/user-attachments/assets/7144f722-f1c7-450f-a8eb-c902071986a1 Also fixes `iconButtonGroup` UI component ## Before https://github.com/user-attachments/assets/7b5f0245-d0e8-48af-9aa5-a29388a1caea ## After https://github.com/user-attachments/assets/1820874f-aa99-41ae-8254-c76c275ee3ae --- .../components/SettingsDataModelOverview.tsx | 3 +- .../hooks/useDeleteWorkflowVersionStep.ts | 21 ++- .../WorkflowDiagramCanvasEditableEffect.tsx | 5 +- .../components/WorkflowDiagramDefaultEdge.tsx | 31 +++- .../components/WorkflowDiagramEdgeOptions.tsx | 56 ++++++ .../components/WorkflowDiagramEffect.tsx | 6 +- .../constants/CreateStepNodeWidth.ts | 2 +- .../hooks/useStartNodeCreation.ts | 18 +- .../workflow-diagram/types/WorkflowDiagram.ts | 9 +- .../__tests__/addCreateStepNodes.test.ts | 2 + .../__tests__/generateWorkflowDiagram.test.ts | 164 ++++++++++++++++- .../generateWorkflowRunDiagram.test.ts | 50 +++-- .../workflow-diagram/utils/addEdgeOptions.ts | 14 ++ .../utils/generateWorkflowDiagram.ts | 133 +++++++++----- .../utils/generateWorkflowRunDiagram.ts | 173 ++++++------------ .../workflow-diagram/utils/isStepNode.ts | 10 + .../hooks/__tests__/useCreateStep.test.tsx | 6 +- .../workflow-steps/hooks/useCreateStep.ts | 16 +- .../hooks/useCreateWorkflowVersionStep.ts | 26 ++- ...=> workflowInsertStepIdsComponentState.ts} | 13 +- .../utils/__tests__/insert-step.spec.ts | 17 +- .../workflow-step/utils/insert-step.ts | 18 +- ...workflow-version-step.workspace-service.ts | 5 +- .../exceptions/workflow-run.exception.ts | 1 + .../workflow-runner/jobs/run-workflow.job.ts | 5 +- .../__tests__/getRootSteps.utils.spec.ts | 85 +++++++++ .../utils/getRootSteps.utils.ts | 24 +++ .../button/components/IconButtonGroup.tsx | 50 +++-- .../input/button/components/InsideButton.tsx | 47 +++++ .../__stories__/IconButtonGroup.stories.tsx | 34 +--- packages/twenty-ui/src/input/index.ts | 2 + 31 files changed, 755 insertions(+), 291 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isStepNode.ts rename packages/twenty-front/src/modules/workflow/workflow-steps/states/{workflowCreateStepFromParentStepIdComponentState.ts => workflowInsertStepIdsComponentState.ts} (50%) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/getRootSteps.utils.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/getRootSteps.utils.ts create mode 100644 packages/twenty-ui/src/input/button/components/InsideButton.tsx diff --git a/packages/twenty-front/src/modules/settings/data-model/graph-overview/components/SettingsDataModelOverview.tsx b/packages/twenty-front/src/modules/settings/data-model/graph-overview/components/SettingsDataModelOverview.tsx index 338112f96..eeb67afe4 100644 --- a/packages/twenty-front/src/modules/settings/data-model/graph-overview/components/SettingsDataModelOverview.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/graph-overview/components/SettingsDataModelOverview.tsx @@ -204,8 +204,7 @@ export const SettingsDataModelOverview = () => { > { input: DeleteWorkflowVersionStepInput, ) => { const result = await mutate({ variables: { input } }); + const deletedStep = result?.data?.deleteWorkflowVersionStep; + if (!isDefined(deletedStep)) { return; } @@ -43,6 +45,7 @@ export const useDeleteWorkflowVersionStep = () => { const cachedRecord = getRecordFromCache( input.workflowVersionId, ); + if (!isDefined(cachedRecord)) { return; } @@ -51,12 +54,21 @@ export const useDeleteWorkflowVersionStep = () => { ...cachedRecord, steps: (cachedRecord.steps || []) .filter((step: WorkflowAction) => step.id !== deletedStep.id) - .map((step) => { + .map((step: WorkflowAction) => { + if (!step.nextStepIds?.includes(deletedStep.id)) { + return step; + } + return { ...step, - nextStepIds: step.nextStepIds?.filter( - (nextStepId) => nextStepId !== deletedStep.id, - ), + nextStepIds: [ + ...new Set([ + ...(step.nextStepIds?.filter( + (nextStepId) => nextStepId !== deletedStep.id, + ) || []), + ...(deletedStep.nextStepIds || []), + ]), + ], }; }), }; @@ -64,6 +76,7 @@ export const useDeleteWorkflowVersionStep = () => { const recordGqlFields = { steps: true, }; + updateRecordFromCache({ objectMetadataItems, objectMetadataItem, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx index d54c46ff1..f3db904b4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx @@ -68,7 +68,10 @@ export const WorkflowDiagramCanvasEditableEffect = () => { } if (isCreateStepNode(selectedNode)) { - startNodeCreation(selectedNode.data.parentNodeId); + startNodeCreation({ + parentStepId: selectedNode.data.parentNodeId, + nextStepId: undefined, + }); return; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx index 075ce242b..fc508b3f4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx @@ -1,18 +1,23 @@ import { useTheme } from '@emotion/react'; import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; +import { WorkflowDiagramEdgeOptions } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions'; +import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -type WorkflowDiagramDefaultEdgeProps = EdgeProps; +type WorkflowDiagramDefaultEdgeProps = EdgeProps; export const WorkflowDiagramDefaultEdge = ({ + source, + target, sourceY, targetY, markerStart, markerEnd, + data, }: WorkflowDiagramDefaultEdgeProps) => { const theme = useTheme(); - const [edgePath] = getStraightPath({ + const [edgePath, labelX, labelY] = getStraightPath({ sourceX: CREATE_STEP_NODE_WIDTH, sourceY, targetX: CREATE_STEP_NODE_WIDTH, @@ -20,11 +25,21 @@ export const WorkflowDiagramDefaultEdge = ({ }); return ( - + <> + + {data?.shouldDisplayEdgeOptions && ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx new file mode 100644 index 000000000..22f0085da --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions.tsx @@ -0,0 +1,56 @@ +import { EdgeLabelRenderer } from '@xyflow/react'; +import { STEP_ICON_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; +import styled from '@emotion/styled'; +import { IconButtonGroup } from 'twenty-ui/input'; +import { IconPlus } from 'twenty-ui/display'; +import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; +import { isDefined } from 'twenty-shared/utils'; + +const EDGE_OPTION_BUTTON_LEFT_MARGIN = 8; + +const StyledIconButtonGroup = styled(IconButtonGroup)` + pointer-events: all; +`; + +const StyledContainer = styled.div<{ + labelX?: number; + labelY?: number; +}>` + position: absolute; + transform: ${({ labelX, labelY }) => + `translate(${labelX || 0}px, ${isDefined(labelY) ? labelY - STEP_ICON_WIDTH / 2 : 0}px) translateX(${EDGE_OPTION_BUTTON_LEFT_MARGIN}px)`}; +`; + +type WorkflowDiagramEdgeOptionsProps = { + labelX?: number; + labelY?: number; + parentStepId: string; + nextStepId: string; +}; + +export const WorkflowDiagramEdgeOptions = ({ + labelX, + labelY, + parentStepId, + nextStepId, +}: WorkflowDiagramEdgeOptionsProps) => { + const { startNodeCreation } = useStartNodeCreation(); + + return ( + + + { + startNodeCreation({ parentStepId, nextStepId }); + }, + }, + ]} + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx index 16c6a2da4..3efff1f38 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx @@ -16,6 +16,7 @@ import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWo import { useEffect } from 'react'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; +import { addEdgeOptions } from '@/workflow/workflow-diagram/utils/addEdgeOptions'; export const WorkflowDiagramEffect = ({ workflowWithCurrentVersion, @@ -44,10 +45,11 @@ export const WorkflowDiagramEffect = ({ ); const nextWorkflowDiagram = addCreateStepNodes( - getWorkflowVersionDiagram(currentVersion), + addEdgeOptions(getWorkflowVersionDiagram(currentVersion)), ); let mergedWorkflowDiagram = nextWorkflowDiagram; + if (isDefined(previousWorkflowDiagram)) { mergedWorkflowDiagram = mergeWorkflowDiagrams( previousWorkflowDiagram, @@ -59,6 +61,7 @@ export const WorkflowDiagramEffect = ({ snapshot, workflowLastCreatedStepIdState, ); + if (isDefined(lastCreatedStepId)) { mergedWorkflowDiagram.nodes = mergedWorkflowDiagram.nodes.map( (node) => { @@ -79,6 +82,7 @@ export const WorkflowDiagramEffect = ({ ); const currentVersion = workflowWithCurrentVersion?.currentVersion; + useEffect(() => { if (!isDefined(currentVersion)) { setFlow(undefined); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepNodeWidth.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepNodeWidth.ts index 11026a62a..c03e587e7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepNodeWidth.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepNodeWidth.ts @@ -1,6 +1,6 @@ import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth'; -const STEP_ICON_WIDTH = 24; +export const STEP_ICON_WIDTH = 24; const STEP_PADDING = 8; 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 1487c835a..a5cea2a6b 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 @@ -4,12 +4,12 @@ import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandM import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; -import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { isDefined } from 'twenty-shared/utils'; export const useStartNodeCreation = () => { - const setWorkflowCreateStepFromParentStepId = useSetRecoilComponentStateV2( - workflowCreateStepFromParentStepIdComponentState, + const setWorkflowInsertStepIds = useSetRecoilComponentStateV2( + workflowInsertStepIdsComponentState, ); const { openStepSelectInCommandMenu } = useWorkflowCommandMenu(); @@ -22,8 +22,14 @@ export const useStartNodeCreation = () => { * That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly. */ const startNodeCreation = useCallback( - (parentNodeId: string) => { - setWorkflowCreateStepFromParentStepId(parentNodeId); + ({ + parentStepId, + nextStepId, + }: { + parentStepId: string | undefined; + nextStepId: string | undefined; + }) => { + setWorkflowInsertStepIds({ parentStepId, nextStepId }); if (isDefined(workflowVisualizerWorkflowId)) { openStepSelectInCommandMenu(workflowVisualizerWorkflowId); @@ -31,7 +37,7 @@ export const useStartNodeCreation = () => { } }, [ - setWorkflowCreateStepFromParentStepId, + setWorkflowInsertStepIds, workflowVisualizerWorkflowId, openStepSelectInCommandMenu, ], 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 1930e4e5d..a86c26e88 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 @@ -4,11 +4,12 @@ import { } from '@/workflow/types/Workflow'; import { Edge, Node } from '@xyflow/react'; +export type WorkflowDiagramStepNode = Node; export type WorkflowDiagramNode = Node; -export type WorkflowDiagramEdge = Edge; +export type WorkflowDiagramEdge = Edge; export type WorkflowRunDiagramNode = Node; -export type WorkflowRunDiagramEdge = Edge; +export type WorkflowRunDiagramEdge = Edge; export type WorkflowRunDiagram = { nodes: Array; @@ -67,6 +68,10 @@ export type WorkflowRunDiagramNodeData = Exclude< 'runStatus' > & { runStatus: WorkflowDiagramRunStatus }; +export type EdgeData = { + shouldDisplayEdgeOptions?: boolean; +}; + export type WorkflowDiagramNodeType = | 'default' | 'empty-trigger' diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts index 4cedd97de..082371f3a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts @@ -30,6 +30,7 @@ describe('addCreateStepNodes', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -48,6 +49,7 @@ describe('addCreateStepNodes', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts index 1ec663b34..35f0602e5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts @@ -52,6 +52,7 @@ describe('generateWorkflowDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -70,6 +71,7 @@ describe('generateWorkflowDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; @@ -118,6 +120,7 @@ describe('generateWorkflowDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -136,15 +139,168 @@ describe('generateWorkflowDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; const result = generateWorkflowDiagram({ trigger, steps }); - expect(result.edges[0].source).toEqual(result.nodes[0].id); - expect(result.edges[0].target).toEqual(result.nodes[1].id); + expect(result.edges.length).toEqual(2); + expect(result.nodes.length).toEqual(3); - expect(result.edges[1].source).toEqual(result.nodes[1].id); - expect(result.edges[1].target).toEqual(result.nodes[2].id); + expect(result.edges[0].source).toEqual('trigger'); + expect(result.edges[0].target).toEqual('step1'); + + expect(result.edges[1].source).toEqual('step1'); + expect(result.edges[1].target).toEqual('step2'); + }); + + it('should take nextStepIds into account', () => { + const trigger: WorkflowTrigger = { + name: 'Company created', + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + outputSchema: {}, + }, + }; + const steps: WorkflowStep[] = [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + serverlessFunctionVersion: '1', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + }, + nextStepIds: undefined, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + serverlessFunctionVersion: '1', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + }, + nextStepIds: ['step1'], + }, + ]; + + const result = generateWorkflowDiagram({ trigger, steps }); + + expect(result.edges.length).toEqual(2); + expect(result.nodes.length).toEqual(3); + + expect(result.edges[0].source).toEqual('trigger'); + expect(result.edges[0].target).toEqual('step2'); + + expect(result.edges[1].source).toEqual('step2'); + expect(result.edges[1].target).toEqual('step1'); + }); + + it('should take nextStepIds into account for complex diagram', () => { + const trigger: WorkflowTrigger = { + name: 'Company created', + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + outputSchema: {}, + }, + }; + const steps: WorkflowStep[] = [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + serverlessFunctionVersion: '1', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + }, + nextStepIds: undefined, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + serverlessFunctionVersion: '1', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + }, + nextStepIds: ['step1'], + }, + { + id: 'step3', + name: 'Step 3', + type: 'CODE', + valid: true, + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + input: { + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + serverlessFunctionVersion: '1', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + }, + nextStepIds: ['step1'], + }, + ]; + + const result = generateWorkflowDiagram({ trigger, steps }); + + expect(result.edges.length).toEqual(4); + expect(result.nodes.length).toEqual(4); + + expect(result.edges[0].source).toEqual('trigger'); + expect(result.edges[0].target).toEqual('step2'); + + expect(result.edges[1].source).toEqual('trigger'); + expect(result.edges[1].target).toEqual('step3'); + + expect(result.edges[2].source).toEqual('step2'); + expect(result.edges[2].target).toEqual('step1'); + + expect(result.edges[3].source).toEqual('step3'); + expect(result.edges[3].target).toEqual('step1'); }); }); 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 86f42e1ee..0f18ed3d6 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 @@ -21,6 +21,7 @@ describe('generateWorkflowRunDiagram', () => { outputSchema: {}, }, }; + const steps: WorkflowStep[] = [ { id: 'step1', @@ -39,6 +40,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -57,6 +59,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step3'], }, { id: 'step3', @@ -75,8 +78,10 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; + const stepsOutput: WorkflowRunOutputStepsOutput = { step1: { result: undefined, @@ -144,7 +149,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step1", "position": { "x": 0, - "y": 0, + "y": 150, }, }, { @@ -157,7 +162,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step2", "position": { "x": 0, - "y": 150, + "y": 300, }, }, { @@ -170,7 +175,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step3", "position": { "x": 0, - "y": 300, + "y": 450, }, }, ], @@ -189,6 +194,7 @@ describe('generateWorkflowRunDiagram', () => { outputSchema: {}, }, }; + const steps: WorkflowStep[] = [ { id: 'step1', @@ -207,6 +213,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -225,6 +232,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step3'], }, { id: 'step3', @@ -243,8 +251,10 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; + const stepsOutput: WorkflowRunOutputStepsOutput = { step1: { result: {}, @@ -322,7 +332,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step1", "position": { "x": 0, - "y": 0, + "y": 150, }, }, { @@ -335,7 +345,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step2", "position": { "x": 0, - "y": 150, + "y": 300, }, }, { @@ -348,7 +358,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step3", "position": { "x": 0, - "y": 300, + "y": 450, }, }, ], @@ -367,6 +377,7 @@ describe('generateWorkflowRunDiagram', () => { outputSchema: {}, }, }; + const steps: WorkflowStep[] = [ { id: 'step1', @@ -385,6 +396,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -403,6 +415,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step3'], }, { id: 'step3', @@ -421,8 +434,10 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; + const stepsOutput = undefined; const result = generateWorkflowRunDiagram({ trigger, steps, stepsOutput }); @@ -485,7 +500,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step1", "position": { "x": 0, - "y": 0, + "y": 150, }, }, { @@ -498,7 +513,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step2", "position": { "x": 0, - "y": 150, + "y": 300, }, }, { @@ -511,7 +526,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step3", "position": { "x": 0, - "y": 300, + "y": 450, }, }, ], @@ -530,6 +545,7 @@ describe('generateWorkflowRunDiagram', () => { outputSchema: {}, }, }; + const steps: WorkflowStep[] = [ { id: 'step1', @@ -548,6 +564,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step2'], }, { id: 'step2', @@ -566,6 +583,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step3'], }, { id: 'step3', @@ -584,6 +602,7 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: ['step4'], }, { id: 'step4', @@ -602,8 +621,10 @@ describe('generateWorkflowRunDiagram', () => { }, outputSchema: {}, }, + nextStepIds: undefined, }, ]; + const stepsOutput: WorkflowRunOutputStepsOutput = { step1: { result: {}, @@ -681,7 +702,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step1", "position": { "x": 0, - "y": 0, + "y": 150, }, }, { @@ -694,7 +715,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step2", "position": { "x": 0, - "y": 150, + "y": 300, }, }, { @@ -707,7 +728,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step3", "position": { "x": 0, - "y": 300, + "y": 450, }, }, { @@ -720,7 +741,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step4", "position": { "x": 0, - "y": 450, + "y": 600, }, }, ], @@ -762,6 +783,7 @@ describe('generateWorkflowRunDiagram', () => { ], outputSchema: {}, }, + nextStepIds: undefined, }, ]; const stepsOutput = { @@ -814,7 +836,7 @@ describe('generateWorkflowRunDiagram', () => { "id": "step1", "position": { "x": 0, - "y": 0, + "y": 150, }, }, ], diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts new file mode 100644 index 000000000..ac2cd3e27 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts @@ -0,0 +1,14 @@ +import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; + +export const addEdgeOptions = ({ + nodes, + edges, +}: WorkflowDiagram): WorkflowDiagram => { + return { + nodes, + edges: edges.map((edge) => ({ + ...edge, + data: { shouldDisplayEdgeOptions: true }, + })), + }; +}; 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 2154850c5..aa9d1374b 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 @@ -15,6 +15,58 @@ import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerSt import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; +/** + * Groups workflow steps into levels based on their distance from root nodes. + * + * A root node is one that is not referenced as a `nextStepId` by any other step. + * The function performs a breadth-first traversal from all roots and assigns + * each step to a level indicating its depth in the graph. + * + * Returns an array where each sub-array contains all steps at the same level. + */ +const groupStepsByLevel = (steps: WorkflowStep[]): WorkflowStep[][] => { + const stepMap = new Map(); + + const childIds = new Set(); + + for (const step of steps) { + stepMap.set(step.id, step); + step.nextStepIds?.forEach((id) => childIds.add(id)); + } + + const rootSteps = steps.filter((step) => !childIds.has(step.id)); + + const stepsByLevel: WorkflowStep[][] = []; + + const visited = new Set(); + + const visit = ({ step, level }: { step: WorkflowStep; level: number }) => { + if (visited.has(step.id)) { + return; + } + + visited.add(step.id); + + if (!isDefined(stepsByLevel[level])) { + stepsByLevel[level] = []; + } + + stepsByLevel[level].push(step); + + step.nextStepIds?.forEach((childId) => { + const child = stepMap.get(childId); + + if (isDefined(child)) { + visit({ step: child, level: level + 1 }); + } + }); + }; + + rootSteps.forEach((root) => visit({ step: root, level: 0 })); + + return stepsByLevel; +}; + export const generateWorkflowDiagram = ({ trigger, steps, @@ -23,6 +75,7 @@ export const generateWorkflowDiagram = ({ steps: Array; }): WorkflowDiagram => { const nodes: Array = []; + const edges: Array = []; if (isDefined(trigger)) { @@ -31,58 +84,50 @@ export const generateWorkflowDiagram = ({ nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION); } - const processNode = ({ - stepIndex, - parentNodeId, - xPos, - yPos, - }: { - stepIndex: number; - parentNodeId: string; - xPos: number; - yPos: number; - }) => { - const step = steps.at(stepIndex); - if (!isDefined(step)) { - return; + const stepsGroupedByLevel = groupStepsByLevel(steps); + + let levelYPos = FIRST_NODE_POSITION.y; + + const xPos = FIRST_NODE_POSITION.x; + + for (const stepsByLevel of stepsGroupedByLevel) { + levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES; + + for (const step of stepsByLevel) { + nodes.push({ + id: step.id, + data: { + nodeType: 'action', + actionType: step.type, + name: step.name, + } satisfies WorkflowDiagramStepNodeData, + position: { + x: xPos, + y: levelYPos, + }, + }); } + } - const nodeId = step.id; - - nodes.push({ - id: nodeId, - data: { - nodeType: 'action', - actionType: step.type, - name: step.name, - } satisfies WorkflowDiagramStepNodeData, - position: { - x: xPos, - y: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES, - }, - }); - + for (const firstLevelStep of stepsGroupedByLevel[0] || []) { edges.push({ ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, id: v4(), - source: parentNodeId, - target: nodeId, + source: TRIGGER_STEP_ID, + target: firstLevelStep.id, }); + } - processNode({ - stepIndex: stepIndex + 1, - parentNodeId: nodeId, - xPos, - yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES, + for (const step of steps) { + step.nextStepIds?.forEach((child) => { + edges.push({ + ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, + id: v4(), + source: step.id, + target: child, + }); }); - }; - - processNode({ - stepIndex: 0, - parentNodeId: TRIGGER_STEP_ID, - xPos: FIRST_NODE_POSITION.x, - yPos: FIRST_NODE_POSITION.y, - }); + } return { nodes, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts index 1eca837e9..34b92df7c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts @@ -3,22 +3,16 @@ import { WorkflowStep, WorkflowTrigger, } from '@/workflow/types/Workflow'; -import { FIRST_NODE_POSITION } from '@/workflow/workflow-diagram/constants/FirstNodePosition'; -import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes'; -import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration'; import { WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeSuccessConfiguration'; import { WorkflowDiagramRunStatus, WorkflowRunDiagram, - WorkflowRunDiagramEdge, WorkflowRunDiagramNode, - WorkflowRunDiagramNodeData, WorkflowRunDiagramStepNodeData, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode'; -import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; import { isDefined } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; +import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; +import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode'; export const generateWorkflowRunDiagram = ({ trigger, @@ -44,124 +38,79 @@ export const generateWorkflowRunDiagram = ({ } | undefined = undefined; - const triggerBase = getWorkflowDiagramTriggerNode({ trigger }); + const workflowDiagram = generateWorkflowDiagram({ trigger, steps }); - const nodes: Array = [ - { - ...triggerBase, - data: { - ...triggerBase.data, - runStatus: 'success', - }, - }, - ]; - const edges: Array = []; + let skippedExecution = false; - const processNode = ({ - stepIndex, - parentNodeId, - parentRunStatus, - xPos, - yPos, - skippedExecution, - }: { - stepIndex: number; - parentNodeId: string; - parentRunStatus: WorkflowDiagramRunStatus; - xPos: number; - yPos: number; - skippedExecution: boolean; - }) => { - const step = steps.at(stepIndex); - if (!isDefined(step)) { - return; - } - - const nodeId = step.id; - - if (parentRunStatus === 'success') { - edges.push({ - ...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION, - id: v4(), - source: parentNodeId, - target: nodeId, - }); - } else { - edges.push({ - ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, - id: v4(), - source: parentNodeId, - target: nodeId, - }); - } - - const runResult = stepsOutput?.[nodeId]; - const isPendingFormAction = - step.type === 'FORM' && - isDefined(runResult?.pendingEvent) && - runResult.pendingEvent; - - let runStatus: WorkflowDiagramRunStatus; - if (skippedExecution) { - runStatus = 'not-executed'; - } else if (!isDefined(runResult) || isPendingFormAction) { - runStatus = 'running'; - } else { - if (isDefined(runResult.error)) { - runStatus = 'failure'; - } else { - runStatus = 'success'; + const workflowRunDiagramNodes: WorkflowRunDiagramNode[] = + workflowDiagram.nodes.filter(isStepNode).map((node) => { + if (node.data.nodeType === 'trigger') { + return { + ...node, + data: { + ...node.data, + runStatus: 'success', + }, + }; } - } - const nodeData: WorkflowRunDiagramNodeData = { - nodeType: 'action', - actionType: step.type, - name: step.name, - runStatus, - }; + const nodeId = node.id; - nodes.push({ - id: nodeId, - data: nodeData, - position: { - x: xPos, - y: yPos, - }, + const runResult = stepsOutput?.[nodeId]; + + const isPendingFormAction = + node.data.nodeType === 'action' && + node.data.actionType === 'FORM' && + isDefined(runResult?.pendingEvent) && + runResult.pendingEvent; + + let runStatus: WorkflowDiagramRunStatus = 'success'; + + if (skippedExecution) { + runStatus = 'not-executed'; + } else if (!isDefined(runResult) || isPendingFormAction) { + runStatus = 'running'; + } else if (isDefined(runResult.error)) { + runStatus = 'failure'; + } + + skippedExecution = + skippedExecution || runStatus === 'failure' || runStatus === 'running'; + + const nodeData = { ...node.data, runStatus }; + + if (isPendingFormAction) { + stepToOpenByDefault = { + id: nodeId, + data: nodeData, + }; + } + + return { + ...node, + data: nodeData, + }; }); - if (isPendingFormAction) { - stepToOpenByDefault = { - id: nodeId, - data: nodeData, + const workflowRunDiagramEdges = workflowDiagram.edges.map((edge) => { + const parentNode = workflowRunDiagramNodes.find( + (node) => node.id === edge.source, + ); + + if (isDefined(parentNode) && parentNode.data.runStatus === 'success') { + return { + ...edge, + ...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION, }; } - processNode({ - stepIndex: stepIndex + 1, - parentNodeId: nodeId, - parentRunStatus: runStatus, - xPos, - yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES, - skippedExecution: skippedExecution - ? true - : runStatus === 'failure' || runStatus === 'running', - }); - }; - - processNode({ - stepIndex: 0, - parentNodeId: TRIGGER_STEP_ID, - parentRunStatus: 'success', - xPos: FIRST_NODE_POSITION.x, - yPos: FIRST_NODE_POSITION.y, - skippedExecution: false, + return edge; }); return { diagram: { - nodes, - edges, + nodes: workflowRunDiagramNodes, + edges: workflowRunDiagramEdges, }, stepToOpenByDefault, }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isStepNode.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isStepNode.ts new file mode 100644 index 000000000..54f85f597 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isStepNode.ts @@ -0,0 +1,10 @@ +import { + WorkflowDiagramNode, + WorkflowDiagramStepNode, +} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; + +export const isStepNode = ( + node: WorkflowDiagramNode, +): node is WorkflowDiagramStepNode => { + return node.data.nodeType === 'trigger' || node.data.nodeType === 'action'; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx index c00e8c934..07a9c60b6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx @@ -1,5 +1,5 @@ import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; -import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; @@ -33,10 +33,10 @@ const wrapper = ({ children }: { children: React.ReactNode }) => { { set( - workflowCreateStepFromParentStepIdComponentState.atomFamily({ + workflowInsertStepIdsComponentState.atomFamily({ instanceId: workflowVisualizerComponentInstanceId, }), - 'parent-step-id', + { parentStepId: 'parent-step-id', nextStepId: undefined }, ); }} > diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts index d41459a1e..20b25dfab 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts @@ -8,7 +8,7 @@ import { } from '@/workflow/types/Workflow'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; -import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { isDefined } from 'twenty-shared/utils'; export const useCreateStep = ({ @@ -24,15 +24,17 @@ export const useCreateStep = ({ workflowLastCreatedStepIdComponentState, ); - const workflowCreateStepFromParentStepId = useRecoilComponentValueV2( - workflowCreateStepFromParentStepIdComponentState, + const workflowInsertStepIds = useRecoilComponentValueV2( + workflowInsertStepIdsComponentState, ); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); const createStep = async (newStepType: WorkflowStepType) => { - if (!isDefined(workflowCreateStepFromParentStepId)) { - throw new Error('Select a step to create a new step from first.'); + if (!isDefined(workflowInsertStepIds.parentStepId)) { + throw new Error( + 'No parentStepId. Please select a parent step to create from.', + ); } const workflowVersionId = await getUpdatableWorkflowVersion(workflow); @@ -41,8 +43,8 @@ export const useCreateStep = ({ await createWorkflowVersionStep({ workflowVersionId, stepType: newStepType, - parentStepId: workflowCreateStepFromParentStepId, - nextStepId: undefined, + parentStepId: workflowInsertStepIds.parentStepId, + nextStepId: workflowInsertStepIds.nextStepId, }) )?.data?.createWorkflowVersionStep; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts index 68f0abe08..db256ca18 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts @@ -37,8 +37,9 @@ export const useCreateWorkflowVersionStep = () => { variables: { input }, }); - const createdStep = result?.data?.createWorkflowVersionStep; - if (!isDefined(createdStep)) { + const insertedStep = result?.data?.createWorkflowVersionStep; + + if (!isDefined(insertedStep)) { return; } @@ -50,20 +51,29 @@ export const useCreateWorkflowVersionStep = () => { return; } + const { parentStepId, nextStepId } = input; + const updatedExistingSteps = - cachedRecord.steps?.map((step) => { - if (step.id === input.parentStepId) { + cachedRecord.steps?.map((existingStep) => { + if (existingStep.id === parentStepId) { return { - ...step, - nextStepIds: [...(step.nextStepIds || []), createdStep.id], + ...existingStep, + nextStepIds: [ + ...new Set([ + ...(existingStep.nextStepIds?.filter( + (id) => id !== nextStepId, + ) || []), + insertedStep.id, + ]), + ], }; } - return step; + return existingStep; }) ?? []; const newCachedRecord = { ...cachedRecord, - steps: [...updatedExistingSteps, createdStep], + steps: [...updatedExistingSteps, insertedStep], }; const recordGqlFields = { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts similarity index 50% rename from packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts index 98153b858..73f945375 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts @@ -1,9 +1,14 @@ import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; -export const workflowCreateStepFromParentStepIdComponentState = - createComponentStateV2({ - key: 'workflowCreateStepFromParentStepIdComponentState', - defaultValue: undefined, +type WorkflowInsertStepIdsState = { + parentStepId: string | undefined; + nextStepId: string | undefined; +}; + +export const workflowInsertStepIdsComponentState = + createComponentStateV2({ + key: 'workflowInsertStepIdsComponentState', + defaultValue: { parentStepId: undefined, nextStepId: undefined }, componentInstanceContext: WorkflowVisualizerComponentInstanceContext, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts index 60fd0d0db..cacd6d71b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts @@ -38,7 +38,8 @@ describe('insertStep', () => { insertedStep: newStep, }); - expect(result).toEqual([step1, step2, newStep]); + expect(result.updatedSteps).toEqual([step1, step2, newStep]); + expect(result.updatedInsertedStep).toEqual(newStep); }); it('should update parent step nextStepIds when inserting a step between two steps', () => { @@ -53,7 +54,7 @@ describe('insertStep', () => { nextStepId: '2', }); - expect(result).toEqual([ + expect(result.updatedSteps).toEqual([ { ...step1, nextStepIds: ['new'] }, step2, { ...newStep, nextStepIds: ['2'] }, @@ -71,7 +72,10 @@ describe('insertStep', () => { nextStepId: '1', }); - expect(result).toEqual([step1, { ...newStep, nextStepIds: ['1'] }]); + expect(result.updatedSteps).toEqual([ + step1, + { ...newStep, nextStepIds: ['1'] }, + ]); }); it('should handle inserting a step at the end of the workflow', () => { @@ -85,7 +89,10 @@ describe('insertStep', () => { nextStepId: undefined, }); - expect(result).toEqual([{ ...step1, nextStepIds: ['new'] }, newStep]); + expect(result.updatedSteps).toEqual([ + { ...step1, nextStepIds: ['new'] }, + newStep, + ]); }); it('should handle inserting a step between two steps with multiple nextStepIds', () => { @@ -101,7 +108,7 @@ describe('insertStep', () => { nextStepId: '2', }); - expect(result).toEqual([ + expect(result.updatedSteps).toEqual([ { ...step1, nextStepIds: ['3', 'new'] }, step2, step3, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts index 38ab226b7..20fee33e4 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts @@ -10,7 +10,7 @@ export const insertStep = ({ insertedStep: WorkflowAction; parentStepId?: string; nextStepId?: string; -}): WorkflowAction[] => { +}): { updatedSteps: WorkflowAction[]; updatedInsertedStep: WorkflowAction } => { const updatedExistingSteps = existingSteps.map((existingStep) => { if (existingStep.id === parentStepId) { return { @@ -28,11 +28,13 @@ export const insertStep = ({ return existingStep; }); - return [ - ...updatedExistingSteps, - { - ...insertedStep, - nextStepIds: nextStepId ? [nextStepId] : undefined, - }, - ]; + const updatedInsertedStep = { + ...insertedStep, + nextStepIds: nextStepId ? [nextStepId] : undefined, + }; + + return { + updatedSteps: [...updatedExistingSteps, updatedInsertedStep], + updatedInsertedStep, + }; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 6f36aba45..f53506893 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -96,7 +96,8 @@ export class WorkflowVersionStepWorkspaceService { assertWorkflowVersionIsDraft(workflowVersion); const existingSteps = workflowVersion.steps || []; - const updatedSteps = insertStep({ + + const { updatedSteps, updatedInsertedStep } = insertStep({ existingSteps, insertedStep: enrichedNewStep, parentStepId, @@ -107,7 +108,7 @@ export class WorkflowVersionStepWorkspaceService { steps: updatedSteps, }); - return enrichedNewStep; + return updatedInsertedStep; } async updateWorkflowVersionStep({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/exceptions/workflow-run.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/exceptions/workflow-run.exception.ts index 299e334a7..37ddde5b4 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/exceptions/workflow-run.exception.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/exceptions/workflow-run.exception.ts @@ -8,6 +8,7 @@ export class WorkflowRunException extends CustomException { export enum WorkflowRunExceptionCode { WORKFLOW_RUN_NOT_FOUND = 'WORKFLOW_RUN_NOT_FOUND', + WORKFLOW_ROOT_STEP_NOT_FOUND = 'WORKFLOW_ROOT_STEP_NOT_FOUND', INVALID_OPERATION = 'INVALID_OPERATION', INVALID_INPUT = 'INVALID_INPUT', WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED', diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts index fd60c96e8..de990d4fd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts @@ -14,6 +14,7 @@ import { WorkflowRunExceptionCode, } from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; +import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/getRootSteps.utils'; export type RunWorkflowJobData = { workspaceId: string; @@ -114,9 +115,11 @@ export class RunWorkflowJob { await this.throttleExecution(workflowVersion.workflowId); + const rootSteps = getRootSteps(workflowVersion.steps); + await this.executeWorkflow({ workflowRunId, - currentStepId: workflowVersion.steps[0].id, + currentStepId: rootSteps[0].id, steps: workflowVersion.steps, context, workspaceId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/getRootSteps.utils.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/getRootSteps.utils.spec.ts new file mode 100644 index 000000000..b08c1b7de --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/getRootSteps.utils.spec.ts @@ -0,0 +1,85 @@ +import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/getRootSteps.utils'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +describe('getRootSteps', () => { + it('should return the root steps', () => { + const steps = [ + { + id: 'step1', + nextStepIds: ['step2'], + }, + { id: 'step2', nextStepIds: undefined }, + ] as WorkflowAction[]; + + const expectedRootSteps = [ + { + id: 'step1', + nextStepIds: ['step2'], + }, + ] as WorkflowAction[]; + + expect(getRootSteps(steps)).toEqual(expectedRootSteps); + }); + + it('should not consider step order', () => { + const steps = [ + { id: 'step2', nextStepIds: undefined }, + { + id: 'step1', + nextStepIds: ['step2'], + }, + ] as WorkflowAction[]; + + const expectedRootSteps = [ + { + id: 'step1', + nextStepIds: ['step2'], + }, + ] as WorkflowAction[]; + + expect(getRootSteps(steps)).toEqual(expectedRootSteps); + }); + + it('should handle multiple root steps', () => { + const steps = [ + { + id: 'step1', + nextStepIds: ['step3'], + }, + { + id: 'step2', + nextStepIds: ['step3'], + }, + { id: 'step3', nextStepIds: ['step4'] }, + { id: 'step4', nextStepIds: undefined }, + ] as WorkflowAction[]; + + const expectedRootSteps = [ + { + id: 'step1', + nextStepIds: ['step3'], + }, + { + id: 'step2', + nextStepIds: ['step3'], + }, + ] as WorkflowAction[]; + + expect(getRootSteps(steps)).toEqual(expectedRootSteps); + }); + + it('should throw if buggy steps provided', () => { + const steps = [ + { + id: 'step1', + nextStepIds: ['step2'], + }, + { + id: 'step2', + nextStepIds: ['step1'], + }, + ] as WorkflowAction[]; + + expect(() => getRootSteps(steps)).toThrow('No root step found'); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/getRootSteps.utils.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/getRootSteps.utils.ts new file mode 100644 index 000000000..c129b4021 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/getRootSteps.utils.ts @@ -0,0 +1,24 @@ +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + WorkflowRunException, + WorkflowRunExceptionCode, +} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception'; + +export const getRootSteps = (steps: WorkflowAction[]): WorkflowAction[] => { + const childIds = new Set(); + + for (const step of steps) { + step.nextStepIds?.forEach((id) => childIds.add(id)); + } + + const rootSteps = steps.filter((step) => !childIds.has(step.id)); + + if (rootSteps.length === 0) { + throw new WorkflowRunException( + 'No root step found', + WorkflowRunExceptionCode.WORKFLOW_ROOT_STEP_NOT_FOUND, + ); + } + + return rootSteps; +}; diff --git a/packages/twenty-ui/src/input/button/components/IconButtonGroup.tsx b/packages/twenty-ui/src/input/button/components/IconButtonGroup.tsx index 2b8f0e758..e0d14b503 100644 --- a/packages/twenty-ui/src/input/button/components/IconButtonGroup.tsx +++ b/packages/twenty-ui/src/input/button/components/IconButtonGroup.tsx @@ -2,17 +2,10 @@ import styled from '@emotion/styled'; import { IconComponent } from '@ui/display'; import { MouseEvent } from 'react'; -import { IconButton, IconButtonPosition, IconButtonProps } from './IconButton'; +import { InsideButton } from '@ui/input/button/components/InsideButton'; -const StyledIconButtonGroupContainer = styled.div` - border-radius: ${({ theme }) => theme.border.radius.md}; - display: flex; -`; - -export type IconButtonGroupProps = Pick< - IconButtonProps, - 'accent' | 'size' | 'variant' -> & { +export type IconButtonGroupProps = { + disabled?: boolean; iconButtons: { Icon: IconComponent; onClick?: (event: MouseEvent) => void; @@ -20,31 +13,36 @@ export type IconButtonGroupProps = Pick< className?: string; }; +const StyledIconButtonGroupContainer = styled.div< + Pick +>` + display: inline-flex; + align-items: flex-start; + background-color: ${({ disabled, theme }) => + disabled ? 'inherit' : theme.background.transparent.lighter}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + gap: 2px; + padding: 2px; + backdrop-filter: blur(20px); + + &:hover { + box-shadow: ${({ theme }) => theme.boxShadow.light}; + } +`; + export const IconButtonGroup = ({ - accent, iconButtons, - size, - variant, + disabled, className, }: IconButtonGroupProps) => ( - + {iconButtons.map(({ Icon, onClick }, index) => { - const position: IconButtonPosition = - index === 0 - ? 'left' - : index === iconButtons.length - 1 - ? 'right' - : 'middle'; - return ( - ); })} diff --git a/packages/twenty-ui/src/input/button/components/InsideButton.tsx b/packages/twenty-ui/src/input/button/components/InsideButton.tsx new file mode 100644 index 000000000..742a00d10 --- /dev/null +++ b/packages/twenty-ui/src/input/button/components/InsideButton.tsx @@ -0,0 +1,47 @@ +import { IconComponent } from '@ui/display'; +import styled from '@emotion/styled'; +import React from 'react'; +import { useTheme } from '@emotion/react'; + +export type InsideButtonProps = { + className?: string; + Icon?: IconComponent; + onClick?: (event: React.MouseEvent) => void; + disabled?: boolean; +}; + +const StyledButton = styled.button` + align-items: center; + border: none; + background-color: transparent; + border-radius: ${({ theme }) => theme.border.radius.xs}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; + display: flex; + flex-direction: row; + height: 20px; + justify-content: center; + padding: 0; + white-space: nowrap; + min-width: 20px; + transition: background-color 0.1s ease; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.light}; + } +`; + +export const InsideButton = ({ + className, + Icon, + onClick, + disabled = false, +}: InsideButtonProps) => { + const theme = useTheme(); + + return ( + + {Icon && } + + ); +}; diff --git a/packages/twenty-ui/src/input/button/components/__stories__/IconButtonGroup.stories.tsx b/packages/twenty-ui/src/input/button/components/__stories__/IconButtonGroup.stories.tsx index a9a431b4d..546375de9 100644 --- a/packages/twenty-ui/src/input/button/components/__stories__/IconButtonGroup.stories.tsx +++ b/packages/twenty-ui/src/input/button/components/__stories__/IconButtonGroup.stories.tsx @@ -5,11 +5,7 @@ import { CatalogStory, ComponentDecorator, } from '@ui/testing'; -import { - IconButtonAccent, - IconButtonSize, - IconButtonVariant, -} from '../IconButton'; + import { IconButtonGroup } from '../IconButtonGroup'; const meta: Meta = { @@ -32,40 +28,22 @@ type Story = StoryObj; export const Default: Story = { args: { - size: 'small', - variant: 'primary', - accent: 'danger', + disabled: false, }, decorators: [ComponentDecorator], }; export const Catalog: CatalogStory = { argTypes: { - size: { control: false }, - variant: { control: false }, - accent: { control: false }, + disabled: { control: false }, }, parameters: { catalog: { dimensions: [ { - name: 'sizes', - values: ['small', 'medium'] satisfies IconButtonSize[], - props: (size: IconButtonSize) => ({ size }), - }, - { - name: 'accents', - values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[], - props: (accent: IconButtonAccent) => ({ accent }), - }, - { - name: 'variants', - values: [ - 'primary', - 'secondary', - 'tertiary', - ] satisfies IconButtonVariant[], - props: (variant: IconButtonVariant) => ({ variant }), + name: 'disabled', + values: [true, false], + props: (disabled: boolean) => ({ disabled }), }, ], }, diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts index f0aeb1e98..64d916e26 100644 --- a/packages/twenty-ui/src/input/index.ts +++ b/packages/twenty-ui/src/input/index.ts @@ -49,6 +49,8 @@ export type { export { IconButton } from './button/components/IconButton'; export type { IconButtonGroupProps } from './button/components/IconButtonGroup'; export { IconButtonGroup } from './button/components/IconButtonGroup'; +export type { InsideButtonProps } from './button/components/InsideButton'; +export { InsideButton } from './button/components/InsideButton'; export type { LightButtonAccent, LightButtonProps,