diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx index 3f3734b07..a7b190bec 100644 --- a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx @@ -7,7 +7,7 @@ import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkf import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState'; import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; -import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow'; +import { findStepPosition } from '@/workflow/utils/findStepPosition'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; @@ -43,10 +43,13 @@ const getStepDefinitionOrThrow = ({ ); } - const selectedNodePosition = findStepPositionOrThrow({ + const selectedNodePosition = findStepPosition({ steps: currentVersion.steps, stepId: stepId, }); + if (!isDefined(selectedNodePosition)) { + return undefined; + } return { type: 'action', @@ -76,6 +79,9 @@ export const RightDrawerWorkflowEditStepContent = ({ stepId: workflowSelectedNode, workflow, }); + if (!isDefined(stepDefinition)) { + return null; + } switch (stepDefinition.type) { case 'trigger': { diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx index 8c05d48ba..8484a29e7 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx @@ -2,6 +2,7 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; import styled from '@emotion/styled'; import { Handle, Position } from '@xyflow/react'; import React from 'react'; +import { isDefined } from 'twenty-ui'; import { capitalize } from '~/utils/string/capitalize'; type Variant = 'placeholder'; @@ -76,16 +77,24 @@ export const StyledTargetHandle = styled(Handle)` visibility: hidden; `; +const StyledRightFloatingElementContainer = styled.div` + position: absolute; + transform: translateX(100%); + right: ${({ theme }) => theme.spacing(-2)}; +`; + export const WorkflowDiagramBaseStepNode = ({ nodeType, label, variant, Icon, + RightFloatingElement, }: { nodeType: WorkflowDiagramStepNodeData['nodeType']; label: string; variant?: Variant; Icon?: React.ReactNode; + RightFloatingElement?: React.ReactNode; }) => { return ( @@ -101,6 +110,12 @@ export const WorkflowDiagramBaseStepNode = ({ {label} + + {isDefined(RightFloatingElement) ? ( + + {RightFloatingElement} + + ) : null} diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx index cc5f1a94e..00b20bdae 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx @@ -1,9 +1,15 @@ +import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton'; import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode'; +import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; +import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui'; +import { useRecoilValue } from 'recoil'; +import { IconCode, IconMail, IconPlaylistAdd, IconTrash } from 'twenty-ui'; const StyledStepNodeLabelIconContainer = styled.div` align-items: center; @@ -15,12 +21,26 @@ const StyledStepNodeLabelIconContainer = styled.div` `; export const WorkflowDiagramStepNode = ({ + id, data, + selected, }: { + id: string; data: WorkflowDiagramStepNodeData; + selected?: boolean; }) => { const theme = useTheme(); + const workflowId = useRecoilValue(workflowIdState); + + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId); + assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion); + + const { deleteOneStep } = useDeleteOneStep({ + workflow: workflowWithCurrentVersion, + stepId: id, + }); + const renderStepIcon = () => { switch (data.nodeType) { case 'trigger': { @@ -67,6 +87,16 @@ export const WorkflowDiagramStepNode = ({ nodeType={data.nodeType} label={data.label} Icon={renderStepIcon()} + RightFloatingElement={ + selected ? ( + { + return deleteOneStep(); + }} + /> + ) : undefined + } /> ); }; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneStep.tsx b/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneStep.tsx new file mode 100644 index 000000000..0a44ccbc2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneStep.tsx @@ -0,0 +1,76 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion'; +import { + WorkflowVersion, + WorkflowWithCurrentVersion, +} from '@/workflow/types/Workflow'; +import { removeStep } from '@/workflow/utils/removeStep'; + +export const useDeleteOneStep = ({ + stepId, + workflow, +}: { + stepId: string; + workflow: WorkflowWithCurrentVersion; +}) => { + const { updateOneRecord: updateOneWorkflowVersion } = + useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({ + workflowId: workflow.id, + }); + + const deleteOneStep = async () => { + if (workflow.currentVersion.status !== 'DRAFT') { + const newVersionName = `v${workflow.versions.length + 1}`; + + if (stepId === TRIGGER_STEP_ID) { + await createNewWorkflowVersion({ + name: newVersionName, + status: 'DRAFT', + trigger: null, + steps: workflow.currentVersion.steps, + }); + } else { + await createNewWorkflowVersion({ + name: newVersionName, + status: 'DRAFT', + trigger: workflow.currentVersion.trigger, + steps: removeStep({ + steps: workflow.currentVersion.steps ?? [], + stepId, + }), + }); + } + + return; + } + + if (stepId === TRIGGER_STEP_ID) { + await updateOneWorkflowVersion({ + idToUpdate: workflow.currentVersion.id, + updateOneRecordInput: { + trigger: null, + }, + }); + } else { + await updateOneWorkflowVersion({ + idToUpdate: workflow.currentVersion.id, + updateOneRecordInput: { + steps: removeStep({ + steps: workflow.currentVersion.steps ?? [], + stepId, + }), + }, + }); + } + }; + + return { + deleteOneStep, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts new file mode 100644 index 000000000..01385411b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/removeStep.test.ts @@ -0,0 +1,108 @@ +import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow'; +import { removeStep } from '../removeStep'; + +it('returns a deep copy of the provided steps array instead of mutating it', () => { + const stepToBeRemoved = { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'first', + }, + type: 'CODE', + valid: true, + } satisfies WorkflowStep; + const workflowVersionInitial = { + __typename: 'WorkflowVersion', + status: 'ACTIVE', + createdAt: '', + id: '1', + name: '', + steps: [stepToBeRemoved], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + } satisfies WorkflowVersion; + + const stepsUpdated = removeStep({ + steps: workflowVersionInitial.steps, + stepId: stepToBeRemoved.id, + }); + + expect(workflowVersionInitial.steps).not.toBe(stepsUpdated); +}); + +it('removes a step in a non-empty steps array', () => { + const stepToBeRemoved: WorkflowStep = { + id: 'step-2', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE', + valid: true, + }; + const workflowVersionInitial = { + __typename: 'WorkflowVersion', + status: 'ACTIVE', + createdAt: '', + id: '1', + name: '', + steps: [ + { + id: 'step-1', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE', + valid: true, + }, + stepToBeRemoved, + { + id: 'step-3', + name: '', + settings: { + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: false }, + }, + serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', + }, + type: 'CODE', + valid: true, + }, + ], + trigger: { + settings: { eventName: 'company.created' }, + type: 'DATABASE_EVENT', + }, + updatedAt: '', + workflowId: '', + } satisfies WorkflowVersion; + + const stepsUpdated = removeStep({ + steps: workflowVersionInitial.steps, + stepId: stepToBeRemoved.id, + }); + + const expectedUpdatedSteps: Array = [ + workflowVersionInitial.steps[0], + workflowVersionInitial.steps[2], + ]; + expect(stepsUpdated).toEqual(expectedUpdatedSteps); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/findStepPosition.ts b/packages/twenty-front/src/modules/workflow/utils/findStepPosition.ts new file mode 100644 index 000000000..3ae23dff0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/findStepPosition.ts @@ -0,0 +1,41 @@ +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { isDefined } from 'twenty-ui'; + +/** + * This function returns the reference of the array where the step should be positioned + * and at which index. + */ +export const findStepPosition = ({ + steps, + stepId, +}: { + steps: Array; + stepId: string | undefined; +}): { steps: Array; index: number } | undefined => { + if (!isDefined(stepId) || stepId === TRIGGER_STEP_ID) { + return { + steps, + index: 0, + }; + } + + for (const [index, step] of steps.entries()) { + if (step.id === stepId) { + return { + steps, + index, + }; + } + + // TODO: When condition will have been implemented, put recursivity here. + // if (step.type === "CONDITION") { + // return findNodePosition({ + // workflowSteps: step.conditions, + // stepId, + // }) + // } + } + + return undefined; +}; diff --git a/packages/twenty-front/src/modules/workflow/utils/findStepPositionOrThrow.ts b/packages/twenty-front/src/modules/workflow/utils/findStepPositionOrThrow.ts index bf10df778..c20ffbcd4 100644 --- a/packages/twenty-front/src/modules/workflow/utils/findStepPositionOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/utils/findStepPositionOrThrow.ts @@ -1,41 +1,21 @@ -import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; import { WorkflowStep } from '@/workflow/types/Workflow'; +import { findStepPosition } from '@/workflow/utils/findStepPosition'; import { isDefined } from 'twenty-ui'; /** * This function returns the reference of the array where the step should be positioned * and at which index. */ -export const findStepPositionOrThrow = ({ - steps, - stepId, -}: { +export const findStepPositionOrThrow = (props: { steps: Array; stepId: string | undefined; }): { steps: Array; index: number } => { - if (!isDefined(stepId) || stepId === TRIGGER_STEP_ID) { - return { - steps, - index: 0, - }; + const result = findStepPosition(props); + if (!isDefined(result)) { + throw new Error( + `Couldn't locate the step. Unreachable step id: ${props.stepId}.`, + ); } - for (const [index, step] of steps.entries()) { - if (step.id === stepId) { - return { - steps, - index, - }; - } - - // TODO: When condition will have been implemented, put recursivity here. - // if (step.type === "CONDITION") { - // return findNodePosition({ - // workflowSteps: step.conditions, - // stepId, - // }) - // } - } - - throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`); + return result; }; diff --git a/packages/twenty-front/src/modules/workflow/utils/removeStep.ts b/packages/twenty-front/src/modules/workflow/utils/removeStep.ts new file mode 100644 index 000000000..eb6fe41dd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/removeStep.ts @@ -0,0 +1,21 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow'; + +export const removeStep = ({ + steps: stepsInitial, + stepId, +}: { + steps: Array; + stepId: string | undefined; +}) => { + const steps = structuredClone(stepsInitial); + + const parentStepPosition = findStepPositionOrThrow({ + steps, + stepId, + }); + + parentStepPosition.steps.splice(parentStepPosition.index, 1); + + return steps; +};