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
@@ -6,7 +6,15 @@ import { useCreateStep } from '../useCreateStep';
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: {
createWorkflowVersionStep: { createdStep: { id: '1', type: 'CODE' } },
createWorkflowVersionStep: {
stepsDiff: [
{
type: 'CREATE',
path: ['steps', 0],
value: { id: 'step-id', type: 'CODE' },
},
],
},
},
});
@@ -25,6 +33,8 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
}),
}));
jest.mock('uuid', () => ({ v4: () => 'step-id' }));
const wrapper = ({ children }: { children: React.ReactNode }) => {
const workflowVisualizerComponentInstanceId =
'workflow-visualizer-instance-id';
@@ -65,6 +75,7 @@ describe('useCreateStep', () => {
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
expect(mockCreateWorkflowVersionStep).toHaveBeenCalledWith({
id: 'step-id',
workflowVersionId: mockWorkflowVersionId,
stepType: 'CODE',
parentStepId: 'parent-step-id',
@@ -41,11 +41,18 @@ describe('useDeleteStep', () => {
it('should delete step and clean up dependencies', async () => {
const mockWorkflowVersionId = 'version-123';
const mockStepId = 'step-1';
const mockDeletedStepIds = ['step-1', 'step-2'];
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
mockDeleteWorkflowVersionStep.mockResolvedValue({
deletedStepIds: mockDeletedStepIds,
deletedStepIds: {
stepsDiff: [
{
type: 'DELETE',
path: ['steps', 0],
value: mockStepId,
},
],
},
});
const { result } = renderHook(() => useDeleteStep());
@@ -58,7 +65,7 @@ describe('useDeleteStep', () => {
});
expect(mockCloseCommandMenu).toHaveBeenCalled();
expect(mockDeleteStepsOutputSchema).toHaveBeenCalledWith({
stepIds: mockDeletedStepIds,
stepIds: [mockStepId],
workflowVersionId: mockWorkflowVersionId,
});
});
@@ -6,7 +6,15 @@ import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-di
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockDuplicateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: {
duplicateWorkflowVersionStep: { createdStep: { id: '2', type: 'CODE' } },
duplicateWorkflowVersionStep: {
stepsDiff: [
{
type: 'CREATE',
path: ['steps', 0],
value: { id: '2', type: 'CODE' },
},
],
},
},
});
@@ -5,8 +5,15 @@ import { type WorkflowStepType } from '@/workflow/types/Workflow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import {
type Difference,
type DifferenceChange,
type DifferenceCreate,
} from 'microdiff';
import { useState } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const useCreateStep = () => {
const [isLoading, setIsLoading] = useState(false);
@@ -42,9 +49,11 @@ export const useCreateStep = () => {
try {
const workflowVersionId = await getUpdatableWorkflowVersion();
const id = v4();
const workflowVersionStepChanges = (
await createWorkflowVersionStep({
id,
workflowVersionId,
stepType: newStepType,
parentStepId,
@@ -54,16 +63,26 @@ export const useCreateStep = () => {
})
)?.data?.createWorkflowVersionStep;
const createdStep = workflowVersionStepChanges?.createdStep;
const stepsDiff = workflowVersionStepChanges?.stepsDiff as Difference[];
if (!isDefined(createdStep)) {
const addedStepDiff = stepsDiff?.find(
(diff) => diff.type === 'CREATE' && diff.value.id === id,
) as Nullable<DifferenceCreate>;
const createdFirstStepDiff = stepsDiff?.find(
(diff) => diff.type === 'CHANGE' && diff.value?.[0]?.id === id,
) as Nullable<DifferenceChange>;
if (!isDefined(createdFirstStepDiff) && !isDefined(addedStepDiff)) {
throw new Error("Couldn't create step");
}
setWorkflowSelectedNode(createdStep.id);
setWorkflowLastCreatedStepId(createdStep.id);
setWorkflowSelectedNode(id);
setWorkflowLastCreatedStepId(id);
return createdStep;
return isDefined(createdFirstStepDiff)
? createdFirstStepDiff.value[0]
: addedStepDiff?.value;
} finally {
setIsLoading(false);
}
@@ -14,7 +14,7 @@ export const useDeleteStep = () => {
const deleteStep = async (stepId: string) => {
const workflowVersionId = await getUpdatableWorkflowVersion();
const workflowVersionStepChanges = await deleteWorkflowVersionStep({
await deleteWorkflowVersionStep({
workflowVersionId,
stepId,
});
@@ -22,7 +22,7 @@ export const useDeleteStep = () => {
closeCommandMenu();
deleteStepsOutputSchema({
stepIds: workflowVersionStepChanges?.deletedStepIds ?? [],
stepIds: [stepId],
workflowVersionId,
});
};
@@ -3,6 +3,7 @@ import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUp
import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useDuplicateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep';
import { type Difference } from 'microdiff';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -36,16 +37,17 @@ export const useDuplicateStep = () => {
})
)?.data?.duplicateWorkflowVersionStep;
const createdStep = workflowVersionStepChanges?.createdStep;
const stepsDiff = workflowVersionStepChanges?.stepsDiff as Difference[];
const createdStepDiff = stepsDiff?.find((diff) => diff.type === 'CREATE');
if (!isDefined(createdStep)) {
if (!isDefined(createdStepDiff)) {
throw new Error("Couldn't duplicate step");
}
setWorkflowSelectedNode(createdStep.id);
setWorkflowLastCreatedStepId(createdStep.id);
setWorkflowSelectedNode(createdStepDiff.value.id);
setWorkflowLastCreatedStepId(createdStepDiff.value.id);
return createdStep;
return createdStepDiff.value;
} finally {
setIsLoading(false);
}
@@ -5,11 +5,8 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import {
type WorkflowAction,
type WorkflowVersion,
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-shared/utils';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { applyDiff, isDefined } from 'twenty-shared/utils';
import { type WorkflowVersionStepChanges } from '~/generated/graphql';
export const useUpdateWorkflowVersionCache = () => {
@@ -44,47 +41,14 @@ export const useUpdateWorkflowVersionCache = () => {
return;
}
const {
triggerNextStepIds,
stepsNextStepIds,
createdStep,
deletedStepIds,
} = workflowVersionStepChanges;
const { triggerDiff, stepsDiff } = workflowVersionStepChanges;
const newCachedRecord = {
...cachedRecord,
trigger: isDefined(cachedRecord.trigger)
? {
...cachedRecord.trigger,
nextStepIds: triggerNextStepIds,
}
: cachedRecord.trigger,
steps: (cachedRecord.steps || []).map((step: WorkflowAction) => ({
...step,
nextStepIds: stepsNextStepIds[step.id] ?? step.nextStepIds,
})),
} satisfies WorkflowVersion;
if (isDefined(createdStep)) {
const formattedCreatedStep = {
...(createdStep as WorkflowAction),
nextStepIds: createdStep.nextStepIds || [],
};
newCachedRecord.steps.push(formattedCreatedStep);
}
if (isDefined(deletedStepIds) && deletedStepIds.length > 0) {
newCachedRecord.steps = newCachedRecord.steps.filter(
(step: WorkflowAction) => !deletedStepIds.includes(step.id),
);
const hasDeletedTrigger: boolean = deletedStepIds.includes('trigger');
if (hasDeletedTrigger) {
newCachedRecord.trigger = null;
}
}
steps: applyDiff({ steps: cachedRecord.steps }, stepsDiff).steps,
trigger: applyDiff({ trigger: cachedRecord.trigger }, triggerDiff)
.trigger,
} as WorkflowVersion;
const recordGqlFields = {
steps: true,