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 };
};