Migrate update step changes to diff (#14663)

This PR should fix optimistic rendering issues on step updates:
- compute a diff for trigger and steps on mutations
- build an util that applies that diff (built a more robust version from
https://github.com/AsyncBanana/micropatch)
- apply diff in cache
This commit is contained in:
Thomas Trompette
2025-09-23 18:41:26 +02:00
committed by GitHub
parent a5c47a60d0
commit 6ea7aea92b
31 changed files with 1545 additions and 518 deletions
@@ -1,24 +1,191 @@
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
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';
import {
type WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
type WorkflowTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
describe('computeWorkflowVersionStepChanges', () => {
it('should compute next step ids', () => {
const input = {
trigger: { nextStepIds: ['1', '2'] } as WorkflowTrigger,
steps: [
{ id: '1', nextStepIds: ['3'] },
{ id: '2', nextStepIds: ['3'] },
] as WorkflowAction[],
deletedStepIds: ['5'],
const mockTrigger: WorkflowTrigger = {
name: 'Test Manual Trigger',
type: WorkflowTriggerType.MANUAL,
settings: {
input: {},
outputSchema: {},
},
nextStepIds: ['step-1'],
};
const mockSteps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Form Step',
type: WorkflowActionType.FORM,
settings: {
input: [],
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
valid: true,
nextStepIds: ['step-2'],
},
{
id: 'step-2',
name: 'Email Step',
type: WorkflowActionType.SEND_EMAIL,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
valid: true,
nextStepIds: [],
},
];
it('should compute trigger diff when trigger is updated', () => {
const existingTrigger = mockTrigger;
const updatedTrigger = {
...mockTrigger,
nextStepIds: ['step-1', 'step-3'],
};
const expectedResult = {
triggerNextStepIds: ['1', '2'],
stepsNextStepIds: { '1': ['3'], '2': ['3'] },
deletedStepIds: ['5'],
};
const result = computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps: mockSteps,
updatedTrigger,
updatedSteps: mockSteps,
});
expect(computeWorkflowVersionStepChanges(input)).toEqual(expectedResult);
expect(result.triggerDiff).toMatchObject([
{ path: ['trigger', 'nextStepIds', 1], type: 'CREATE', value: 'step-3' },
]);
expect(result.stepsDiff).toMatchObject([]);
expect(result.stepsDiff.length).toBe(0); // No steps changed
// Verify the trigger diff contains the nextStepIds change
const nextStepIdsDiff = result.triggerDiff.find((diff) =>
diff.path.includes('nextStepIds'),
);
expect(nextStepIdsDiff).toMatchObject({
type: 'CREATE',
path: ['trigger', 'nextStepIds', 1],
value: 'step-3',
});
});
it('should compute steps diff when steps are updated', () => {
const existingSteps = mockSteps;
const updatedSteps = [
...mockSteps,
{
id: 'step-3',
name: 'Code Step',
type: WorkflowActionType.CODE,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
outputSchema: {},
},
valid: true,
nextStepIds: [],
} as WorkflowAction,
];
const result = computeWorkflowVersionStepChanges({
existingTrigger: mockTrigger,
existingSteps,
updatedTrigger: mockTrigger,
updatedSteps,
});
expect(result.stepsDiff).toBeDefined();
expect(result.stepsDiff.length).toBeGreaterThan(0);
expect(result.triggerDiff).toBeDefined();
expect(result.triggerDiff.length).toBe(0); // No trigger changed
// Verify the steps diff contains the new step
const createDiff = result.stepsDiff.find((diff) => diff.type === 'CREATE');
expect(createDiff).toBeDefined();
});
it('should return empty diffs when no changes are made', () => {
const result = computeWorkflowVersionStepChanges({
existingTrigger: mockTrigger,
existingSteps: mockSteps,
updatedTrigger: mockTrigger,
updatedSteps: mockSteps,
});
expect(result.triggerDiff).toBeDefined();
expect(result.triggerDiff.length).toBe(0);
expect(result.stepsDiff).toBeDefined();
expect(result.stepsDiff.length).toBe(0);
});
it('should handle null existing trigger and steps', () => {
const updatedTrigger = mockTrigger;
const updatedSteps = mockSteps;
const result = computeWorkflowVersionStepChanges({
existingTrigger: null,
existingSteps: null,
updatedTrigger,
updatedSteps,
});
expect(result.triggerDiff).toBeDefined();
expect(result.triggerDiff.length).toBeGreaterThan(0);
expect(result.stepsDiff).toBeDefined();
expect(result.stepsDiff.length).toBeGreaterThan(0);
// Verify change diffs are present
const triggerChangeDiff = result.triggerDiff.find(
(diff) => diff.type === 'CHANGE',
);
expect(triggerChangeDiff).toBeDefined();
const stepsChangeDiff = result.stepsDiff.find(
(diff) => diff.type === 'CHANGE',
);
expect(stepsChangeDiff).toBeDefined();
});
it('should return empty diffs when updated values are undefined', () => {
const result = computeWorkflowVersionStepChanges({
existingTrigger: mockTrigger,
existingSteps: mockSteps,
// updatedTrigger and updatedSteps are undefined
});
expect(result.triggerDiff).toBeDefined();
expect(result.triggerDiff.length).toBe(0);
expect(result.stepsDiff).toBeDefined();
expect(result.stepsDiff.length).toBe(0);
});
});
@@ -1,24 +1,28 @@
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { isDefined } from 'class-validator';
import diff, { type Difference } from 'microdiff';
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';
export const computeWorkflowVersionStepChanges = ({
trigger,
steps,
createdStep,
deletedStepIds,
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[] | null;
createdStep?: WorkflowAction;
deletedStepIds?: string[];
}): WorkflowVersionStepChangesDTO => {
return {
triggerNextStepIds: trigger?.nextStepIds,
stepsNextStepIds: Object.fromEntries(
(steps || []).map((step) => [step.id, step.nextStepIds]),
),
createdStep,
deletedStepIds,
};
existingTrigger: WorkflowTrigger | null;
existingSteps: WorkflowAction[] | null;
updatedTrigger?: WorkflowTrigger | null;
updatedSteps?: WorkflowAction[] | null;
}) => {
const triggerDiff: Difference[] =
updatedTrigger !== undefined
? diff({ trigger: existingTrigger }, { trigger: updatedTrigger })
: [];
const stepsDiff: Difference[] = isDefined(updatedSteps)
? diff({ steps: existingSteps }, { steps: updatedSteps })
: [];
return { triggerDiff, stepsDiff };
};
@@ -181,12 +181,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1', 'step-3'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
triggerDiff: [
{
path: ['trigger', 'nextStepIds', 1],
type: 'CREATE',
value: 'step-3',
},
],
stepsDiff: [],
});
});
@@ -203,12 +205,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
triggerDiff: [],
stepsDiff: [],
});
});
});
@@ -272,13 +270,21 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
'iterator-step': ['step-2'],
},
stepsDiff: [
{
path: [
'steps',
3,
'settings',
'input',
'initialLoopStepIds',
1,
],
type: 'CREATE',
value: 'step-3',
},
],
triggerDiff: [],
});
});
@@ -301,13 +307,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
'iterator-step': ['step-2'],
},
stepsDiff: [],
triggerDiff: [],
});
});
@@ -357,13 +358,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
'iterator-step': ['step-2', 'step-3'],
},
stepsDiff: [
{
path: ['steps', 3, 'nextStepIds', 1],
type: 'CREATE',
value: 'step-3',
},
],
triggerDiff: [],
});
});
});
@@ -392,12 +394,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': ['step-3'],
'step-3': [],
},
stepsDiff: [
{
path: ['steps', 1, 'nextStepIds', 0],
type: 'CREATE',
value: 'step-3',
},
],
triggerDiff: [],
});
});
@@ -414,12 +418,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [],
triggerDiff: [],
});
});
@@ -480,12 +480,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: [],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
triggerDiff: [
{
oldValue: 'step-1',
path: ['trigger', 'nextStepIds', 0],
type: 'REMOVE',
},
],
stepsDiff: [],
});
});
@@ -509,12 +511,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [],
triggerDiff: [],
});
});
});
@@ -544,12 +542,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': [],
'step-2': [],
'step-3': [],
},
stepsDiff: [
{
oldValue: 'step-2',
path: ['steps', 0, 'nextStepIds', 0],
type: 'REMOVE',
},
],
triggerDiff: [],
});
});
@@ -566,12 +566,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalledWith();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [],
triggerDiff: [],
});
});
@@ -670,13 +666,34 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'iterator-step': ['step-1'],
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [
{
oldValue: 'step-2',
path: [
'steps',
0,
'settings',
'input',
'initialLoopStepIds',
0,
],
type: 'CHANGE',
value: 'step-3',
},
{
oldValue: 'step-3',
path: [
'steps',
0,
'settings',
'input',
'initialLoopStepIds',
1,
],
type: 'REMOVE',
},
],
triggerDiff: [],
});
});
@@ -699,13 +716,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
).not.toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'iterator-step': ['step-1'],
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [],
triggerDiff: [],
});
});
@@ -735,13 +747,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'iterator-step': [],
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
stepsDiff: [
{
oldValue: 'step-1',
path: ['steps', 0, 'nextStepIds', 0],
type: 'REMOVE',
},
],
triggerDiff: [],
});
});
});
@@ -176,8 +176,8 @@ export class WorkflowVersionEdgeWorkspaceService {
if (trigger.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
});
}
@@ -191,8 +191,9 @@ export class WorkflowVersionEdgeWorkspaceService {
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
updatedTrigger,
});
}
@@ -227,8 +228,8 @@ export class WorkflowVersionEdgeWorkspaceService {
!isDefined(sourceConnectionOptions)
) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
});
}
@@ -257,8 +258,9 @@ export class WorkflowVersionEdgeWorkspaceService {
}
return computeWorkflowVersionStepChanges({
trigger,
steps: updatedSteps,
existingTrigger: trigger,
existingSteps: steps,
updatedSteps,
});
}
@@ -375,8 +377,8 @@ export class WorkflowVersionEdgeWorkspaceService {
if (!trigger.nextStepIds?.includes(target)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
});
}
@@ -392,8 +394,9 @@ export class WorkflowVersionEdgeWorkspaceService {
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
updatedTrigger,
});
}
@@ -428,8 +431,8 @@ export class WorkflowVersionEdgeWorkspaceService {
!isDefined(sourceConnectionOptions)
) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
});
}
@@ -453,8 +456,8 @@ export class WorkflowVersionEdgeWorkspaceService {
if (!shouldPersist) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
existingTrigger: trigger,
existingSteps: steps,
});
}
@@ -471,8 +474,9 @@ export class WorkflowVersionEdgeWorkspaceService {
});
return computeWorkflowVersionStepChanges({
trigger,
steps: updatedSteps,
existingTrigger: trigger,
existingSteps: steps,
updatedSteps,
});
}
@@ -191,8 +191,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
workflowVersionId: 'workflow-version-id',
});
expect(result.type).toBe(WorkflowActionType.CODE);
const codeResult = result as unknown as {
expect(result.builtStep.type).toBe(WorkflowActionType.CODE);
const codeResult = result.builtStep as unknown as {
settings: {
input: {
serverlessFunctionId: string;
@@ -214,8 +214,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
workflowVersionId: 'workflow-version-id',
});
expect(result.type).toBe(WorkflowActionType.FORM);
expect(result.settings.input).toEqual([]);
expect(result.builtStep.type).toBe(WorkflowActionType.FORM);
expect(result.builtStep.settings.input).toEqual([]);
});
});
@@ -15,6 +15,13 @@ import {
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
jest.mock(
'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util',
() => ({
computeWorkflowVersionStepChanges: jest.fn(),
}),
);
type MockWorkspaceRepository = Partial<
WorkspaceRepository<WorkflowVersionWorkspaceEntity>
> & {
@@ -78,8 +85,16 @@ describe('WorkflowVersionStepWorkspaceService', () => {
let twentyORMGlobalManager: jest.Mocked<TwentyORMGlobalManager>;
let service: WorkflowVersionStepWorkspaceService;
let mockWorkflowVersionWorkspaceRepository: MockWorkspaceRepository;
let mockComputeWorkflowVersionStepChanges: jest.Mock;
beforeEach(async () => {
const {
computeWorkflowVersionStepChanges,
} = require('src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util');
mockComputeWorkflowVersionStepChanges =
computeWorkflowVersionStepChanges as jest.Mock;
mockWorkflowVersionWorkspaceRepository = {
findOne: jest.fn(),
update: jest.fn(),
@@ -116,10 +131,13 @@ describe('WorkflowVersionStepWorkspaceService', () => {
runStepCreationSideEffectsAndBuildStep: jest
.fn()
.mockImplementation(({ type }) => ({
id: 'new-step-id',
type,
settings: {},
nextStepIds: [],
builtStep: {
id: 'new-step-id',
type,
settings: {},
nextStepIds: [],
},
additionalCreatedSteps: [],
})),
runWorkflowVersionStepDeletionSideEffects: jest.fn(),
},
@@ -140,6 +158,25 @@ describe('WorkflowVersionStepWorkspaceService', () => {
describe('createWorkflowVersionStep', () => {
it('should create a step linked to trigger', async () => {
const mockChanges = {
triggerDiff: [
{
type: 'CHANGE',
path: ['nextStepIds'],
value: ['step-1', 'new-step-id'],
},
],
stepsDiff: [
{
type: 'CREATE',
path: [],
value: { id: 'new-step-id', type: 'FORM' },
},
],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
@@ -151,20 +188,33 @@ describe('WorkflowVersionStepWorkspaceService', () => {
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result.createdStep).toBeDefined();
const createdStepId = result.createdStep?.id;
expect(result.triggerNextStepIds).toEqual(['step-1', createdStepId]);
expect(result.stepsNextStepIds).toEqual({
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
it('should create a step between a trigger and a step', async () => {
const mockChanges = {
triggerDiff: [
{ type: 'CHANGE', path: ['nextStepIds'], value: ['new-step-id'] },
],
stepsDiff: [
{
type: 'CREATE',
path: [],
value: {
id: 'new-step-id',
type: 'FORM',
nextStepIds: ['step-1'],
},
},
],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
@@ -176,21 +226,32 @@ describe('WorkflowVersionStepWorkspaceService', () => {
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result.createdStep).toBeDefined();
const createdStepId = result.createdStep?.id as string;
expect(result.triggerNextStepIds).toEqual([createdStepId]);
expect(result.stepsNextStepIds).toEqual({
[createdStepId]: ['step-1'],
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
it('should create a step between two steps', async () => {
const mockChanges = {
triggerDiff: [],
stepsDiff: [
{ type: 'CHANGE', path: [0, 'nextStepIds'], value: ['new-step-id'] },
{
type: 'CREATE',
path: [],
value: {
id: 'new-step-id',
type: 'FORM',
nextStepIds: ['step-2'],
},
},
],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
@@ -202,21 +263,27 @@ describe('WorkflowVersionStepWorkspaceService', () => {
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result.createdStep).toBeDefined();
const createdStepId = result.createdStep?.id as string;
expect(result.triggerNextStepIds).toEqual(['step-1']);
expect(result.stepsNextStepIds).toEqual({
'step-1': [createdStepId],
[createdStepId]: ['step-2'],
'step-2': [],
'step-3': [],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
it('should create a step without parent or children', async () => {
const mockChanges = {
triggerDiff: [],
stepsDiff: [
{
type: 'CREATE',
path: [],
value: { id: 'new-step-id', type: 'FORM' },
},
],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.createWorkflowVersionStep({
input: {
stepType: WorkflowActionType.FORM,
@@ -228,65 +295,59 @@ describe('WorkflowVersionStepWorkspaceService', () => {
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result.createdStep).toBeDefined();
expect(result.triggerNextStepIds).toEqual(['step-1']);
expect(result.stepsNextStepIds).toEqual({
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
});
describe('deleteWorkflowVersionStep', () => {
it('should delete step linked to trigger', async () => {
const mockChanges = {
triggerDiff: [
{ type: 'CHANGE', path: ['nextStepIds'], value: ['step-2'] },
],
stepsDiff: [{ type: 'REMOVE', path: [0] }],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.deleteWorkflowVersionStep({
stepIdToDelete: 'step-1',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
trigger: { ...mockTrigger, nextStepIds: ['step-2'] },
steps: mockSteps.filter((step) => step.id !== 'step-1'),
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result).toEqual({
triggerNextStepIds: ['step-2'],
stepsNextStepIds: {
'step-2': [],
'step-3': [],
},
deletedStepIds: ['step-1'],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
it('should delete trigger', async () => {
const mockChanges = {
triggerDiff: [{ type: 'REMOVE', path: [] }],
stepsDiff: [],
};
mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges);
const result = await service.deleteWorkflowVersionStep({
stepIdToDelete: TRIGGER_STEP_ID,
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
trigger: null,
steps: mockSteps,
});
expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled();
expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled();
expect(result).toEqual({
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
deletedStepIds: [TRIGGER_STEP_ID],
});
expect(result).toEqual(mockChanges);
expect(result.triggerDiff).toBeDefined();
expect(result.stepsDiff).toBeDefined();
});
});
});
@@ -101,16 +101,19 @@ export class WorkflowVersionStepOperationsWorkspaceService {
workspaceId,
workflowVersionId,
position,
id,
}: {
type: WorkflowActionType;
workspaceId: string;
workflowVersionId: string;
position?: WorkflowStepPositionInput;
}): Promise<WorkflowAction> {
const newStepId = v4();
id?: string;
}): Promise<{
builtStep: WorkflowAction;
additionalCreatedSteps?: WorkflowAction[];
}> {
const baseStep = {
id: newStepId,
id: id || v4(),
position,
valid: false,
nextStepIds: [],
@@ -135,40 +138,44 @@ export class WorkflowVersionStepOperationsWorkspaceService {
}
return {
...baseStep,
name: 'Code - Serverless Function',
type: WorkflowActionType.CODE,
settings: {
...BASE_STEP_DEFINITION,
outputSchema: {
link: {
isLeaf: true,
icon: 'IconVariable',
tab: 'test',
label: 'Generate Function Output',
builtStep: {
...baseStep,
name: 'Code - Serverless Function',
type: WorkflowActionType.CODE,
settings: {
...BASE_STEP_DEFINITION,
outputSchema: {
link: {
isLeaf: true,
icon: 'IconVariable',
tab: 'test',
label: 'Generate Function Output',
},
_outputSchemaType: 'LINK',
},
input: {
serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA,
},
_outputSchemaType: 'LINK',
},
input: {
serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA,
},
},
};
}
case WorkflowActionType.SEND_EMAIL: {
return {
...baseStep,
name: 'Send Email',
type: WorkflowActionType.SEND_EMAIL,
settings: {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
builtStep: {
...baseStep,
name: 'Send Email',
type: WorkflowActionType.SEND_EMAIL,
settings: {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
},
},
};
@@ -180,14 +187,16 @@ export class WorkflowVersionStepOperationsWorkspaceService {
});
return {
...baseStep,
name: 'Create Record',
type: WorkflowActionType.CREATE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
builtStep: {
...baseStep,
name: 'Create Record',
type: WorkflowActionType.CREATE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
},
},
},
};
@@ -199,16 +208,18 @@ export class WorkflowVersionStepOperationsWorkspaceService {
});
return {
...baseStep,
name: 'Update Record',
type: WorkflowActionType.UPDATE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
objectRecordId: '',
fieldsToUpdate: [],
builtStep: {
...baseStep,
name: 'Update Record',
type: WorkflowActionType.UPDATE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {},
objectRecordId: '',
fieldsToUpdate: [],
},
},
},
};
@@ -220,14 +231,16 @@ export class WorkflowVersionStepOperationsWorkspaceService {
});
return {
...baseStep,
name: 'Delete Record',
type: WorkflowActionType.DELETE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecordId: '',
builtStep: {
...baseStep,
name: 'Delete Record',
type: WorkflowActionType.DELETE_RECORD,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecordId: '',
},
},
},
};
@@ -239,69 +252,79 @@ export class WorkflowVersionStepOperationsWorkspaceService {
});
return {
...baseStep,
name: 'Search Records',
type: WorkflowActionType.FIND_RECORDS,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
limit: 1,
builtStep: {
...baseStep,
name: 'Search Records',
type: WorkflowActionType.FIND_RECORDS,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
limit: 1,
},
},
},
};
}
case WorkflowActionType.FORM: {
return {
...baseStep,
name: 'Form',
type: WorkflowActionType.FORM,
settings: {
...BASE_STEP_DEFINITION,
input: [],
builtStep: {
...baseStep,
name: 'Form',
type: WorkflowActionType.FORM,
settings: {
...BASE_STEP_DEFINITION,
input: [],
},
},
};
}
case WorkflowActionType.FILTER: {
return {
...baseStep,
name: 'Filter',
type: WorkflowActionType.FILTER,
settings: {
...BASE_STEP_DEFINITION,
input: {
stepFilterGroups: [],
stepFilters: [],
builtStep: {
...baseStep,
name: 'Filter',
type: WorkflowActionType.FILTER,
settings: {
...BASE_STEP_DEFINITION,
input: {
stepFilterGroups: [],
stepFilters: [],
},
},
},
};
}
case WorkflowActionType.HTTP_REQUEST: {
return {
...baseStep,
name: 'HTTP Request',
type: WorkflowActionType.HTTP_REQUEST,
settings: {
...BASE_STEP_DEFINITION,
input: {
url: '',
method: 'GET',
headers: {},
body: {},
builtStep: {
...baseStep,
name: 'HTTP Request',
type: WorkflowActionType.HTTP_REQUEST,
settings: {
...BASE_STEP_DEFINITION,
input: {
url: '',
method: 'GET',
headers: {},
body: {},
},
},
},
};
}
case WorkflowActionType.AI_AGENT: {
return {
...baseStep,
name: 'AI Agent',
type: WorkflowActionType.AI_AGENT,
settings: {
...BASE_STEP_DEFINITION,
input: {
agentId: '',
prompt: '',
builtStep: {
...baseStep,
name: 'AI Agent',
type: WorkflowActionType.AI_AGENT,
settings: {
...BASE_STEP_DEFINITION,
input: {
agentId: '',
prompt: '',
},
},
},
};
@@ -315,16 +338,19 @@ export class WorkflowVersionStepOperationsWorkspaceService {
});
return {
...baseStep,
name: 'Iterator',
type: WorkflowActionType.ITERATOR,
settings: {
...BASE_STEP_DEFINITION,
input: {
items: [],
initialLoopStepIds: [emptyNodeStep.id],
builtStep: {
...baseStep,
name: 'Iterator',
type: WorkflowActionType.ITERATOR,
settings: {
...BASE_STEP_DEFINITION,
input: {
items: [],
initialLoopStepIds: [emptyNodeStep.id],
},
},
},
additionalCreatedSteps: [emptyNodeStep],
};
}
default:
@@ -4,6 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
@@ -43,24 +44,9 @@ export class WorkflowVersionStepWorkspaceService {
nextStepId,
position,
parentStepConnectionOptions,
id,
} = input;
const newStep =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: newStep,
workspaceId,
});
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
@@ -69,12 +55,29 @@ export class WorkflowVersionStepWorkspaceService {
assertWorkflowVersionIsDraft(workflowVersion);
const existingSteps = workflowVersion.steps || [];
const existingSteps = workflowVersion.steps;
const existingTrigger = workflowVersion.trigger;
const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({
existingSteps,
const { builtStep, additionalCreatedSteps } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
id,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: builtStep,
workspaceId,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: existingSteps ?? [],
existingTrigger,
insertedStep: enrichedNewStep,
parentStepId,
@@ -82,6 +85,10 @@ export class WorkflowVersionStepWorkspaceService {
parentStepConnectionOptions,
});
if (isDefined(additionalCreatedSteps)) {
updatedSteps.push(...additionalCreatedSteps);
}
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
@@ -95,9 +102,10 @@ export class WorkflowVersionStepWorkspaceService {
});
return computeWorkflowVersionStepChanges({
createdStep: updatedInsertedStep,
trigger: updatedTrigger,
steps: updatedSteps,
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
});
}
@@ -109,7 +117,7 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId: string;
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowAction> {
}): Promise<WorkflowActionDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
@@ -252,9 +260,10 @@ export class WorkflowVersionStepWorkspaceService {
);
return computeWorkflowVersionStepChanges({
steps: updatedSteps,
trigger: updatedTrigger,
deletedStepIds: removedStepIds,
existingTrigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
@@ -294,7 +303,7 @@ export class WorkflowVersionStepWorkspaceService {
},
);
const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
@@ -313,9 +322,10 @@ export class WorkflowVersionStepWorkspaceService {
});
return computeWorkflowVersionStepChanges({
createdStep: updatedInsertedStep,
trigger: updatedTrigger,
steps: updatedSteps,
existingTrigger: workflowVersion.trigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
@@ -350,7 +360,7 @@ export class WorkflowVersionStepWorkspaceService {
},
);
const defaultStep =
const { builtStep } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: newStep.type,
@@ -362,7 +372,7 @@ export class WorkflowVersionStepWorkspaceService {
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: {
...defaultStep,
...builtStep,
id: existingStep.id,
nextStepIds: existingStep.nextStepIds,
position: existingStep.position,