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:
@@ -873,6 +873,8 @@ export type CreateWorkflowVersionEdgeInput = {
|
||||
};
|
||||
|
||||
export type CreateWorkflowVersionStepInput = {
|
||||
/** Step ID */
|
||||
id?: InputMaybe<Scalars['String']>;
|
||||
/** Next step ID */
|
||||
nextStepId?: InputMaybe<Scalars['UUID']>;
|
||||
/** Parent step connection options */
|
||||
@@ -4033,10 +4035,8 @@ export type WorkflowVersion = {
|
||||
|
||||
export type WorkflowVersionStepChanges = {
|
||||
__typename?: 'WorkflowVersionStepChanges';
|
||||
createdStep?: Maybe<WorkflowAction>;
|
||||
deletedStepIds?: Maybe<Array<Scalars['String']>>;
|
||||
stepsNextStepIds?: Maybe<Scalars['JSON']>;
|
||||
triggerNextStepIds?: Maybe<Array<Scalars['String']>>;
|
||||
stepsDiff?: Maybe<Scalars['JSON']>;
|
||||
triggerDiff?: Maybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
@@ -5411,6 +5411,8 @@ export type FindOneCoreViewSortQueryVariables = Exact<{
|
||||
|
||||
export type FindOneCoreViewSortQuery = { __typename?: 'Query', getCoreViewSort?: { __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string } | null };
|
||||
|
||||
export type WorkflowDiffFragmentFragment = { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null };
|
||||
|
||||
export type ActivateWorkflowVersionMutationVariables = Exact<{
|
||||
workflowVersionId: Scalars['UUID'];
|
||||
}>;
|
||||
@@ -5437,14 +5439,14 @@ export type CreateWorkflowVersionEdgeMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null } };
|
||||
export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
|
||||
|
||||
export type CreateWorkflowVersionStepMutationVariables = Exact<{
|
||||
input: CreateWorkflowVersionStepInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<string> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } };
|
||||
export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
|
||||
|
||||
export type DeactivateWorkflowVersionMutationVariables = Exact<{
|
||||
workflowVersionId: Scalars['UUID'];
|
||||
@@ -5458,21 +5460,21 @@ export type DeleteWorkflowVersionEdgeMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null } };
|
||||
export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
|
||||
|
||||
export type DeleteWorkflowVersionStepMutationVariables = Exact<{
|
||||
input: DeleteWorkflowVersionStepInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, deletedStepIds?: Array<string> | null } };
|
||||
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
|
||||
|
||||
export type DuplicateWorkflowVersionStepMutationVariables = Exact<{
|
||||
input: DuplicateWorkflowVersionStepInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<string> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } };
|
||||
export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
|
||||
|
||||
export type RunWorkflowVersionMutationVariables = Exact<{
|
||||
input: RunWorkflowVersionInput;
|
||||
@@ -6062,6 +6064,12 @@ ${BillingSubscriptionFragmentFragmentDoc}
|
||||
${RoleFragmentFragmentDoc}
|
||||
${ViewFragmentFragmentDoc}
|
||||
${AvailableWorkspacesFragmentFragmentDoc}`;
|
||||
export const WorkflowDiffFragmentFragmentDoc = gql`
|
||||
fragment WorkflowDiffFragment on WorkflowVersionStepChanges {
|
||||
triggerDiff
|
||||
stepsDiff
|
||||
}
|
||||
`;
|
||||
export const AssignRoleToAgentDocument = gql`
|
||||
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
|
||||
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
|
||||
@@ -12394,11 +12402,10 @@ export type CreateDraftFromWorkflowVersionMutationOptions = Apollo.BaseMutationO
|
||||
export const CreateWorkflowVersionEdgeDocument = gql`
|
||||
mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
|
||||
createWorkflowVersionEdge(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
${WorkflowDiffFragmentFragmentDoc}`;
|
||||
export type CreateWorkflowVersionEdgeMutationFn = Apollo.MutationFunction<CreateWorkflowVersionEdgeMutation, CreateWorkflowVersionEdgeMutationVariables>;
|
||||
|
||||
/**
|
||||
@@ -12428,23 +12435,10 @@ export type CreateWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOption
|
||||
export const CreateWorkflowVersionStepDocument = gql`
|
||||
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
createdStep {
|
||||
id
|
||||
name
|
||||
type
|
||||
settings
|
||||
valid
|
||||
nextStepIds
|
||||
position {
|
||||
x
|
||||
y
|
||||
}
|
||||
}
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
${WorkflowDiffFragmentFragmentDoc}`;
|
||||
export type CreateWorkflowVersionStepMutationFn = Apollo.MutationFunction<CreateWorkflowVersionStepMutation, CreateWorkflowVersionStepMutationVariables>;
|
||||
|
||||
/**
|
||||
@@ -12505,11 +12499,10 @@ export type DeactivateWorkflowVersionMutationOptions = Apollo.BaseMutationOption
|
||||
export const DeleteWorkflowVersionEdgeDocument = gql`
|
||||
mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
|
||||
deleteWorkflowVersionEdge(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
${WorkflowDiffFragmentFragmentDoc}`;
|
||||
export type DeleteWorkflowVersionEdgeMutationFn = Apollo.MutationFunction<DeleteWorkflowVersionEdgeMutation, DeleteWorkflowVersionEdgeMutationVariables>;
|
||||
|
||||
/**
|
||||
@@ -12539,12 +12532,10 @@ export type DeleteWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOption
|
||||
export const DeleteWorkflowVersionStepDocument = gql`
|
||||
mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
|
||||
deleteWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
deletedStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
${WorkflowDiffFragmentFragmentDoc}`;
|
||||
export type DeleteWorkflowVersionStepMutationFn = Apollo.MutationFunction<DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables>;
|
||||
|
||||
/**
|
||||
@@ -12574,23 +12565,10 @@ export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOption
|
||||
export const DuplicateWorkflowVersionStepDocument = gql`
|
||||
mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) {
|
||||
duplicateWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
createdStep {
|
||||
id
|
||||
name
|
||||
type
|
||||
settings
|
||||
valid
|
||||
nextStepIds
|
||||
position {
|
||||
x
|
||||
y
|
||||
}
|
||||
}
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
${WorkflowDiffFragmentFragmentDoc}`;
|
||||
export type DuplicateWorkflowVersionStepMutationFn = Apollo.MutationFunction<DuplicateWorkflowVersionStepMutation, DuplicateWorkflowVersionStepMutationVariables>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -837,6 +837,8 @@ export type CreateWorkflowVersionEdgeInput = {
|
||||
};
|
||||
|
||||
export type CreateWorkflowVersionStepInput = {
|
||||
/** Step ID */
|
||||
id?: InputMaybe<Scalars['String']>;
|
||||
/** Next step ID */
|
||||
nextStepId?: InputMaybe<Scalars['UUID']>;
|
||||
/** Parent step connection options */
|
||||
@@ -3861,10 +3863,8 @@ export type WorkflowVersion = {
|
||||
|
||||
export type WorkflowVersionStepChanges = {
|
||||
__typename?: 'WorkflowVersionStepChanges';
|
||||
createdStep?: Maybe<WorkflowAction>;
|
||||
deletedStepIds?: Maybe<Array<Scalars['String']>>;
|
||||
stepsNextStepIds?: Maybe<Scalars['JSON']>;
|
||||
triggerNextStepIds?: Maybe<Array<Scalars['String']>>;
|
||||
stepsDiff?: Maybe<Scalars['JSON']>;
|
||||
triggerDiff?: Maybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const WORKFLOW_DIFF_FRAGMENT = gql`
|
||||
fragment WorkflowDiffFragment on WorkflowVersionStepChanges {
|
||||
triggerDiff
|
||||
stepsDiff
|
||||
}
|
||||
`;
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_WORKFLOW_VERSION_EDGE = gql`
|
||||
mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
|
||||
createWorkflowVersionEdge(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
|
||||
${WORKFLOW_DIFF_FRAGMENT}
|
||||
`;
|
||||
|
||||
+4
-14
@@ -1,22 +1,12 @@
|
||||
import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_WORKFLOW_VERSION_STEP = gql`
|
||||
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
createdStep {
|
||||
id
|
||||
name
|
||||
type
|
||||
settings
|
||||
valid
|
||||
nextStepIds
|
||||
position {
|
||||
x
|
||||
y
|
||||
}
|
||||
}
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
|
||||
${WORKFLOW_DIFF_FRAGMENT}
|
||||
`;
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_WORKFLOW_VERSION_EDGE = gql`
|
||||
mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
|
||||
deleteWorkflowVersionEdge(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
|
||||
${WORKFLOW_DIFF_FRAGMENT}
|
||||
`;
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_WORKFLOW_VERSION_STEP = gql`
|
||||
mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
|
||||
deleteWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
deletedStepIds
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
|
||||
${WORKFLOW_DIFF_FRAGMENT}
|
||||
`;
|
||||
|
||||
+4
-14
@@ -1,3 +1,4 @@
|
||||
import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DUPLICATE_WORKFLOW_VERSION_STEP = gql`
|
||||
@@ -5,20 +6,9 @@ export const DUPLICATE_WORKFLOW_VERSION_STEP = gql`
|
||||
$input: DuplicateWorkflowVersionStepInput!
|
||||
) {
|
||||
duplicateWorkflowVersionStep(input: $input) {
|
||||
triggerNextStepIds
|
||||
stepsNextStepIds
|
||||
createdStep {
|
||||
id
|
||||
name
|
||||
type
|
||||
settings
|
||||
valid
|
||||
nextStepIds
|
||||
position {
|
||||
x
|
||||
y
|
||||
}
|
||||
}
|
||||
...WorkflowDiffFragment
|
||||
}
|
||||
}
|
||||
|
||||
${WORKFLOW_DIFF_FRAGMENT}
|
||||
`;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const getStepDefinitionOrThrow = ({
|
||||
|
||||
const selectedNodePosition = findStepPosition({
|
||||
steps,
|
||||
stepId: stepId,
|
||||
stepId,
|
||||
});
|
||||
if (!isDefined(selectedNodePosition)) {
|
||||
return undefined;
|
||||
|
||||
+12
-1
@@ -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',
|
||||
|
||||
+10
-3
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
|
||||
+7
-43
@@ -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,
|
||||
|
||||
+6
@@ -45,4 +45,10 @@ export class CreateWorkflowVersionStepInput {
|
||||
nullable: true,
|
||||
})
|
||||
position?: WorkflowStepPositionInput;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Step ID',
|
||||
nullable: true,
|
||||
})
|
||||
id?: string;
|
||||
}
|
||||
|
||||
+6
-13
@@ -1,20 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { Difference } from 'microdiff';
|
||||
|
||||
@ObjectType('WorkflowVersionStepChanges')
|
||||
export class WorkflowVersionStepChangesDTO {
|
||||
@Field(() => [String], { nullable: true })
|
||||
triggerNextStepIds?: string[];
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
triggerDiff?: Difference[];
|
||||
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
stepsNextStepIds?: Record<string, string[] | undefined>;
|
||||
|
||||
@Field(() => WorkflowActionDTO, { nullable: true })
|
||||
createdStep?: WorkflowActionDTO;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
deletedStepIds?: string[];
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
stepsDiff?: Difference[];
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workf
|
||||
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
|
||||
import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto';
|
||||
import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto';
|
||||
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
|
||||
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
|
||||
import { WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
|
||||
import { WorkflowVersionStepGraphqlApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-version-step-graphql-api-exception.filter';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
+183
-16
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-18
@@ -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 };
|
||||
};
|
||||
|
||||
+103
-90
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+22
-18
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+133
-72
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+131
-105
@@ -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:
|
||||
|
||||
+42
-32
@@ -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,
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import { type Difference } from 'microdiff';
|
||||
|
||||
import { applyDiff } from '@/utils/applyDiff';
|
||||
|
||||
describe('applyDiff', () => {
|
||||
describe('input validation', () => {
|
||||
it('should throw error for non-array diffs', () => {
|
||||
const obj = { test: 'value' };
|
||||
|
||||
expect(() => applyDiff(obj, null as any)).toThrow(
|
||||
'Diffs must be an array'
|
||||
);
|
||||
expect(() => applyDiff(obj, 'invalid' as any)).toThrow(
|
||||
'Diffs must be an array'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty diffs array', () => {
|
||||
const obj = { test: 'value' };
|
||||
const result = applyDiff(obj, []);
|
||||
|
||||
expect(result).toEqual({ test: 'value' });
|
||||
expect(result).not.toBe(obj); // Should return a copy
|
||||
});
|
||||
|
||||
it('should skip invalid diffs', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
null as any,
|
||||
{ type: 'CREATE', path: [], value: 'test' } as any,
|
||||
{ type: 'CREATE', path: ['test'], value: 'updated' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ test: 'updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CREATE operations', () => {
|
||||
it('should create new properties in objects', () => {
|
||||
const obj = { existing: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
existing: 'value',
|
||||
newProp: 'newValue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create nested properties', () => {
|
||||
const obj = { level1: { existing: 'value' } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1', 'newProp'], value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
existing: 'value',
|
||||
newProp: 'newValue',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create array elements', () => {
|
||||
const obj = ['existing'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: [1], value: 'newElement' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['existing', 'newElement']);
|
||||
});
|
||||
|
||||
it('should create deeply nested structures with existing intermediate objects', () => {
|
||||
const obj = { level1: { level2: {} } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
prop: 'deep',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should build deeply nested structures with multiple CREATE operations', () => {
|
||||
const obj = {};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1'], value: {} },
|
||||
{ type: 'CREATE', path: ['level1', 'level2'], value: {} },
|
||||
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
prop: 'deep',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE operations', () => {
|
||||
it('should change existing properties', () => {
|
||||
const obj = { prop: 'oldValue' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'oldValue', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ prop: 'newValue' });
|
||||
});
|
||||
|
||||
it('should change nested properties', () => {
|
||||
const obj = { level1: { prop: 'oldValue' } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['level1', 'prop'], oldValue: 'oldValue', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: { prop: 'newValue' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should change array elements', () => {
|
||||
const obj = ['old', 'values'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: [0], oldValue: 'old', value: 'new' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['new', 'values']);
|
||||
});
|
||||
|
||||
it('should change complex nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['users', 0, 'name'], oldValue: 'John', value: 'Johnny' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
users: [
|
||||
{ id: 1, name: 'Johnny' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('REMOVE operations', () => {
|
||||
it('should remove properties from objects', () => {
|
||||
const obj = { keep: 'value', remove: 'toDelete' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ keep: 'value' });
|
||||
});
|
||||
|
||||
it('should remove nested properties', () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
keep: 'value',
|
||||
remove: 'toDelete',
|
||||
},
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['level1', 'remove'], oldValue: 'toDelete' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: { keep: 'value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove single array element', () => {
|
||||
const obj = ['keep1', 'remove', 'keep2'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'remove' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['keep1', 'keep2']);
|
||||
});
|
||||
|
||||
it('should remove multiple array elements in correct order', () => {
|
||||
const obj = ['a', 'b', 'c', 'd', 'e'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['a', 'c', 'e']);
|
||||
});
|
||||
|
||||
it('should handle complex array removal scenarios', () => {
|
||||
const obj = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [0], oldValue: 'a' }, // Remove 'a'
|
||||
{ type: 'REMOVE', path: [2], oldValue: 'c' }, // Remove 'c'
|
||||
{ type: 'REMOVE', path: [4], oldValue: 'e' }, // Remove 'e'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['b', 'd', 'f']);
|
||||
});
|
||||
|
||||
it('should remove from nested arrays', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ id: 1, tags: ['tag1', 'tag2', 'tag3'] },
|
||||
{ id: 2, tags: ['tag4', 'tag5'] },
|
||||
],
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['items', 0, 'tags', 1], oldValue: 'tag2' }, // Remove 'tag2'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 1, tags: ['tag1', 'tag3'] },
|
||||
{ id: 2, tags: ['tag4', 'tag5'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for non-numeric array index', () => {
|
||||
const obj = ['a', 'b', 'c'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['invalid'], oldValue: 'invalid' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Expected numeric index for array removal, got string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed operations', () => {
|
||||
it('should apply multiple different operations', () => {
|
||||
const obj = {
|
||||
keep: 'value',
|
||||
change: 'oldValue',
|
||||
remove: 'toDelete',
|
||||
nested: {
|
||||
array: ['a', 'b', 'c'],
|
||||
},
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
|
||||
{ type: 'CHANGE', path: ['change'], oldValue: 'oldValue', value: 'newValue' },
|
||||
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
|
||||
{ type: 'REMOVE', path: ['nested', 'array', 1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'CREATE', path: ['nested', 'newArray'], value: [1, 2, 3] },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
keep: 'value',
|
||||
change: 'newValue',
|
||||
newProp: 'newValue',
|
||||
nested: {
|
||||
array: ['a', 'c'],
|
||||
newArray: [1, 2, 3],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle operations on the same array', () => {
|
||||
const obj = ['a', 'b', 'c', 'd'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: [0], oldValue: 'a', value: 'A' }, // Change 'a' to 'A'
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['A', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for invalid diff type', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'INVALID' as any, path: ['test'], value: 'newValue' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Unsupported diff type: INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error with path information for invalid operations', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['nonExistent', 'deep', 'path'], oldValue: 'value', value: 'value' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Failed to apply diff at path nonExistent.deep.path'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FORBIDDEN_OBJECT_KEYS protection', () => {
|
||||
it('should throw error when trying to CREATE forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['__proto__'], value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
"Refusing to set forbidden property key '__proto__' on object (prototype pollution protection)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent Unicode escape bypasses of forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const unicodeBypassAttempts = [
|
||||
// __proto__ with Unicode escapes
|
||||
'__\u0070roto__', // \u0070 = 'p'
|
||||
'__\u{70}roto__', // ES6 syntax
|
||||
'__pr\u006fto__', // \u006f = 'o'
|
||||
'__proto\u005f\u005f', // \u005f = '_'
|
||||
|
||||
// constructor with Unicode escapes
|
||||
'construc\u0074or', // \u0074 = 't'
|
||||
'constr\u0075ctor', // \u0075 = 'u'
|
||||
'\u0063onstructor', // \u0063 = 'c'
|
||||
|
||||
// prototype with Unicode escapes
|
||||
'proto\u0074ype', // \u0074 = 't'
|
||||
'prototy\u0070e', // \u0070 = 'p'
|
||||
'\u0070rototype', // \u0070 = 'p'
|
||||
];
|
||||
|
||||
unicodeBypassAttempts.forEach((maliciousKey) => {
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: [maliciousKey], value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
new RegExp(`Refusing to set forbidden property key.*prototype pollution protection`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when trying to CHANGE forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['constructor'], oldValue: 'old', value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
"Refusing to set forbidden property key 'constructor' on object (prototype pollution protection)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should silently skip removal of forbidden property keys', () => {
|
||||
const obj = {
|
||||
safe: 'value',
|
||||
normalProp: 'normal',
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Try to remove forbidden keys (these should be silently skipped)
|
||||
{ type: 'REMOVE', path: ['__proto__'], oldValue: 'anything' },
|
||||
{ type: 'REMOVE', path: ['constructor'], oldValue: 'anything' },
|
||||
{ type: 'REMOVE', path: ['prototype'], oldValue: 'anything' },
|
||||
// Remove a normal property (this should work)
|
||||
{ type: 'REMOVE', path: ['safe'], oldValue: 'value' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
|
||||
// Only the safe property should be removed, normalProp should remain
|
||||
expect(result).toEqual({
|
||||
normalProp: 'normal',
|
||||
});
|
||||
|
||||
// Verify safe property was actually removed
|
||||
expect(result).not.toHaveProperty('safe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('should not modify the original object', () => {
|
||||
const obj = { prop: 'value', nested: { deep: 'value' } };
|
||||
const originalObj = JSON.parse(JSON.stringify(obj));
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
|
||||
{ type: 'CHANGE', path: ['nested', 'deep'], oldValue: 'value', value: 'newDeepValue' },
|
||||
];
|
||||
|
||||
applyDiff(obj, diffs);
|
||||
|
||||
expect(obj).toEqual(originalObj);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const obj = ['a', 'b', 'c'];
|
||||
const originalObj = [...obj];
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' },
|
||||
];
|
||||
|
||||
applyDiff(obj, diffs);
|
||||
|
||||
expect(obj).toEqual(originalObj);
|
||||
});
|
||||
|
||||
it('should handle frozen objects', () => {
|
||||
const obj = Object.freeze({ prop: 'value' });
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ prop: 'newValue' });
|
||||
expect(obj.prop).toBe('value'); // Original unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle workflow-like data structures', () => {
|
||||
const obj = {
|
||||
trigger: {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: { table: 'users' },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'CODE',
|
||||
settings: { code: 'console.log("hello")' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'EMAIL',
|
||||
settings: { to: 'test@example.com' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Update trigger settings
|
||||
{ type: 'CHANGE', path: ['trigger', 'settings', 'table'], oldValue: 'users', value: 'contacts' },
|
||||
// Remove first step
|
||||
{ type: 'REMOVE', path: ['steps', 0], oldValue: '1' },
|
||||
// Update remaining step
|
||||
{ type: 'CHANGE', path: ['steps', 1, 'settings', 'to'], oldValue: 'test@example.com', value: 'new@example.com' },
|
||||
// Add new step
|
||||
{ type: 'CREATE', path: ['steps', 2], value: {
|
||||
id: '3',
|
||||
type: 'WEBHOOK',
|
||||
settings: { url: 'https://api.example.com/webhook' },
|
||||
}},
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
trigger: {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: { table: 'contacts' },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: '2',
|
||||
type: 'EMAIL',
|
||||
settings: { to: 'new@example.com' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'WEBHOOK',
|
||||
settings: { url: 'https://api.example.com/webhook' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array with multiple removals and additions', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
{ id: 3, name: 'Item 3' },
|
||||
{ id: 4, name: 'Item 4' },
|
||||
{ id: 5, name: 'Item 5' },
|
||||
],
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Remove items at indices 1 and 3 (Item 2 and Item 4)
|
||||
{ type: 'REMOVE', path: ['items', 1], oldValue: 'Item 2' },
|
||||
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
|
||||
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
|
||||
// Update remaining item
|
||||
{ type: 'CHANGE', path: ['items', 0, 'name'], oldValue: 'Item 1', value: 'Updated Item 1' },
|
||||
// Add new item
|
||||
{ type: 'CREATE', path: ['items', 5], value: { id: 6, name: 'New Item' } },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 1, name: 'Updated Item 1' },
|
||||
{ id: 3, name: 'Item 3' },
|
||||
{ id: 5, name: 'Item 5' },
|
||||
{ id: 6, name: 'New Item' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { isNumber, isObject, isString } from '@sniptt/guards';
|
||||
import { type Difference } from 'microdiff';
|
||||
|
||||
type ObjectType = Record<string, unknown>;
|
||||
type ArrayType = unknown[];
|
||||
type MutableData = ObjectType | ArrayType;
|
||||
|
||||
type ArrayDeletionFn = () => void;
|
||||
|
||||
// Symbol used to mark array elements for deletion
|
||||
const REMOVE_SYMBOL = Symbol('micropatch-delete');
|
||||
|
||||
const FORBIDDEN_OBJECT_KEYS = ['__proto__', 'constructor', 'prototype'];
|
||||
|
||||
export const applyDiff = <T>(obj: T, diffs: Difference[]): T => {
|
||||
if (!isDefined(obj)) {
|
||||
throw new Error('Cannot apply diff to null or undefined object');
|
||||
}
|
||||
|
||||
if (!Array.isArray(diffs)) {
|
||||
throw new Error('Diffs must be an array');
|
||||
}
|
||||
|
||||
// Create a deep copy to avoid modifying read-only/frozen objects
|
||||
const mutableObj = deepClone(obj) as T;
|
||||
const arrayDeletionQueue: ArrayDeletionFn[] = [];
|
||||
|
||||
for (const diff of diffs) {
|
||||
if (!diff || !diff.path || diff.path.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
applyDiffToPath(mutableObj as MutableData, diff, arrayDeletionQueue);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to apply diff at path ${diff.path.join('.')}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
arrayDeletionQueue.forEach((deletionFn) => deletionFn());
|
||||
|
||||
return mutableObj;
|
||||
};
|
||||
|
||||
const applyDiffToPath = (
|
||||
obj: MutableData,
|
||||
diff: Difference,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
) => {
|
||||
const { path, type } = diff;
|
||||
const value = 'value' in diff ? diff.value : undefined;
|
||||
const pathLength = path.length;
|
||||
const lastPathElement = path[pathLength - 1];
|
||||
|
||||
const parentContainer = navigateToParent(obj, path);
|
||||
|
||||
switch (type) {
|
||||
case 'CREATE':
|
||||
case 'CHANGE':
|
||||
setValueAtPath(parentContainer, lastPathElement, value);
|
||||
break;
|
||||
|
||||
case 'REMOVE':
|
||||
handleRemoval(obj, path, parentContainer, lastPathElement, arrayDeletionQueue);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported diff type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToParent = (obj: MutableData, path: (string | number)[]): MutableData => {
|
||||
let current = obj;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const pathElement = path[i];
|
||||
|
||||
if (current === null || current === undefined) {
|
||||
throw new Error(`Cannot traverse path: found null/undefined at element ${i}`);
|
||||
}
|
||||
|
||||
if (isNumber(pathElement) && !Array.isArray(current)) {
|
||||
throw new Error(`Expected array at path element ${i}, got ${typeof current}`);
|
||||
}
|
||||
|
||||
if (isString(pathElement) && Array.isArray(current)) {
|
||||
throw new Error(`Expected object at path element ${i}, got array`);
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
current = current[pathElement as number] as MutableData;
|
||||
} else {
|
||||
current = (current as ObjectType)[pathElement as string] as MutableData;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const setValueAtPath = (
|
||||
container: MutableData,
|
||||
pathElement: string | number,
|
||||
value: unknown
|
||||
): void => {
|
||||
if (Array.isArray(container)) {
|
||||
if (!isNumber(pathElement)) {
|
||||
throw new Error(`Expected numeric index for array, got ${typeof pathElement}`);
|
||||
}
|
||||
|
||||
try {
|
||||
container[pathElement] = value;
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`);
|
||||
}
|
||||
} else if (isObject(container)) {
|
||||
if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) {
|
||||
throw new Error(`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`);
|
||||
}
|
||||
|
||||
try {
|
||||
container[pathElement] = value;
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected object or array, got ${typeof container}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoval = (
|
||||
rootObj: MutableData,
|
||||
fullPath: (string | number)[],
|
||||
parentContainer: MutableData,
|
||||
lastPathElement: string | number,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
): void => {
|
||||
if (Array.isArray(parentContainer)) {
|
||||
handleArrayRemoval(rootObj, fullPath, parentContainer, lastPathElement, arrayDeletionQueue);
|
||||
} else {
|
||||
handleObjectRemoval(parentContainer as ObjectType, lastPathElement);
|
||||
}
|
||||
};
|
||||
|
||||
// Handles removal from arrays by marking for deletion and queuing cleanup
|
||||
const handleArrayRemoval = (
|
||||
rootObj: MutableData,
|
||||
fullPath: (string | number)[],
|
||||
parentArray: ArrayType,
|
||||
index: string | number,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
): void => {
|
||||
if (typeof index !== 'number') {
|
||||
throw new Error(`Expected numeric index for array removal, got ${typeof index}`);
|
||||
}
|
||||
|
||||
parentArray[index] = REMOVE_SYMBOL;
|
||||
|
||||
arrayDeletionQueue.push(() => {
|
||||
if (fullPath.length === 1) {
|
||||
if (Array.isArray(rootObj)) {
|
||||
filterRemoveSymbols(rootObj);
|
||||
}
|
||||
} else {
|
||||
filterRemoveSymbols(parentArray);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleObjectRemoval = (
|
||||
parentObject: ObjectType,
|
||||
key: string | number
|
||||
): void => {
|
||||
if (FORBIDDEN_OBJECT_KEYS.includes(key as string)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete parentObject[key];
|
||||
};
|
||||
|
||||
const filterRemoveSymbols = (array: ArrayType): void => {
|
||||
const indicesToRemove: number[] = [];
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (array[i] === REMOVE_SYMBOL) {
|
||||
indicesToRemove.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
|
||||
array.splice(indicesToRemove[i], 1);
|
||||
}
|
||||
};
|
||||
|
||||
const deepClone = (obj: MutableData): MutableData => {
|
||||
if (obj === null || !isObject(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof structuredClone !== 'undefined') {
|
||||
try {
|
||||
return structuredClone(obj) as MutableData;
|
||||
} catch {
|
||||
return deepCloneJson(obj);
|
||||
}
|
||||
}
|
||||
|
||||
return deepCloneJson(obj);
|
||||
};
|
||||
|
||||
const deepCloneJson = (obj: MutableData): MutableData => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj)) as MutableData;
|
||||
} catch {
|
||||
throw new Error('Failed to clone object');
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { applyDiff } from './applyDiff';
|
||||
export { filterOutByProperty } from './array/filterOutByProperty';
|
||||
export { findById } from './array/findById';
|
||||
export { findByProperty } from './array/findByProperty';
|
||||
|
||||
Reference in New Issue
Block a user