Skip to content

Commit 3536109

Browse files
authored
Delete workflow step (#7373)
- Allows the deletion of triggers and steps in workflows. If the workflow can not be edited right now, we create a new draft version. - The workflow right drawer can now render nothing. It's necessary to behave that way because a deleted step will still be displayed for a short amount of time in the drawer. The drawer will be filled with blank content when it disappears. https://github.com/user-attachments/assets/abd5184e-d3db-4fe7-8870-ccc78ff23d41 Closes #7057
1 parent 3a0c32a commit 3536109

File tree

8 files changed

+308
-31
lines changed

8 files changed

+308
-31
lines changed

packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkf
77
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
88
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
99
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
10-
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
10+
import { findStepPosition } from '@/workflow/utils/findStepPosition';
1111
import { useRecoilValue } from 'recoil';
1212
import { isDefined } from 'twenty-ui';
1313

@@ -43,10 +43,13 @@ const getStepDefinitionOrThrow = ({
4343
);
4444
}
4545

46-
const selectedNodePosition = findStepPositionOrThrow({
46+
const selectedNodePosition = findStepPosition({
4747
steps: currentVersion.steps,
4848
stepId: stepId,
4949
});
50+
if (!isDefined(selectedNodePosition)) {
51+
return undefined;
52+
}
5053

5154
return {
5255
type: 'action',
@@ -76,6 +79,9 @@ export const RightDrawerWorkflowEditStepContent = ({
7679
stepId: workflowSelectedNode,
7780
workflow,
7881
});
82+
if (!isDefined(stepDefinition)) {
83+
return null;
84+
}
7985

8086
switch (stepDefinition.type) {
8187
case 'trigger': {

packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
22
import styled from '@emotion/styled';
33
import { Handle, Position } from '@xyflow/react';
44
import React from 'react';
5+
import { isDefined } from 'twenty-ui';
56
import { capitalize } from '~/utils/string/capitalize';
67

78
type Variant = 'placeholder';
@@ -76,16 +77,24 @@ export const StyledTargetHandle = styled(Handle)`
7677
visibility: hidden;
7778
`;
7879

80+
const StyledRightFloatingElementContainer = styled.div`
81+
position: absolute;
82+
transform: translateX(100%);
83+
right: ${({ theme }) => theme.spacing(-2)};
84+
`;
85+
7986
export const WorkflowDiagramBaseStepNode = ({
8087
nodeType,
8188
label,
8289
variant,
8390
Icon,
91+
RightFloatingElement,
8492
}: {
8593
nodeType: WorkflowDiagramStepNodeData['nodeType'];
8694
label: string;
8795
variant?: Variant;
8896
Icon?: React.ReactNode;
97+
RightFloatingElement?: React.ReactNode;
8998
}) => {
9099
return (
91100
<StyledStepNodeContainer>
@@ -101,6 +110,12 @@ export const WorkflowDiagramBaseStepNode = ({
101110

102111
{label}
103112
</StyledStepNodeLabel>
113+
114+
{isDefined(RightFloatingElement) ? (
115+
<StyledRightFloatingElementContainer>
116+
{RightFloatingElement}
117+
</StyledRightFloatingElementContainer>
118+
) : null}
104119
</StyledStepNodeInnerContainer>
105120

106121
<StyledSourceHandle type="source" position={Position.Bottom} />

packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx

+31-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
12
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
3+
import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep';
4+
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
5+
import { workflowIdState } from '@/workflow/states/workflowIdState';
26
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
37
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
8+
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
49
import { useTheme } from '@emotion/react';
510
import styled from '@emotion/styled';
6-
import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
11+
import { useRecoilValue } from 'recoil';
12+
import { IconCode, IconMail, IconPlaylistAdd, IconTrash } from 'twenty-ui';
713

814
const StyledStepNodeLabelIconContainer = styled.div`
915
align-items: center;
@@ -15,12 +21,26 @@ const StyledStepNodeLabelIconContainer = styled.div`
1521
`;
1622

1723
export const WorkflowDiagramStepNode = ({
24+
id,
1825
data,
26+
selected,
1927
}: {
28+
id: string;
2029
data: WorkflowDiagramStepNodeData;
30+
selected?: boolean;
2131
}) => {
2232
const theme = useTheme();
2333

34+
const workflowId = useRecoilValue(workflowIdState);
35+
36+
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
37+
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
38+
39+
const { deleteOneStep } = useDeleteOneStep({
40+
workflow: workflowWithCurrentVersion,
41+
stepId: id,
42+
});
43+
2444
const renderStepIcon = () => {
2545
switch (data.nodeType) {
2646
case 'trigger': {
@@ -67,6 +87,16 @@ export const WorkflowDiagramStepNode = ({
6787
nodeType={data.nodeType}
6888
label={data.label}
6989
Icon={renderStepIcon()}
90+
RightFloatingElement={
91+
selected ? (
92+
<FloatingIconButton
93+
Icon={IconTrash}
94+
onClick={() => {
95+
return deleteOneStep();
96+
}}
97+
/>
98+
) : undefined
99+
}
70100
/>
71101
);
72102
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
2+
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
3+
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
4+
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
5+
import {
6+
WorkflowVersion,
7+
WorkflowWithCurrentVersion,
8+
} from '@/workflow/types/Workflow';
9+
import { removeStep } from '@/workflow/utils/removeStep';
10+
11+
export const useDeleteOneStep = ({
12+
stepId,
13+
workflow,
14+
}: {
15+
stepId: string;
16+
workflow: WorkflowWithCurrentVersion;
17+
}) => {
18+
const { updateOneRecord: updateOneWorkflowVersion } =
19+
useUpdateOneRecord<WorkflowVersion>({
20+
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
21+
});
22+
23+
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({
24+
workflowId: workflow.id,
25+
});
26+
27+
const deleteOneStep = async () => {
28+
if (workflow.currentVersion.status !== 'DRAFT') {
29+
const newVersionName = `v${workflow.versions.length + 1}`;
30+
31+
if (stepId === TRIGGER_STEP_ID) {
32+
await createNewWorkflowVersion({
33+
name: newVersionName,
34+
status: 'DRAFT',
35+
trigger: null,
36+
steps: workflow.currentVersion.steps,
37+
});
38+
} else {
39+
await createNewWorkflowVersion({
40+
name: newVersionName,
41+
status: 'DRAFT',
42+
trigger: workflow.currentVersion.trigger,
43+
steps: removeStep({
44+
steps: workflow.currentVersion.steps ?? [],
45+
stepId,
46+
}),
47+
});
48+
}
49+
50+
return;
51+
}
52+
53+
if (stepId === TRIGGER_STEP_ID) {
54+
await updateOneWorkflowVersion({
55+
idToUpdate: workflow.currentVersion.id,
56+
updateOneRecordInput: {
57+
trigger: null,
58+
},
59+
});
60+
} else {
61+
await updateOneWorkflowVersion({
62+
idToUpdate: workflow.currentVersion.id,
63+
updateOneRecordInput: {
64+
steps: removeStep({
65+
steps: workflow.currentVersion.steps ?? [],
66+
stepId,
67+
}),
68+
},
69+
});
70+
}
71+
};
72+
73+
return {
74+
deleteOneStep,
75+
};
76+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow';
2+
import { removeStep } from '../removeStep';
3+
4+
it('returns a deep copy of the provided steps array instead of mutating it', () => {
5+
const stepToBeRemoved = {
6+
id: 'step-1',
7+
name: '',
8+
settings: {
9+
errorHandlingOptions: {
10+
retryOnFailure: { value: true },
11+
continueOnFailure: { value: false },
12+
},
13+
serverlessFunctionId: 'first',
14+
},
15+
type: 'CODE',
16+
valid: true,
17+
} satisfies WorkflowStep;
18+
const workflowVersionInitial = {
19+
__typename: 'WorkflowVersion',
20+
status: 'ACTIVE',
21+
createdAt: '',
22+
id: '1',
23+
name: '',
24+
steps: [stepToBeRemoved],
25+
trigger: {
26+
settings: { eventName: 'company.created' },
27+
type: 'DATABASE_EVENT',
28+
},
29+
updatedAt: '',
30+
workflowId: '',
31+
} satisfies WorkflowVersion;
32+
33+
const stepsUpdated = removeStep({
34+
steps: workflowVersionInitial.steps,
35+
stepId: stepToBeRemoved.id,
36+
});
37+
38+
expect(workflowVersionInitial.steps).not.toBe(stepsUpdated);
39+
});
40+
41+
it('removes a step in a non-empty steps array', () => {
42+
const stepToBeRemoved: WorkflowStep = {
43+
id: 'step-2',
44+
name: '',
45+
settings: {
46+
errorHandlingOptions: {
47+
retryOnFailure: { value: true },
48+
continueOnFailure: { value: false },
49+
},
50+
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
51+
},
52+
type: 'CODE',
53+
valid: true,
54+
};
55+
const workflowVersionInitial = {
56+
__typename: 'WorkflowVersion',
57+
status: 'ACTIVE',
58+
createdAt: '',
59+
id: '1',
60+
name: '',
61+
steps: [
62+
{
63+
id: 'step-1',
64+
name: '',
65+
settings: {
66+
errorHandlingOptions: {
67+
retryOnFailure: { value: true },
68+
continueOnFailure: { value: false },
69+
},
70+
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
71+
},
72+
type: 'CODE',
73+
valid: true,
74+
},
75+
stepToBeRemoved,
76+
{
77+
id: 'step-3',
78+
name: '',
79+
settings: {
80+
errorHandlingOptions: {
81+
retryOnFailure: { value: true },
82+
continueOnFailure: { value: false },
83+
},
84+
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
85+
},
86+
type: 'CODE',
87+
valid: true,
88+
},
89+
],
90+
trigger: {
91+
settings: { eventName: 'company.created' },
92+
type: 'DATABASE_EVENT',
93+
},
94+
updatedAt: '',
95+
workflowId: '',
96+
} satisfies WorkflowVersion;
97+
98+
const stepsUpdated = removeStep({
99+
steps: workflowVersionInitial.steps,
100+
stepId: stepToBeRemoved.id,
101+
});
102+
103+
const expectedUpdatedSteps: Array<WorkflowStep> = [
104+
workflowVersionInitial.steps[0],
105+
workflowVersionInitial.steps[2],
106+
];
107+
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
108+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
2+
import { WorkflowStep } from '@/workflow/types/Workflow';
3+
import { isDefined } from 'twenty-ui';
4+
5+
/**
6+
* This function returns the reference of the array where the step should be positioned
7+
* and at which index.
8+
*/
9+
export const findStepPosition = ({
10+
steps,
11+
stepId,
12+
}: {
13+
steps: Array<WorkflowStep>;
14+
stepId: string | undefined;
15+
}): { steps: Array<WorkflowStep>; index: number } | undefined => {
16+
if (!isDefined(stepId) || stepId === TRIGGER_STEP_ID) {
17+
return {
18+
steps,
19+
index: 0,
20+
};
21+
}
22+
23+
for (const [index, step] of steps.entries()) {
24+
if (step.id === stepId) {
25+
return {
26+
steps,
27+
index,
28+
};
29+
}
30+
31+
// TODO: When condition will have been implemented, put recursivity here.
32+
// if (step.type === "CONDITION") {
33+
// return findNodePosition({
34+
// workflowSteps: step.conditions,
35+
// stepId,
36+
// })
37+
// }
38+
}
39+
40+
return undefined;
41+
};

0 commit comments

Comments
 (0)