Delete filter on branch deletion (#13834)

When the target is not in the direct next step ids of the source (step
or trigger), check if there is a filter between both. If yes, delete the
filter and remove it from source next step ids.

Also refactored the existing.
This commit is contained in:
Thomas Trompette
2025-08-12 12:30:08 +02:00
committed by GitHub
parent d4b5ed93a3
commit 954da6da9d
29 changed files with 605 additions and 153 deletions
@@ -1,5 +1,5 @@
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { type WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const getIsInputTabDisabled = ({
stepExecutionStatus,
@@ -10,10 +10,10 @@ import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/componen
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { OTHER_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/OtherTriggerTypes';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated/graphql';
@@ -2,7 +2,6 @@ import { stepsOutputSchemaFamilyState } from '@/workflow/states/stepsOutputSchem
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import {
@@ -11,6 +10,7 @@ import {
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const useStepsOutputSchema = () => {
const populateStepsOutputSchema = useRecoilCallback(
@@ -1,5 +1,5 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { findStepPosition } from '../findStepPosition';
describe('findStepPosition', () => {
@@ -2,7 +2,7 @@ import {
type WorkflowAction,
type WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { getStepDefinitionOrThrow } from '../getStepDefinitionOrThrow';
describe('getStepDefinitionOrThrow', () => {
@@ -1,6 +1,6 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
/**
* This function returns the reference of the array where the step should be positioned
@@ -3,8 +3,8 @@ import {
type WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { findStepPosition } from '@/workflow/utils/findStepPosition';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const getStepDefinitionOrThrow = ({
stepId,
@@ -7,12 +7,12 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
const StyledStepNodeLabelIconContainer = styled.div`
@@ -1,6 +1,6 @@
import { type WorkflowDiagramEmptyTriggerNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { type Node } from '@xyflow/react';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION = {
id: TRIGGER_STEP_ID,
@@ -15,9 +15,9 @@ import {
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { v4 } from 'uuid';
export const generateWorkflowDiagram = ({
@@ -2,10 +2,10 @@ import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { type Node } from '@xyflow/react';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const getWorkflowDiagramTriggerNode = ({
trigger,
@@ -1,5 +1,5 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { getWorkflowPreviousStepId } from '../getWorkflowPreviousStepId';
describe('getWorkflowPreviousStepId', () => {
@@ -1,7 +1,6 @@
import { type WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { StepStatus, TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { getWorkflowRunStepContext } from '../getWorkflowRunStepContext';
import { StepStatus } from 'twenty-shared/workflow';
describe('getWorkflowRunStepContext', () => {
it('should return an empty array for trigger step', () => {
@@ -1,5 +1,5 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const getWorkflowPreviousStepId = ({
stepId,
@@ -1,8 +1,8 @@
import { type WorkflowRunFlow } from '@/workflow/types/Workflow';
import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import {
getWorkflowRunContext,
TRIGGER_STEP_ID,
type WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
@@ -1 +0,0 @@
export const TRIGGER_STEP_ID = 'trigger';
@@ -4,7 +4,6 @@ import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/ste
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import {
type OutputSchema,
type StepOutputSchema,
@@ -12,6 +11,7 @@ import {
import { filterOutputSchema } from '@/workflow/workflow-variables/utils/filterOutputSchema';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { isEmptyObject } from '~/utils/isEmptyObject';
export const useAvailableVariablesInWorkflowStep = ({
@@ -1,14 +1,17 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
type MockWorkspaceRepository = Partial<
WorkspaceRepository<WorkflowVersionWorkspaceEntity>
@@ -71,6 +74,7 @@ const mockWorkflowVersion = {
describe('WorkflowVersionEdgeWorkspaceService', () => {
let twentyORMGlobalManager: jest.Mocked<TwentyORMGlobalManager>;
let workflowCommonWorkspaceService: jest.Mocked<WorkflowCommonWorkspaceService>;
let service: WorkflowVersionEdgeWorkspaceService;
let mockWorkflowVersionWorkspaceRepository: MockWorkspaceRepository;
@@ -90,6 +94,12 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
.mockResolvedValue(mockWorkflowVersionWorkspaceRepository),
} as unknown as jest.Mocked<TwentyORMGlobalManager>;
workflowCommonWorkspaceService = {
getWorkflowVersionOrFail: jest
.fn()
.mockResolvedValue(mockWorkflowVersion),
} as unknown as jest.Mocked<WorkflowCommonWorkspaceService>;
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkflowVersionEdgeWorkspaceService,
@@ -97,6 +107,10 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
provide: TwentyORMGlobalManager,
useValue: twentyORMGlobalManager,
},
{
provide: WorkflowCommonWorkspaceService,
useValue: workflowCommonWorkspaceService,
},
],
}).compile();
@@ -107,7 +121,7 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
it('should throw if target does not exists', async () => {
const call = async () =>
await service.createWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'not-existing-step',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
@@ -121,12 +135,19 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
describe('with source is the trigger', () => {
it('should create an edge between trigger and step-1', async () => {
const result = await service.createWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'step-3',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
@@ -148,7 +169,7 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
it('should not duplicate stepIds if edge already exists', async () => {
const result = await service.createWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'step-1',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
@@ -245,7 +266,7 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
it('should throw if target does not exists', async () => {
const call = async () =>
await service.deleteWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'not-existing-step',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
@@ -259,12 +280,19 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
describe('with source is the trigger', () => {
it('should delete an edge between trigger and step-1', async () => {
const result = await service.deleteWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'step-1',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
@@ -286,12 +314,19 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
it('should not delete if edge does not exists', async () => {
const result = await service.deleteWorkflowVersionEdge({
source: 'trigger',
source: TRIGGER_STEP_ID,
target: 'step-2',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).not.toHaveBeenCalled();
@@ -377,5 +412,195 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
);
});
});
describe('with filter steps', () => {
it('should delete the filter step when deleting edge from trigger to target through filter', async () => {
const mockStepsWithFilter = [
{
id: 'step-1',
type: WorkflowActionType.FORM,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'filter-step',
type: WorkflowActionType.FILTER,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2'],
},
] as WorkflowAction[];
const mockTriggerWithFilter = {
type: WorkflowTriggerType.MANUAL,
settings: {},
nextStepIds: ['step-1', 'filter-step'],
};
const mockWorkflowVersionWithFilter = {
id: mockWorkflowVersionId,
trigger: mockTriggerWithFilter,
steps: mockStepsWithFilter,
status: 'DRAFT',
} as WorkflowVersionWorkspaceEntity;
workflowCommonWorkspaceService.getWorkflowVersionOrFail.mockResolvedValue(
mockWorkflowVersionWithFilter,
);
const result = await service.deleteWorkflowVersionEdge({
source: TRIGGER_STEP_ID,
target: 'step-2',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
trigger: {
...mockTriggerWithFilter,
nextStepIds: ['step-1'],
},
steps: mockStepsWithFilter.filter(
(step) => step.id !== 'filter-step',
),
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
},
});
});
it('should delete the filter step when deleting edge from step to target through filter', async () => {
const mockStepsWithFilter = [
{
id: 'step-1',
type: WorkflowActionType.FORM,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2', 'filter-step'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'step-3',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'filter-step',
type: WorkflowActionType.FILTER,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
] as WorkflowAction[];
const mockWorkflowVersionWithFilter = {
id: mockWorkflowVersionId,
trigger: mockTrigger,
steps: mockStepsWithFilter,
status: 'DRAFT',
} as WorkflowVersionWorkspaceEntity;
workflowCommonWorkspaceService.getWorkflowVersionOrFail.mockResolvedValue(
mockWorkflowVersionWithFilter,
);
const result = await service.deleteWorkflowVersionEdge({
source: 'step-1',
target: 'step-3',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
steps: mockStepsWithFilter
.map((step) => {
if (step.id === 'step-1') {
return {
...step,
nextStepIds: ['step-2'],
};
}
return step;
})
.filter((step) => step.id !== 'filter-step'),
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
});
});
});
});
});
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
@Module({
imports: [WorkflowCommonModule],
providers: [WorkflowVersionEdgeWorkspaceService],
exports: [WorkflowVersionEdgeWorkspaceService],
})
@@ -1,21 +1,30 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
WorkflowVersionEdgeException,
WorkflowVersionEdgeExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-edge.exception';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@Injectable()
export class WorkflowVersionEdgeWorkspaceService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async createWorkflowVersionEdge({
@@ -36,26 +45,16 @@ export class WorkflowVersionEdgeWorkspaceService {
{ shouldBypassPermissionChecks: true },
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionEdgeException(
'WorkflowVersion not found',
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const steps = workflowVersion.steps || [];
const trigger = workflowVersion.trigger;
const isSourceTrigger = source === 'trigger';
const steps = workflowVersion.steps || [];
const targetStep = steps.find((step) => step.id === target);
@@ -66,41 +65,150 @@ export class WorkflowVersionEdgeWorkspaceService {
);
}
const isSourceTrigger = source === TRIGGER_STEP_ID;
if (isSourceTrigger) {
if (!isDefined(trigger)) {
throw new WorkflowVersionEdgeException(
`Trigger not found in workflowVersion '${workflowVersionId}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
return this.createTriggerEdge({
trigger,
steps,
target,
workflowVersion,
workflowVersionRepository,
});
} else {
return this.createStepEdge({
trigger,
steps,
source,
target,
workflowVersion,
workflowVersionRepository,
});
}
}
if (trigger.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
async deleteWorkflowVersionEdge({
source,
target,
workflowVersionId,
workspaceId,
}: {
source: string;
target: string;
workflowVersionId: string;
workspaceId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const updatedTrigger = {
...trigger,
nextStepIds: [...(trigger.nextStepIds ?? []), target],
};
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const trigger = workflowVersion.trigger;
const steps = workflowVersion.steps || [];
const targetStep = steps.find((step) => step.id === target);
if (!isDefined(targetStep)) {
throw new WorkflowVersionEdgeException(
`Target step '${target}' not found in workflowVersion '${workflowVersionId}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
const isSourceTrigger = source === TRIGGER_STEP_ID;
if (isSourceTrigger) {
return this.deleteTriggerEdge({
trigger,
steps,
target,
workflowVersion,
workflowVersionRepository,
});
} else {
return this.deleteStepEdge({
trigger,
steps,
source,
target,
workflowVersion,
workflowVersionRepository,
});
}
}
private async createTriggerEdge({
trigger,
steps,
target,
workflowVersion,
workflowVersionRepository,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
target: string;
workflowVersion: WorkflowVersionWorkspaceEntity;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
if (!isDefined(trigger)) {
throw new WorkflowVersionEdgeException(
`Trigger not found in workflowVersion '${workflowVersion.id}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
if (trigger.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
trigger,
steps,
});
}
const updatedTrigger = {
...trigger,
nextStepIds: [...(trigger.nextStepIds ?? []), target],
};
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps,
});
}
private async createStepEdge({
trigger,
steps,
source,
target,
workflowVersion,
workflowVersionRepository,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
source: string;
target: string;
workflowVersion: WorkflowVersionWorkspaceEntity;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
const sourceStep = steps.find((step) => step.id === source);
if (!isDefined(sourceStep)) {
throw new WorkflowVersionEdgeException(
`Source step '${source}' not found in workflowVersion '${workflowVersionId}'`,
`Source step '${source}' not found in workflowVersion '${workflowVersion.id}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
@@ -135,106 +243,92 @@ export class WorkflowVersionEdgeWorkspaceService {
});
}
async deleteWorkflowVersionEdge({
source,
private async deleteTriggerEdge({
trigger,
steps,
target,
workflowVersionId,
workspaceId,
workflowVersion,
workflowVersionRepository,
}: {
source: string;
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
target: string;
workflowVersionId: string;
workspaceId: string;
workflowVersion: WorkflowVersionWorkspaceEntity;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
if (!isDefined(trigger)) {
throw new WorkflowVersionEdgeException(
`Trigger not found in workflowVersion '${workflowVersion.id}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
if (!trigger.nextStepIds?.includes(target)) {
return this.handleFilterBetweenTriggerAndTarget({
trigger,
steps,
target,
workflowVersionId: workflowVersion.id,
workflowVersionRepository,
});
}
const updatedTrigger = {
...trigger,
nextStepIds: trigger.nextStepIds?.filter(
(nextStepId: string) => nextStepId !== target,
),
};
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionEdgeException(
'WorkflowVersion not found',
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
assertWorkflowVersionIsDraft(workflowVersion);
const steps = workflowVersion.steps || [];
const trigger = workflowVersion.trigger;
const isSourceTrigger = source === 'trigger';
const targetStep = steps.find((step) => step.id === target);
if (!isDefined(targetStep)) {
throw new WorkflowVersionEdgeException(
`Target step '${target}' not found in workflowVersion '${workflowVersionId}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
if (isSourceTrigger) {
if (!isDefined(trigger)) {
throw new WorkflowVersionEdgeException(
`Trigger not found in workflowVersion '${workflowVersionId}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
if (!trigger.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const updatedTrigger = {
...trigger,
nextStepIds: trigger.nextStepIds?.filter(
(nextStepId) => nextStepId !== target,
),
};
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps,
});
}
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps,
});
}
private async deleteStepEdge({
trigger,
steps,
source,
target,
workflowVersion,
workflowVersionRepository,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
source: string;
target: string;
workflowVersion: WorkflowVersionWorkspaceEntity;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
const sourceStep = steps.find((step) => step.id === source);
if (!isDefined(sourceStep)) {
throw new WorkflowVersionEdgeException(
`Source step '${source}' not found in workflowVersion '${workflowVersionId}'`,
`Source step '${source}' not found in workflowVersion '${workflowVersion.id}'`,
WorkflowVersionEdgeExceptionCode.NOT_FOUND,
);
}
if (!sourceStep.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
return await this.handleFilterBetweenSourceAndTarget({
trigger,
steps,
sourceStep,
target,
workflowVersionId: workflowVersion.id,
workflowVersionRepository,
});
}
const updatedSourceStep = {
...sourceStep,
nextStepIds: sourceStep.nextStepIds?.filter(
(nextStepId) => nextStepId !== target,
(nextStepId: string) => nextStepId !== target,
),
};
@@ -255,4 +349,125 @@ export class WorkflowVersionEdgeWorkspaceService {
steps: updatedSteps,
});
}
private async handleFilterBetweenTriggerAndTarget({
trigger,
steps,
target,
workflowVersionId,
workflowVersionRepository,
}: {
trigger: WorkflowTrigger;
steps: WorkflowAction[];
target: string;
workflowVersionId: string;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
const filterBetweenTriggerAndTarget = this.findFilterBetweenNodes({
steps,
sourceNextStepIds: trigger.nextStepIds,
target,
});
if (!isDefined(filterBetweenTriggerAndTarget)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const updatedTrigger = {
...trigger,
nextStepIds: trigger.nextStepIds?.filter(
(nextStepId: string) => nextStepId !== filterBetweenTriggerAndTarget.id,
),
};
const updatedSteps = steps.filter(
(step) => step.id !== filterBetweenTriggerAndTarget.id,
);
await workflowVersionRepository.update(workflowVersionId, {
trigger: updatedTrigger,
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps: updatedSteps,
});
}
private async handleFilterBetweenSourceAndTarget({
trigger,
steps,
sourceStep,
target,
workflowVersionRepository,
workflowVersionId,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
sourceStep: WorkflowAction;
target: string;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
workflowVersionId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const filterBetweenSourceAndTarget = this.findFilterBetweenNodes({
steps,
sourceNextStepIds: sourceStep.nextStepIds,
target,
});
if (!isDefined(filterBetweenSourceAndTarget)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const updatedSourceStep = {
...sourceStep,
nextStepIds: sourceStep.nextStepIds?.filter(
(nextStepId: string) => nextStepId !== filterBetweenSourceAndTarget.id,
),
};
const updatedSteps = steps
.map((step) => {
if (step.id === sourceStep.id) {
return updatedSourceStep;
}
return step;
})
.filter((step) => step.id !== filterBetweenSourceAndTarget.id);
await workflowVersionRepository.update(workflowVersionId, {
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
trigger,
steps: updatedSteps,
});
}
private findFilterBetweenNodes({
steps,
sourceNextStepIds,
target,
}: {
steps: WorkflowAction[];
sourceNextStepIds: string[] | undefined;
target: string;
}) {
const nextStepFilters = steps.filter(
(step) =>
sourceNextStepIds?.includes(step.id) &&
step.type === WorkflowActionType.FILTER,
);
return nextStepFilters.find((step) => step.nextStepIds?.includes(target));
}
}
@@ -1,6 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@@ -135,7 +137,7 @@ describe('WorkflowVersionStepWorkspaceService', () => {
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
parentStepId: 'trigger',
parentStepId: TRIGGER_STEP_ID,
nextStepId: undefined,
workflowVersionId: mockWorkflowVersionId,
},
@@ -160,7 +162,7 @@ describe('WorkflowVersionStepWorkspaceService', () => {
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
parentStepId: 'trigger',
parentStepId: TRIGGER_STEP_ID,
nextStepId: 'step-1',
workflowVersionId: mockWorkflowVersionId,
},
@@ -259,7 +261,7 @@ describe('WorkflowVersionStepWorkspaceService', () => {
it('should delete trigger', async () => {
const result = await service.deleteWorkflowVersionStep({
stepIdToDelete: 'trigger',
stepIdToDelete: TRIGGER_STEP_ID,
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
@@ -277,7 +279,7 @@ describe('WorkflowVersionStepWorkspaceService', () => {
'step-2': [],
'step-3': [],
},
deletedStepIds: ['trigger'],
deletedStepIds: [TRIGGER_STEP_ID],
});
});
});
@@ -1,3 +1,5 @@
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import {
type WorkflowAction,
@@ -146,7 +148,7 @@ describe('insertStep', () => {
existingTrigger,
existingSteps: [step1],
insertedStep: newStep,
parentStepId: 'trigger',
parentStepId: TRIGGER_STEP_ID,
nextStepId: undefined,
});
@@ -1,3 +1,5 @@
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
import {
type WorkflowAction,
@@ -187,7 +189,7 @@ describe('removeStep', () => {
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, step2, step3],
stepIdToDelete: 'trigger',
stepIdToDelete: TRIGGER_STEP_ID,
stepToDeleteChildrenIds: ['1'],
});
@@ -247,7 +249,7 @@ describe('removeStep', () => {
const result = removeStep({
existingTrigger: { ...mockTrigger, nextStepIds: [] },
existingSteps: null,
stepIdToDelete: 'trigger',
stepIdToDelete: TRIGGER_STEP_ID,
});
expect(result.updatedTrigger).toEqual(null);
@@ -1,3 +1,5 @@
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@@ -22,7 +24,7 @@ export const insertStep = ({
let updatedExistingSteps = existingSteps;
if (parentStepId === 'trigger') {
if (parentStepId === TRIGGER_STEP_ID) {
if (!existingTrigger) {
throw new Error('Cannot insert step from undefined trigger');
}
@@ -1,4 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import {
type WorkflowAction,
@@ -181,7 +182,7 @@ const removeTrigger = ({
return {
updatedSteps,
updatedTrigger: null,
removedStepIds: ['trigger', ...stepIdsToRemove],
removedStepIds: [TRIGGER_STEP_ID, ...stepIdsToRemove],
};
};
@@ -196,7 +197,7 @@ export const removeStep = ({
stepIdToDelete: string;
stepToDeleteChildrenIds?: string[];
}) => {
if (stepIdToDelete === 'trigger') {
if (stepIdToDelete === TRIGGER_STEP_ID) {
return removeTrigger({
existingSteps,
triggerChildrenIds: stepToDeleteChildrenIds,
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { StepStatus } from 'twenty-shared/workflow';
import { StepStatus, TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -221,7 +221,7 @@ export class WorkflowVersionStepWorkspaceService {
const existingTrigger = workflowVersion.trigger;
const isDeletingTrigger =
stepIdToDelete === 'trigger' && isDefined(existingTrigger);
stepIdToDelete === TRIGGER_STEP_ID && isDefined(existingTrigger);
if (!isDeletingTrigger && !isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
@@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { type WorkflowStepPositionUpdateInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
WorkflowVersionStepException,
@@ -17,7 +19,6 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/
import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowStepPositionUpdateInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto';
@Injectable()
export class WorkflowVersionWorkspaceService {
@@ -139,7 +140,7 @@ export class WorkflowVersionWorkspaceService {
assertWorkflowVersionIsDraft(workflowVersion);
const triggerPosition = positions.find(
(position) => position.id === 'trigger',
(position) => position.id === TRIGGER_STEP_ID,
);
const updatedTrigger =
@@ -0,0 +1 @@
export const TRIGGER_STEP_ID = 'trigger';
@@ -7,6 +7,7 @@
* |___/
*/
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
export type {
WorkflowRunStepInfo,
WorkflowRunStepInfos,