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,