Update next step ids on step update (#11605)

When inserting a new step between step 1 et step 2, then step 1 should
have the new step as next step id, add stop having step 2.

When deleting a step, we link the parent and next steps together. It may
change in the future
This commit is contained in:
Thomas Trompette
2025-04-16 15:30:05 +02:00
committed by GitHub
parent bf704bd1bc
commit 78e10b2da5
10 changed files with 338 additions and 17 deletions

View File

@ -411,6 +411,10 @@ export type CreateServerlessFunctionInput = {
};
export type CreateWorkflowVersionStepInput = {
/** Next step ID */
nextStepId?: InputMaybe<Scalars['String']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['String']>;
/** New step type */
stepType: Scalars['String'];
/** Workflow version ID */

View File

@ -12,8 +12,8 @@ import {
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const generateWorkflowDiagram = ({
trigger,

View File

@ -38,6 +38,8 @@ export const useCreateStep = ({
await createWorkflowVersionStep({
workflowVersionId,
stepType: newStepType,
parentStepId: workflowCreateStepFromParentStepId,
nextStepId: undefined,
})
)?.data?.createWorkflowVersionStep;

View File

@ -15,4 +15,16 @@ export class CreateWorkflowVersionStepInput {
nullable: false,
})
stepType: WorkflowActionType;
@Field(() => String, {
description: 'Parent step ID',
nullable: true,
})
parentStepId?: string;
@Field(() => String, {
description: 'Next step ID',
nullable: true,
})
nextStepId?: string;
}

View File

@ -26,12 +26,11 @@ export class WorkflowStepResolver {
async createWorkflowVersionStep(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args('input')
{ stepType, workflowVersionId }: CreateWorkflowVersionStepInput,
input: CreateWorkflowVersionStepInput,
): Promise<WorkflowActionDTO> {
return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepType,
input,
});
}
@ -57,7 +56,7 @@ export class WorkflowStepResolver {
return this.workflowVersionStepWorkspaceService.deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
stepIdToDelete: stepId,
});
}

View File

@ -0,0 +1,111 @@
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/insert-step';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
describe('insertStep', () => {
const createMockAction = (
id: string,
nextStepIds?: string[],
): WorkflowAction => ({
id,
name: `Action ${id}`,
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: 'test',
serverlessFunctionVersion: '1.0.0',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
valid: true,
nextStepIds,
});
it('should insert a step at the end of the array when no parent or next step is specified', () => {
const step1 = createMockAction('1');
const step2 = createMockAction('2');
const newStep = createMockAction('new');
const result = insertStep({
existingSteps: [step1, step2],
insertedStep: newStep,
});
expect(result).toEqual([step1, step2, newStep]);
});
it('should update parent step nextStepIds when inserting a step between two steps', () => {
const step1 = createMockAction('1', ['2']);
const step2 = createMockAction('2');
const newStep = createMockAction('new');
const result = insertStep({
existingSteps: [step1, step2],
insertedStep: newStep,
parentStepId: '1',
nextStepId: '2',
});
expect(result).toEqual([
{ ...step1, nextStepIds: ['new'] },
step2,
{ ...newStep, nextStepIds: ['2'] },
]);
});
it('should handle inserting a step at the beginning of the workflow', () => {
const step1 = createMockAction('1');
const newStep = createMockAction('new');
const result = insertStep({
existingSteps: [step1],
insertedStep: newStep,
parentStepId: undefined,
nextStepId: '1',
});
expect(result).toEqual([step1, { ...newStep, nextStepIds: ['1'] }]);
});
it('should handle inserting a step at the end of the workflow', () => {
const step1 = createMockAction('1');
const newStep = createMockAction('new');
const result = insertStep({
existingSteps: [step1],
insertedStep: newStep,
parentStepId: '1',
nextStepId: undefined,
});
expect(result).toEqual([{ ...step1, nextStepIds: ['new'] }, newStep]);
});
it('should handle inserting a step between two steps with multiple nextStepIds', () => {
const step1 = createMockAction('1', ['2', '3']);
const step2 = createMockAction('2');
const step3 = createMockAction('3');
const newStep = createMockAction('new');
const result = insertStep({
existingSteps: [step1, step2, step3],
insertedStep: newStep,
parentStepId: '1',
nextStepId: '2',
});
expect(result).toEqual([
{ ...step1, nextStepIds: ['3', 'new'] },
step2,
step3,
{ ...newStep, nextStepIds: ['2'] },
]);
});
});

View File

@ -0,0 +1,108 @@
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/remove-step';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
describe('removeStep', () => {
const createMockAction = (
id: string,
nextStepIds?: string[],
): WorkflowAction => ({
id,
name: `Action ${id}`,
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: 'test',
serverlessFunctionVersion: '1.0.0',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
valid: true,
nextStepIds,
});
it('should remove the specified step from the array', () => {
const step1 = createMockAction('1');
const step2 = createMockAction('2');
const step3 = createMockAction('3');
const result = removeStep({
existingSteps: [step1, step2, step3],
stepIdToDelete: '2',
});
expect(result).toEqual([step1, step3]);
});
it('should handle removing a step that has no next steps', () => {
const step1 = createMockAction('1', ['2']);
const step2 = createMockAction('2');
const step3 = createMockAction('3');
const result = removeStep({
existingSteps: [step1, step2, step3],
stepIdToDelete: '2',
});
expect(result).toEqual([{ ...step1, nextStepIds: [] }, step3]);
});
it('should update nextStepIds of parent steps to include children of removed step', () => {
const step1 = createMockAction('1', ['2']);
const step2 = createMockAction('2', ['3']);
const step3 = createMockAction('3');
const result = removeStep({
existingSteps: [step1, step2, step3],
stepIdToDelete: '2',
stepToDeleteChildrenIds: ['3'],
});
expect(result).toEqual([{ ...step1, nextStepIds: ['3'] }, step3]);
});
it('should handle multiple parent steps pointing to the same step', () => {
const step1 = createMockAction('1', ['3']);
const step2 = createMockAction('2', ['3']);
const step3 = createMockAction('3', ['4']);
const step4 = createMockAction('4');
const result = removeStep({
existingSteps: [step1, step2, step3, step4],
stepIdToDelete: '3',
stepToDeleteChildrenIds: ['4'],
});
expect(result).toEqual([
{ ...step1, nextStepIds: ['4'] },
{ ...step2, nextStepIds: ['4'] },
step4,
]);
});
it('should handle removing a step with multiple children', () => {
const step1 = createMockAction('1', ['2']);
const step2 = createMockAction('2', ['3', '4']);
const step3 = createMockAction('3');
const step4 = createMockAction('4');
const result = removeStep({
existingSteps: [step1, step2, step3, step4],
stepIdToDelete: '2',
stepToDeleteChildrenIds: ['3', '4'],
});
expect(result).toEqual([
{ ...step1, nextStepIds: ['3', '4'] },
step3,
step4,
]);
});
});

View File

@ -0,0 +1,38 @@
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const insertStep = ({
existingSteps,
insertedStep,
parentStepId,
nextStepId,
}: {
existingSteps: WorkflowAction[];
insertedStep: WorkflowAction;
parentStepId?: string;
nextStepId?: string;
}): WorkflowAction[] => {
const updatedExistingSteps = existingSteps.map((existingStep) => {
if (existingStep.id === parentStepId) {
return {
...existingStep,
nextStepIds: [
...new Set([
...(existingStep.nextStepIds?.filter((id) => id !== nextStepId) ||
[]),
insertedStep.id,
]),
],
};
}
return existingStep;
});
return [
...updatedExistingSteps,
{
...insertedStep,
nextStepIds: nextStepId ? [nextStepId] : undefined,
},
];
};

View File

@ -0,0 +1,30 @@
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const removeStep = ({
existingSteps,
stepIdToDelete,
stepToDeleteChildrenIds,
}: {
existingSteps: WorkflowAction[];
stepIdToDelete: string;
stepToDeleteChildrenIds?: string[];
}): WorkflowAction[] => {
return existingSteps
.filter((step) => step.id !== stepIdToDelete)
.map((step) => {
if (step.nextStepIds?.includes(stepIdToDelete)) {
return {
...step,
nextStepIds: [
...new Set([
...step.nextStepIds.filter((id) => id !== stepIdToDelete),
// We automatically link parent and child steps together
...(stepToDeleteChildrenIds || []),
]),
],
};
}
return step;
});
};

View File

@ -7,6 +7,7 @@ import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@ -18,6 +19,8 @@ import {
import { StepOutput } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/insert-step';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/remove-step';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import {
WorkflowAction,
@ -54,13 +57,13 @@ export class WorkflowVersionStepWorkspaceService {
async createWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepType,
input,
}: {
workspaceId: string;
workflowVersionId: string;
stepType: WorkflowActionType;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowActionDTO> {
const { workflowVersionId, stepType, parentStepId, nextStepId } = input;
const newStep = await this.getStepDefaultDefinition({
type: stepType,
workspaceId,
@ -87,8 +90,16 @@ export class WorkflowVersionStepWorkspaceService {
);
}
const existingSteps = workflowVersion.steps || [];
const updatedSteps = insertStep({
existingSteps,
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
});
await workflowVersionRepository.update(workflowVersion.id, {
steps: [...(workflowVersion.steps || []), enrichedNewStep],
steps: updatedSteps,
});
return enrichedNewStep;
@ -151,11 +162,11 @@ export class WorkflowVersionStepWorkspaceService {
async deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
stepIdToDelete,
}: {
workspaceId: string;
workflowVersionId: string;
stepId: string;
stepIdToDelete: string;
}): Promise<WorkflowActionDTO> {
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
@ -182,9 +193,9 @@ export class WorkflowVersionStepWorkspaceService {
);
}
const stepToDelete = workflowVersion.steps.filter(
(step) => step.id === stepId,
)?.[0];
const stepToDelete = workflowVersion.steps.find(
(step) => step.id === stepIdToDelete,
);
if (!isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
@ -194,9 +205,15 @@ export class WorkflowVersionStepWorkspaceService {
}
const workflowVersionUpdates =
stepId === TRIGGER_STEP_ID
stepIdToDelete === TRIGGER_STEP_ID
? { trigger: null }
: { steps: workflowVersion.steps.filter((step) => step.id !== stepId) };
: {
steps: removeStep({
existingSteps: workflowVersion.steps,
stepIdToDelete,
stepToDeleteChildrenIds: stepToDelete.nextStepIds,
}),
};
await workflowVersionRepository.update(
workflowVersion.id,