Implement branch front end (#13489)

First PR to support workflow branches on frontend
This commit is contained in:
martmull
2025-08-04 16:11:46 +02:00
committed by GitHub
parent 2c87c6fc1b
commit 9a05673093
124 changed files with 3735 additions and 938 deletions
@@ -608,11 +608,22 @@ export type CreateWebhookDto = {
targetUrl: Scalars['String'];
};
export type CreateWorkflowVersionEdgeInput = {
/** Workflow version source step ID */
source: Scalars['String'];
/** Workflow version target step ID */
target: Scalars['String'];
/** Workflow version ID */
workflowVersionId: Scalars['String'];
};
export type CreateWorkflowVersionStepInput = {
/** Next step ID */
nextStepId?: InputMaybe<Scalars['UUID']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['UUID']>;
parentStepId?: InputMaybe<Scalars['String']>;
/** Step position */
position?: InputMaybe<WorkflowStepPositionInput>;
/** New step type */
stepType: Scalars['String'];
/** Workflow version ID */
@@ -786,6 +797,7 @@ export enum FeatureFlagKey {
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED'
@@ -1171,7 +1183,8 @@ export type Mutation = {
createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionStep: WorkflowAction;
createWorkflowVersionEdge: WorkflowVersionStepChanges;
createWorkflowVersionStep: WorkflowVersionStepChanges;
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
@@ -1187,7 +1200,8 @@ export type Mutation = {
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
deleteUser: User;
deleteWebhook: Scalars['Boolean'];
deleteWorkflowVersionStep: WorkflowAction;
deleteWorkflowVersionEdge: WorkflowVersionStepChanges;
deleteWorkflowVersionStep: WorkflowVersionStepChanges;
deleteWorkspaceInvitation: Scalars['String'];
disablePostgresProxy: PostgresCredentials;
editSSOIdentityProvider: EditSsoOutput;
@@ -1241,6 +1255,7 @@ export type Mutation = {
updatePasswordViaResetToken: InvalidatePassword;
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
updateWorkflowVersionStep: WorkflowAction;
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
@@ -1394,6 +1409,11 @@ export type MutationCreateWebhookArgs = {
};
export type MutationCreateWorkflowVersionEdgeArgs = {
input: CreateWorkflowVersionEdgeInput;
};
export type MutationCreateWorkflowVersionStepArgs = {
input: CreateWorkflowVersionStepInput;
};
@@ -1464,6 +1484,11 @@ export type MutationDeleteWebhookArgs = {
};
export type MutationDeleteWorkflowVersionEdgeArgs = {
input: CreateWorkflowVersionEdgeInput;
};
export type MutationDeleteWorkflowVersionStepArgs = {
input: DeleteWorkflowVersionStepInput;
};
@@ -1726,6 +1751,11 @@ export type MutationUpdateWorkflowRunStepArgs = {
};
export type MutationUpdateWorkflowVersionPositionsArgs = {
input: UpdateWorkflowVersionPositionsInput;
};
export type MutationUpdateWorkflowVersionStepArgs = {
input: UpdateWorkflowVersionStepInput;
};
@@ -2860,6 +2890,13 @@ export type UpdateWorkflowRunStepInput = {
workflowRunId: Scalars['UUID'];
};
export type UpdateWorkflowVersionPositionsInput = {
/** Workflow version updated positions */
positions: Array<WorkflowStepPositionUpdateInput>;
/** Workflow version ID */
workflowVersionId: Scalars['UUID'];
};
export type UpdateWorkflowVersionStepInput = {
/** Step to update in JSON format */
step: Scalars['JSON'];
@@ -3034,6 +3071,7 @@ export type WorkflowAction = {
id: Scalars['UUID'];
name: Scalars['String'];
nextStepIds?: Maybe<Array<Scalars['UUID']>>;
position?: Maybe<WorkflowStepPosition>;
settings: Scalars['JSON'];
type: Scalars['String'];
valid: Scalars['Boolean'];
@@ -3044,11 +3082,37 @@ export type WorkflowRun = {
workflowRunId: Scalars['UUID'];
};
export type WorkflowStepPosition = {
__typename?: 'WorkflowStepPosition';
x: Scalars['Float'];
y: Scalars['Float'];
};
export type WorkflowStepPositionInput = {
x: Scalars['Float'];
y: Scalars['Float'];
};
export type WorkflowStepPositionUpdateInput = {
/** Step or trigger ID */
id: Scalars['String'];
/** Position of the step or trigger */
position: WorkflowStepPositionInput;
};
export type WorkflowVersion = {
__typename?: 'WorkflowVersion';
id: Scalars['UUID'];
};
export type WorkflowVersionStepChanges = {
__typename?: 'WorkflowVersionStepChanges';
createdStep?: Maybe<WorkflowAction>;
deletedStepId?: Maybe<Scalars['String']>;
stepsNextStepIds?: Maybe<Scalars['JSON']>;
triggerNextStepIds?: Maybe<Array<Scalars['String']>>;
};
export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
@@ -4086,12 +4150,19 @@ export type CreateDraftFromWorkflowVersionMutationVariables = Exact<{
export type CreateDraftFromWorkflowVersionMutation = { __typename?: 'Mutation', createDraftFromWorkflowVersion: { __typename?: 'WorkflowVersion', id: any } };
export type CreateWorkflowVersionEdgeMutationVariables = Exact<{
input: CreateWorkflowVersionEdgeInput;
}>;
export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null } };
export type CreateWorkflowVersionStepMutationVariables = Exact<{
input: CreateWorkflowVersionStepInput;
}>;
export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } };
export type DeactivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['UUID'];
@@ -4100,12 +4171,19 @@ export type DeactivateWorkflowVersionMutationVariables = Exact<{
export type DeactivateWorkflowVersionMutation = { __typename?: 'Mutation', deactivateWorkflowVersion: boolean };
export type DeleteWorkflowVersionEdgeMutationVariables = Exact<{
input: CreateWorkflowVersionEdgeInput;
}>;
export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null } };
export type DeleteWorkflowVersionStepMutationVariables = Exact<{
input: DeleteWorkflowVersionStepInput;
}>;
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array<string> | null, stepsNextStepIds?: any | null, deletedStepId?: string | null } };
export type RunWorkflowVersionMutationVariables = Exact<{
input: RunWorkflowVersionInput;
@@ -4119,14 +4197,14 @@ export type UpdateWorkflowRunStepMutationVariables = Exact<{
}>;
export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } };
export type UpdateWorkflowVersionStepMutationVariables = Exact<{
input: UpdateWorkflowVersionStepInput;
}>;
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null } };
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array<any> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } };
export type SubmitFormStepMutationVariables = Exact<{
input: SubmitFormStepInput;
@@ -4135,6 +4213,13 @@ export type SubmitFormStepMutationVariables = Exact<{
export type SubmitFormStepMutation = { __typename?: 'Mutation', submitFormStep: boolean };
export type UpdateWorkflowVersionPositionsMutationVariables = Exact<{
input: UpdateWorkflowVersionPositionsInput;
}>;
export type UpdateWorkflowVersionPositionsMutation = { __typename?: 'Mutation', updateWorkflowVersionPositions: boolean };
export type DeleteWorkspaceInvitationMutationVariables = Exact<{
appTokenId: Scalars['String'];
}>;
@@ -9226,15 +9311,57 @@ export function useCreateDraftFromWorkflowVersionMutation(baseOptions?: Apollo.M
export type CreateDraftFromWorkflowVersionMutationHookResult = ReturnType<typeof useCreateDraftFromWorkflowVersionMutation>;
export type CreateDraftFromWorkflowVersionMutationResult = Apollo.MutationResult<CreateDraftFromWorkflowVersionMutation>;
export type CreateDraftFromWorkflowVersionMutationOptions = Apollo.BaseMutationOptions<CreateDraftFromWorkflowVersionMutation, CreateDraftFromWorkflowVersionMutationVariables>;
export const CreateWorkflowVersionEdgeDocument = gql`
mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
createWorkflowVersionEdge(input: $input) {
triggerNextStepIds
stepsNextStepIds
}
}
`;
export type CreateWorkflowVersionEdgeMutationFn = Apollo.MutationFunction<CreateWorkflowVersionEdgeMutation, CreateWorkflowVersionEdgeMutationVariables>;
/**
* __useCreateWorkflowVersionEdgeMutation__
*
* To run a mutation, you first call `useCreateWorkflowVersionEdgeMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateWorkflowVersionEdgeMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createWorkflowVersionEdgeMutation, { data, loading, error }] = useCreateWorkflowVersionEdgeMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateWorkflowVersionEdgeMutation(baseOptions?: Apollo.MutationHookOptions<CreateWorkflowVersionEdgeMutation, CreateWorkflowVersionEdgeMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateWorkflowVersionEdgeMutation, CreateWorkflowVersionEdgeMutationVariables>(CreateWorkflowVersionEdgeDocument, options);
}
export type CreateWorkflowVersionEdgeMutationHookResult = ReturnType<typeof useCreateWorkflowVersionEdgeMutation>;
export type CreateWorkflowVersionEdgeMutationResult = Apollo.MutationResult<CreateWorkflowVersionEdgeMutation>;
export type CreateWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOptions<CreateWorkflowVersionEdgeMutation, CreateWorkflowVersionEdgeMutationVariables>;
export const CreateWorkflowVersionStepDocument = gql`
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
nextStepIds
triggerNextStepIds
stepsNextStepIds
createdStep {
id
name
type
settings
valid
nextStepIds
position {
x
y
}
}
}
}
`;
@@ -9295,15 +9422,46 @@ export function useDeactivateWorkflowVersionMutation(baseOptions?: Apollo.Mutati
export type DeactivateWorkflowVersionMutationHookResult = ReturnType<typeof useDeactivateWorkflowVersionMutation>;
export type DeactivateWorkflowVersionMutationResult = Apollo.MutationResult<DeactivateWorkflowVersionMutation>;
export type DeactivateWorkflowVersionMutationOptions = Apollo.BaseMutationOptions<DeactivateWorkflowVersionMutation, DeactivateWorkflowVersionMutationVariables>;
export const DeleteWorkflowVersionEdgeDocument = gql`
mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
deleteWorkflowVersionEdge(input: $input) {
triggerNextStepIds
stepsNextStepIds
}
}
`;
export type DeleteWorkflowVersionEdgeMutationFn = Apollo.MutationFunction<DeleteWorkflowVersionEdgeMutation, DeleteWorkflowVersionEdgeMutationVariables>;
/**
* __useDeleteWorkflowVersionEdgeMutation__
*
* To run a mutation, you first call `useDeleteWorkflowVersionEdgeMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteWorkflowVersionEdgeMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteWorkflowVersionEdgeMutation, { data, loading, error }] = useDeleteWorkflowVersionEdgeMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useDeleteWorkflowVersionEdgeMutation(baseOptions?: Apollo.MutationHookOptions<DeleteWorkflowVersionEdgeMutation, DeleteWorkflowVersionEdgeMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteWorkflowVersionEdgeMutation, DeleteWorkflowVersionEdgeMutationVariables>(DeleteWorkflowVersionEdgeDocument, options);
}
export type DeleteWorkflowVersionEdgeMutationHookResult = ReturnType<typeof useDeleteWorkflowVersionEdgeMutation>;
export type DeleteWorkflowVersionEdgeMutationResult = Apollo.MutationResult<DeleteWorkflowVersionEdgeMutation>;
export type DeleteWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOptions<DeleteWorkflowVersionEdgeMutation, DeleteWorkflowVersionEdgeMutationVariables>;
export const DeleteWorkflowVersionStepDocument = gql`
mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
deleteWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
nextStepIds
triggerNextStepIds
stepsNextStepIds
deletedStepId
}
}
`;
@@ -9375,6 +9533,10 @@ export const UpdateWorkflowRunStepDocument = gql`
settings
valid
nextStepIds
position {
x
y
}
}
}
`;
@@ -9413,6 +9575,10 @@ export const UpdateWorkflowVersionStepDocument = gql`
settings
valid
nextStepIds
position {
x
y
}
}
}
`;
@@ -9473,6 +9639,37 @@ export function useSubmitFormStepMutation(baseOptions?: Apollo.MutationHookOptio
export type SubmitFormStepMutationHookResult = ReturnType<typeof useSubmitFormStepMutation>;
export type SubmitFormStepMutationResult = Apollo.MutationResult<SubmitFormStepMutation>;
export type SubmitFormStepMutationOptions = Apollo.BaseMutationOptions<SubmitFormStepMutation, SubmitFormStepMutationVariables>;
export const UpdateWorkflowVersionPositionsDocument = gql`
mutation UpdateWorkflowVersionPositions($input: UpdateWorkflowVersionPositionsInput!) {
updateWorkflowVersionPositions(input: $input)
}
`;
export type UpdateWorkflowVersionPositionsMutationFn = Apollo.MutationFunction<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>;
/**
* __useUpdateWorkflowVersionPositionsMutation__
*
* To run a mutation, you first call `useUpdateWorkflowVersionPositionsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateWorkflowVersionPositionsMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateWorkflowVersionPositionsMutation, { data, loading, error }] = useUpdateWorkflowVersionPositionsMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateWorkflowVersionPositionsMutation(baseOptions?: Apollo.MutationHookOptions<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>(UpdateWorkflowVersionPositionsDocument, options);
}
export type UpdateWorkflowVersionPositionsMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionPositionsMutation>;
export type UpdateWorkflowVersionPositionsMutationResult = Apollo.MutationResult<UpdateWorkflowVersionPositionsMutation>;
export type UpdateWorkflowVersionPositionsMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>;
export const DeleteWorkspaceInvitationDocument = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)
+67 -3
View File
@@ -572,11 +572,22 @@ export type CreateWebhookDto = {
targetUrl: Scalars['String'];
};
export type CreateWorkflowVersionEdgeInput = {
/** Workflow version source step ID */
source: Scalars['String'];
/** Workflow version target step ID */
target: Scalars['String'];
/** Workflow version ID */
workflowVersionId: Scalars['String'];
};
export type CreateWorkflowVersionStepInput = {
/** Next step ID */
nextStepId?: InputMaybe<Scalars['UUID']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['UUID']>;
parentStepId?: InputMaybe<Scalars['String']>;
/** Step position */
position?: InputMaybe<WorkflowStepPositionInput>;
/** New step type */
stepType: Scalars['String'];
/** Workflow version ID */
@@ -750,6 +761,7 @@ export enum FeatureFlagKey {
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED'
@@ -1127,7 +1139,8 @@ export type Mutation = {
createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionStep: WorkflowAction;
createWorkflowVersionEdge: WorkflowVersionStepChanges;
createWorkflowVersionStep: WorkflowVersionStepChanges;
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
@@ -1142,7 +1155,8 @@ export type Mutation = {
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
deleteUser: User;
deleteWebhook: Scalars['Boolean'];
deleteWorkflowVersionStep: WorkflowAction;
deleteWorkflowVersionEdge: WorkflowVersionStepChanges;
deleteWorkflowVersionStep: WorkflowVersionStepChanges;
deleteWorkspaceInvitation: Scalars['String'];
disablePostgresProxy: PostgresCredentials;
editSSOIdentityProvider: EditSsoOutput;
@@ -1192,6 +1206,7 @@ export type Mutation = {
updatePasswordViaResetToken: InvalidatePassword;
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
updateWorkflowVersionStep: WorkflowAction;
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
@@ -1330,6 +1345,11 @@ export type MutationCreateWebhookArgs = {
};
export type MutationCreateWorkflowVersionEdgeArgs = {
input: CreateWorkflowVersionEdgeInput;
};
export type MutationCreateWorkflowVersionStepArgs = {
input: CreateWorkflowVersionStepInput;
};
@@ -1395,6 +1415,11 @@ export type MutationDeleteWebhookArgs = {
};
export type MutationDeleteWorkflowVersionEdgeArgs = {
input: CreateWorkflowVersionEdgeInput;
};
export type MutationDeleteWorkflowVersionStepArgs = {
input: DeleteWorkflowVersionStepInput;
};
@@ -1637,6 +1662,11 @@ export type MutationUpdateWorkflowRunStepArgs = {
};
export type MutationUpdateWorkflowVersionPositionsArgs = {
input: UpdateWorkflowVersionPositionsInput;
};
export type MutationUpdateWorkflowVersionStepArgs = {
input: UpdateWorkflowVersionStepInput;
};
@@ -2698,6 +2728,13 @@ export type UpdateWorkflowRunStepInput = {
workflowRunId: Scalars['UUID'];
};
export type UpdateWorkflowVersionPositionsInput = {
/** Workflow version updated positions */
positions: Array<WorkflowStepPositionUpdateInput>;
/** Workflow version ID */
workflowVersionId: Scalars['UUID'];
};
export type UpdateWorkflowVersionStepInput = {
/** Step to update in JSON format */
step: Scalars['JSON'];
@@ -2862,6 +2899,7 @@ export type WorkflowAction = {
id: Scalars['UUID'];
name: Scalars['String'];
nextStepIds?: Maybe<Array<Scalars['UUID']>>;
position?: Maybe<WorkflowStepPosition>;
settings: Scalars['JSON'];
type: Scalars['String'];
valid: Scalars['Boolean'];
@@ -2872,11 +2910,37 @@ export type WorkflowRun = {
workflowRunId: Scalars['UUID'];
};
export type WorkflowStepPosition = {
__typename?: 'WorkflowStepPosition';
x: Scalars['Float'];
y: Scalars['Float'];
};
export type WorkflowStepPositionInput = {
x: Scalars['Float'];
y: Scalars['Float'];
};
export type WorkflowStepPositionUpdateInput = {
/** Step or trigger ID */
id: Scalars['String'];
/** Position of the step or trigger */
position: WorkflowStepPositionInput;
};
export type WorkflowVersion = {
__typename?: 'WorkflowVersion';
id: Scalars['UUID'];
};
export type WorkflowVersionStepChanges = {
__typename?: 'WorkflowVersionStepChanges';
createdStep?: Maybe<WorkflowAction>;
deletedStepId?: Maybe<Scalars['String']>;
stepsNextStepIds?: Maybe<Scalars['JSON']>;
triggerNextStepIds?: Maybe<Array<Scalars['String']>>;
};
export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
@@ -9,9 +9,9 @@ import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { useFilteredOtherActions } from '@/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu';
export const CommandMenuWorkflowSelectActionContent = ({
workflow,
@@ -25,28 +25,27 @@ export const CommandMenuWorkflowSelectActionContent = ({
});
const filteredOtherActions = useFilteredOtherActions();
const { closeRightClickMenu } = useCloseRightClickMenu();
const [workflowInsertStepIds, setWorkflowInsertStepIds] =
useRecoilComponentStateV2(workflowInsertStepIdsComponentState);
const handleCreateStep = async (actionType: WorkflowActionType) => {
const { parentStepId, nextStepId } = workflowInsertStepIds;
if (!isDefined(parentStepId)) {
throw new Error(
'No parentStepId. Please select a parent step to create from.',
);
}
const { parentStepId, nextStepId, position } = workflowInsertStepIds;
await createStep({
newStepType: actionType,
parentStepId,
nextStepId,
position,
});
setWorkflowInsertStepIds({
parentStepId: undefined,
nextStepId: undefined,
position: undefined,
});
closeRightClickMenu();
};
return (
@@ -15,6 +15,8 @@ import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hoo
import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition';
import { useIcons } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
export const CommandMenuWorkflowSelectTriggerTypeContent = ({
workflow,
@@ -32,6 +34,10 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({
);
const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const handleTriggerTypeClick = ({
type,
defaultLabel,
@@ -47,6 +53,7 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
steps: !isWorkflowBranchEnabled ? workflow.currentVersion.steps : [],
}),
);
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const CREATE_WORKFLOW_VERSION_EDGE = gql`
mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
createWorkflowVersionEdge(input: $input) {
triggerNextStepIds
stepsNextStepIds
}
}
`;
@@ -3,12 +3,20 @@ import { gql } from '@apollo/client';
export const CREATE_WORKFLOW_VERSION_STEP = gql`
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
nextStepIds
triggerNextStepIds
stepsNextStepIds
createdStep {
id
name
type
settings
valid
nextStepIds
position {
x
y
}
}
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const DELETE_WORKFLOW_VERSION_EDGE = gql`
mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) {
deleteWorkflowVersionEdge(input: $input) {
triggerNextStepIds
stepsNextStepIds
}
}
`;
@@ -3,12 +3,9 @@ import { gql } from '@apollo/client';
export const DELETE_WORKFLOW_VERSION_STEP = gql`
mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
deleteWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
nextStepIds
triggerNextStepIds
stepsNextStepIds
deletedStepId
}
}
`;
@@ -9,6 +9,10 @@ export const UPDATE_WORKFLOW_RUN_STEP = gql`
settings
valid
nextStepIds
position {
x
y
}
}
}
`;
@@ -9,6 +9,10 @@ export const UPDATE_WORKFLOW_VERSION_STEP = gql`
settings
valid
nextStepIds
position {
x
y
}
}
}
`;
@@ -1,90 +1,35 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { DELETE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/deleteWorkflowVersionStep';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { useMutation } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import {
DeleteWorkflowVersionStepInput,
DeleteWorkflowVersionStepMutation,
DeleteWorkflowVersionStepMutationVariables,
WorkflowAction,
} from '~/generated-metadata/graphql';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
export const useDeleteWorkflowVersionStep = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const [mutate] = useMutation<
DeleteWorkflowVersionStepMutation,
DeleteWorkflowVersionStepMutationVariables
>(DELETE_WORKFLOW_VERSION_STEP, {
client: apolloCoreClient,
});
const deleteWorkflowVersionStep = async (
input: DeleteWorkflowVersionStepInput,
) => {
const result = await mutate({ variables: { input } });
const deletedStep = result?.data?.deleteWorkflowVersionStep;
const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionStep;
if (!isDefined(deletedStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!isDefined(cachedRecord)) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: (cachedRecord.steps || [])
.filter((step: WorkflowAction) => step.id !== deletedStep.id)
.map((step: WorkflowAction) => {
if (!step.nextStepIds?.includes(deletedStep.id)) {
return step;
}
return {
...step,
nextStepIds: [
...new Set([
...(step.nextStepIds?.filter(
(nextStepId) => nextStepId !== deletedStep.id,
) || []),
...(deletedStep.nextStepIds || []),
]),
],
};
}),
};
const recordGqlFields = {
steps: true,
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: newCachedRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
updateWorkflowVersionCache({
workflowVersionStepChanges,
workflowVersionId: input.workflowVersionId,
});
};
@@ -4,7 +4,7 @@ import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutpu
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import {
OutputSchema,
StepOutputSchema,
@@ -23,11 +23,14 @@ export const baseWorkflowActionSchema = z.object({
name: z.string(),
valid: z.boolean(),
nextStepIds: z.array(z.string()).optional().nullable(),
position: z.object({ x: z.number(), y: z.number() }).optional().nullable(),
});
export const baseTriggerSchema = z.object({
name: z.string().optional(),
type: z.string(),
position: z.object({ x: z.number(), y: z.number() }).optional().nullable(),
nextStepIds: z.array(z.string()).optional().nullable(),
});
// Action settings schemas
@@ -1,14 +0,0 @@
import styled from '@emotion/styled';
import { Handle } from '@xyflow/react';
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
export const StyledHandle = styled(Handle)`
height: ${NODE_HANDLE_HEIGHT_PX}px;
width: ${NODE_HANDLE_WIDTH_PX}px;
left: ${CREATE_STEP_NODE_WIDTH}px;
visibility: hidden;
`;
export { StyledHandle as WorkflowDiagramBaseHandle };
@@ -0,0 +1,82 @@
import { Handle, HandleProps } from '@xyflow/react';
import styled from '@emotion/styled';
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramBaseHandleProps = HandleProps & {
isVisible?: boolean;
disableHoverEffect?: boolean;
};
const HANDLE_SCALE_ON_HOVER = 1.5;
const TRANSLATE_PERCENT = 33.33;
const StyledHandle = styled(Handle, {
shouldForwardProp: (prop) => !['disableHoverEffect'].includes(prop),
})<WorkflowDiagramBaseHandleProps>`
// We need !important to avoid passing style with the Handle.style property
height: ${NODE_HANDLE_HEIGHT_PX}px !important;
width: ${NODE_HANDLE_WIDTH_PX}px !important;
border-color: ${({ theme }) => theme.border.color.strong} !important;
background: ${({ theme }) => theme.background.primary} !important;
transition:
transform 0.1s ease-out,
background 0.1s,
border-color 0.1s !important;
z-index: 1 !important;
${({ position }) => {
switch (position) {
case 'top':
return `top: 19px !important;`;
case 'bottom':
return `top: 47px !important;`;
default:
return '';
}
}}
${({ disableHoverEffect, theme, position }) => {
if (disableHoverEffect === true) {
return '';
}
let transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) !important;`;
if (position === 'top') {
transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(${-TRANSLATE_PERCENT}%, ${-TRANSLATE_PERCENT}%) !important;`;
} else if (position === 'bottom') {
transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(${-TRANSLATE_PERCENT}%, ${TRANSLATE_PERCENT * 2}%) !important;`;
}
return `
&:hover {
border-color: ${theme.font.color.light} !important;
background: ${theme.background.secondary} !important;
${transform}
}
`;
}}
`;
export const WorkflowDiagramBaseHandle = ({
type,
position,
isVisible = true,
}: WorkflowDiagramBaseHandleProps) => {
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
return (
<StyledHandle
type={type}
position={position}
style={{ opacity: isVisible ? 1 : 0 }}
disableHoverEffect={!isWorkflowBranchEnabled}
/>
);
};
@@ -1,7 +1,6 @@
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
type WorkflowDiagramBlankEdgeProps = EdgeProps<WorkflowDiagramEdge>;
@@ -9,14 +8,16 @@ export const WorkflowDiagramBlankEdge = ({
markerStart,
markerEnd,
sourceY,
sourceX,
targetY,
targetX,
}: WorkflowDiagramBlankEdgeProps) => {
const theme = useTheme();
const [edgePath] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -18,7 +18,6 @@ import {
WorkflowDiagramNode,
WorkflowDiagramNodeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -33,13 +32,27 @@ import {
applyEdgeChanges,
applyNodeChanges,
useReactFlow,
Connection,
OnNodeDrag,
OnBeforeDelete,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Tag, TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
import { WorkflowDiagramRightClickCommandMenu } from '@/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@@ -98,6 +111,11 @@ export const WorkflowDiagramCanvasBase = ({
tagColor,
tagText,
onInit,
onConnect,
onDeleteEdge,
onNodeDragStop,
handlePaneContextMenu,
nodesConnectable = false,
}: {
nodeTypes: Partial<
Record<
@@ -126,6 +144,19 @@ export const WorkflowDiagramCanvasBase = ({
tagColor: TagColor;
tagText: string;
onInit?: () => void;
onConnect?: (params: Connection) => void;
onDeleteEdge?: (edge: WorkflowDiagramEdge) => void;
onNodeDragStop?: OnNodeDrag<WorkflowDiagramNode>;
nodesConnectable?: boolean;
handlePaneContextMenu?: ({
x,
y,
event,
}: {
x: number;
y: number;
event: MouseEvent | React.MouseEvent<Element, MouseEvent>;
}) => void;
}) => {
const theme = useTheme();
@@ -157,16 +188,22 @@ export const WorkflowDiagramCanvasBase = ({
workflowDiagramWaitingNodesDimensionsComponentState,
);
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] =
useState<boolean>(false);
const { nodes, edges } = useMemo(
() =>
isDefined(workflowDiagram)
? getOrganizedDiagram(workflowDiagram)
: { nodes: [], edges: [] },
[workflowDiagram],
);
const { nodes, edges } = useMemo(() => {
if (isDefined(workflowDiagram)) {
if (isWorkflowBranchEnabled) {
return workflowDiagram;
}
return getOrganizedDiagram(workflowDiagram);
}
return { nodes: [], edges: [] };
}, [workflowDiagram, isWorkflowBranchEnabled]);
const { rightDrawerState } = useRightDrawerState();
const { isInRightDrawer } = useContext(ActionMenuContext);
@@ -195,6 +232,7 @@ export const WorkflowDiagramCanvasBase = ({
setWorkflowInsertStepIds({
parentStepId: undefined,
nextStepId: undefined,
position: undefined,
});
setWorkflowSelectedNode(undefined);
});
@@ -368,6 +406,36 @@ export const WorkflowDiagramCanvasBase = ({
],
);
const onBeforeDelete: OnBeforeDelete<
WorkflowDiagramNode,
WorkflowDiagramEdge
> = async (diagram) => {
if (
diagram.nodes.length === 0 // We don't call deleteEdge when node diagram deletion is called
) {
for (const edge of diagram.edges) {
onDeleteEdge?.(edge);
}
return diagram;
}
return false;
};
const onPaneContextMenu = useCallback(
(event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => {
event.preventDefault();
const bounds = containerRef.current?.getBoundingClientRect();
if (!bounds) return;
const x = event.clientX - bounds.left;
const y = event.clientY - bounds.top;
handlePaneContextMenu?.({ x, y, event });
},
[handlePaneContextMenu],
);
return (
<StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers />
@@ -383,17 +451,23 @@ export const WorkflowDiagramCanvasBase = ({
edges={edges}
onNodesChange={handleNodesChanges}
onEdgesChange={handleEdgesChange}
onBeforeDelete={async () => {
// Abort all non-programmatic deletions
return false;
}}
onConnect={isWorkflowBranchEnabled ? onConnect : undefined}
onNodeDragStop={isWorkflowBranchEnabled ? onNodeDragStop : undefined}
onBeforeDelete={
isWorkflowBranchEnabled ? onBeforeDelete : async () => false
}
selectNodesOnDrag={false}
proOptions={{ hideAttribution: true }}
multiSelectionKeyCode={null}
nodesFocusable={false}
edgesFocusable={false}
nodesDraggable={false}
edgesFocusable={
isWorkflowBranchEnabled ? isDefined(onDeleteEdge) : false
}
panOnDrag={workflowDiagramPanOnDrag}
nodesConnectable={false}
onPaneContextMenu={
isWorkflowBranchEnabled ? onPaneContextMenu : undefined
}
nodesConnectable={isWorkflowBranchEnabled ? nodesConnectable : false}
paneClickDistance={10} // Fix small unwanted user dragging does not select node
preventScrolling={false}
>
@@ -402,6 +476,10 @@ export const WorkflowDiagramCanvasBase = ({
{children}
</ReactFlow>
{isDefined(handlePaneContextMenu) && isWorkflowBranchEnabled && (
<WorkflowDiagramRightClickCommandMenu />
)}
<StyledStatusTagContainer data-testid={tagContainerTestId}>
<Tag color={tagColor} text={tagText} />
</StyledStatusTagContainer>
@@ -1,31 +1,123 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramFilteringDisabledEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
import { addEdge, Connection, ReactFlowProvider } from '@xyflow/react';
import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge';
import { isDefined } from 'twenty-shared/utils';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import React from 'react';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import {
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
export const WorkflowDiagramCanvasEditable = ({
versionStatus,
workflowWithCurrentVersion,
}: {
versionStatus: WorkflowVersionStatus;
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
}) => {
const tagProps = getWorkflowVersionStatusTagProps({
workflowVersionStatus: versionStatus,
workflowVersionStatus: workflowWithCurrentVersion.currentVersion.status,
});
const setWorkflowDiagram = useSetRecoilComponentStateV2(
workflowDiagramComponentState,
);
const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2(
workflowDiagramRightClickMenuPositionState,
);
const { createEdge } = useCreateEdge({
workflow: workflowWithCurrentVersion,
});
const { deleteEdge } = useDeleteEdge({
workflow: workflowWithCurrentVersion,
});
const { updateStep } = useUpdateStep({
workflow: workflowWithCurrentVersion,
});
const { updateTrigger } = useUpdateWorkflowVersionTrigger({
workflow: workflowWithCurrentVersion,
});
const onConnect = (edgeConnect: Connection) => {
setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
throw new Error(
'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
);
}
return {
...diagram,
edges: addEdge(edgeConnect, diagram.edges),
};
});
createEdge?.(edgeConnect);
};
const onDeleteEdge = async (edge: WorkflowDiagramEdge) => {
await deleteEdge(edge);
};
const onNodeDragStop = async (
_: React.MouseEvent<Element>,
node: WorkflowDiagramNode,
) => {
const stepToUpdate =
workflowWithCurrentVersion?.currentVersion?.steps?.find(
(step) => step.id === node.id,
);
if (isDefined(stepToUpdate)) {
await updateStep({
...stepToUpdate,
position: node.position,
});
return;
}
const triggerToUpdate = workflowWithCurrentVersion?.currentVersion?.trigger;
if (isDefined(triggerToUpdate)) {
await updateTrigger({
...triggerToUpdate,
position: node.position,
});
return;
}
};
const handlePaneContextMenu = ({ x, y }: { x: number; y: number }) => {
setWorkflowDiagramRightClickMenuPosition({
x,
y,
});
};
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
edgeTypes={{
@@ -38,6 +130,11 @@ export const WorkflowDiagramCanvasEditable = ({
tagContainerTestId="workflow-visualizer-status"
tagColor={tagProps.color}
tagText={tagProps.text}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
handlePaneContextMenu={handlePaneContextMenu}
nodesConnectable
onDeleteEdge={onDeleteEdge}
/>
<WorkflowDiagramCanvasEditableEffect />
@@ -9,7 +9,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/workflow-diagram/constants/EmptyTriggerStepId';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { useTriggerNodeSelection } from '@/workflow/workflow-diagram/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import {
@@ -17,14 +16,12 @@ import {
WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { isCreateStepNode } from '@/workflow/workflow-diagram/utils/isCreateStepNode';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowDiagramCanvasEditableEffect = () => {
const { getIcon } = useIcons();
const { startNodeCreation } = useStartNodeCreation();
const {
openWorkflowTriggerTypeInCommandMenu,
@@ -67,15 +64,6 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
return;
}
if (isCreateStepNode(selectedNode)) {
startNodeCreation({
parentStepId: selectedNode.data.parentNodeId,
nextStepId: undefined,
});
return;
}
const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData;
setWorkflowSelectedNode(selectedNode.id);
@@ -95,7 +83,6 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
setCommandMenuNavigationStack,
workflowVisualizerWorkflowId,
openWorkflowTriggerTypeInCommandMenu,
startNodeCreation,
openWorkflowEditStepInCommandMenu,
getIcon,
setWorkflowSelectedNode,
@@ -0,0 +1,21 @@
import { useTheme } from '@emotion/react';
export const WorkflowDiagramConnector = () => {
const theme = useTheme();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="2"
height="56"
viewBox="0 0 2 56"
fill="none"
>
<path
d="M1 0V28V56"
stroke={theme.border.color.strong}
strokeDasharray="3 3"
/>
</svg>
);
};
@@ -0,0 +1,59 @@
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { WorkflowDiagramConnector } from '@/workflow/workflow-diagram/components/WorkflowDiagramConnector';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import React from 'react';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
`;
const StyledVerticalLineContainer = styled.div`
display: flex;
height: 56px;
justify-content: center;
width: 56px;
`;
type WorkflowDiagramCreateStepElementProps = {
data: WorkflowDiagramStepNodeData;
};
export const WorkflowDiagramCreateStepElement = ({
data,
}: WorkflowDiagramCreateStepElementProps) => {
const { startNodeCreation } = useStartNodeCreation();
const addNode = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
startNodeCreation({
parentStepId: data.stepId,
nextStepId: undefined,
position: {
x: data.position.x,
y: data.position.y + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
},
});
};
return (
<StyledContainer>
<StyledVerticalLineContainer>
<WorkflowDiagramConnector />
</StyledVerticalLineContainer>
<IconButton
Icon={IconPlus}
size="small"
ariaLabel="Add a step"
onClick={addNode}
/>
</StyledContainer>
);
};
@@ -1,25 +0,0 @@
import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramCreateStepNodeClickOutsideId';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { IconPlus } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledContainer = styled.div`
left: ${CREATE_STEP_NODE_WIDTH / 2}px;
padding-top: ${({ theme }) => theme.spacing(3)};
position: relative;
`;
export const WorkflowDiagramCreateStepNode = () => {
return (
<StyledContainer
data-click-outside-id={WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID}
>
<WorkflowDiagramBaseHandle type="target" position={Position.Top} />
<IconButton Icon={IconPlus} size="medium" ariaLabel="Add a step" />
</StyledContainer>
);
};
@@ -3,7 +3,6 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
@@ -16,7 +15,7 @@ import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
getBezierPath,
} from '@xyflow/react';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -32,17 +31,19 @@ const StyledIconButtonGroup = styled(IconButtonGroup)`
export const WorkflowDiagramDefaultEdgeEditable = ({
source,
target,
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
}: WorkflowDiagramDefaultEdgeEditableProps) => {
const theme = useTheme();
const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -1,22 +1,23 @@
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
type WorkflowDiagramDefaultEdgeReadonlyProps = EdgeProps<WorkflowDiagramEdge>;
export const WorkflowDiagramDefaultEdgeReadonly = ({
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
}: WorkflowDiagramDefaultEdgeReadonlyProps) => {
const theme = useTheme();
const [edgePath] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -1,19 +1,20 @@
import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { EdgeProps, getStraightPath } from '@xyflow/react';
import { EdgeProps, getBezierPath } from '@xyflow/react';
type WorkflowDiagramDefaultEdgeRunProps = EdgeProps<WorkflowDiagramEdge>;
export const WorkflowDiagramDefaultEdgeRun = ({
sourceX,
sourceY,
targetX,
targetY,
data,
}: WorkflowDiagramDefaultEdgeRunProps) => {
const [edgePath] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -10,7 +10,6 @@ import {
} from '@/workflow/types/Workflow';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { addCreateStepNodes } from '@/workflow/workflow-diagram/utils/addCreateStepNodes';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@@ -49,13 +48,11 @@ export const WorkflowDiagramEffect = ({
workflowDiagramState,
);
const nextWorkflowDiagram = addCreateStepNodes(
getWorkflowVersionDiagram({
workflowVersion: currentVersion,
isWorkflowFilteringEnabled,
isEditable: true,
}),
);
const nextWorkflowDiagram = getWorkflowVersionDiagram({
workflowVersion: currentVersion,
isWorkflowFilteringEnabled,
isEditable: true,
});
let mergedWorkflowDiagram = nextWorkflowDiagram;
@@ -11,7 +11,6 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
@@ -23,7 +22,6 @@ import {
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramNodeSelectedColors';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
@@ -31,7 +29,7 @@ import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
getBezierPath,
} from '@xyflow/react';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -78,7 +76,9 @@ export const WorkflowDiagramFilterEdgeEditable = ({
source,
target,
sourceY,
sourceX,
targetY,
targetX,
markerStart,
markerEnd,
data,
@@ -87,10 +87,10 @@ export const WorkflowDiagramFilterEdgeEditable = ({
const theme = useTheme();
const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -100,7 +100,7 @@ export const WorkflowDiagramFilterEdgeEditable = ({
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
const { deleteStep } = useDeleteStep({ workflow });
const { startNodeCreation } = useStartNodeCreation();
const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation();
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
@@ -111,15 +111,10 @@ export const WorkflowDiagramFilterEdgeEditable = ({
workflowDiagramPanOnDragComponentState,
);
const workflowInsertStepIds = useRecoilComponentValueV2(
workflowInsertStepIdsComponentState,
);
const isEdgeSelected =
(workflowInsertStepIds.nextStepId === source &&
workflowInsertStepIds.parentStepId === target) ||
(isNonEmptyString(data.stepId) &&
workflowInsertStepIds.parentStepId === data.stepId);
const isEdgeSelected = isNodeCreationStarted({
parentStepId: data.stepId,
nextStepId: target,
});
const workflowSelectedNode = useRecoilComponentValueV2(
workflowSelectedNodeComponentState,
@@ -1,7 +1,6 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useOpenWorkflowViewFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowViewFilterInCommandMenu';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
@@ -17,7 +16,7 @@ import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
getBezierPath,
} from '@xyflow/react';
import { IconFilter } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
@@ -53,7 +52,9 @@ const StyledConfiguredFilterContainer = styled.div`
`;
export const WorkflowDiagramFilterEdgeReadonly = ({
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
@@ -63,10 +64,10 @@ export const WorkflowDiagramFilterEdgeReadonly = ({
const theme = useTheme();
const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -2,7 +2,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useOpenWorkflowRunFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowRunFilterInCommandMenu';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
@@ -16,7 +15,7 @@ import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagra
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { EdgeLabelRenderer, EdgeProps, getStraightPath } from '@xyflow/react';
import { EdgeLabelRenderer, EdgeProps, getBezierPath } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
@@ -55,16 +54,18 @@ const StyledConfiguredFilterContainer = styled.div`
`;
export const WorkflowDiagramFilterEdgeRun = ({
sourceX,
sourceY,
targetX,
targetY,
data,
}: WorkflowDiagramFilterEdgeRunProps) => {
assertFilterEdgeDataOrThrow(data);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -1,45 +1,24 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
getBezierPath,
} from '@xyflow/react';
import { useState } from 'react';
import { IconPlus } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer';
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
`;
const StyledContainer = styled.div<{
labelY?: number;
}>`
position: absolute;
transform: ${({ labelY }) => `translate(${21}px, ${(labelY || 0) - 14}px)`};
`;
const StyledHoverZone = styled.div`
position: absolute;
width: 48px;
height: 52px;
transform: translate(-13px, -16px);
background: transparent;
`;
const StyledWrapper = styled.div`
pointer-events: all;
position: relative;
`;
type WorkflowDiagramFilteringDisabledEdgeEditableProps =
EdgeProps<WorkflowDiagramEdge>;
@@ -48,29 +27,36 @@ export const WorkflowDiagramFilteringDisabledEdgeEditable = ({
markerEnd,
source,
sourceY,
sourceX,
target,
targetX,
targetY,
}: WorkflowDiagramFilteringDisabledEdgeEditableProps) => {
const theme = useTheme();
const [edgePath, , labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
const [hovered, setHovered] = useState(false);
const { startNodeCreation } = useStartNodeCreation();
const handleMouseEnter = () => {
setHovered(true);
};
const workflowInsertStepIds = useRecoilComponentValueV2(
workflowInsertStepIdsComponentState,
);
const handleMouseLeave = () => {
setHovered(false);
};
const isSelected =
workflowInsertStepIds.parentStepId === source &&
workflowInsertStepIds.nextStepId === target;
const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation();
const forceDisplayAddButton = isNodeCreationStarted({
parentStepId: source,
nextStepId: target,
});
return (
<>
@@ -82,33 +68,32 @@ export const WorkflowDiagramFilteringDisabledEdgeEditable = ({
/>
<EdgeLabelRenderer>
<StyledContainer
labelY={labelY}
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<StyledWrapper
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
<WorkflowDiagramEdgeV2VisibilityContainer
shouldDisplay={hovered || forceDisplayAddButton}
>
<StyledHoverZone />
{(hovered || isSelected) && (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconPlus,
onClick: () => {
startNodeCreation({
parentStepId: source,
nextStepId: target,
});
},
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconPlus,
onClick: () => {
startNodeCreation({
parentStepId: source,
nextStepId: target,
});
},
]}
/>
)}
</StyledWrapper>
</StyledContainer>
},
]}
/>
</WorkflowDiagramEdgeV2VisibilityContainer>
</WorkflowDiagramEdgeV2Container>
</EdgeLabelRenderer>
</>
);
@@ -1,7 +1,6 @@
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react';
type WorkflowDiagramFilteringDisabledEdgeReadonlyProps =
EdgeProps<WorkflowDiagramEdge>;
@@ -9,15 +8,17 @@ type WorkflowDiagramFilteringDisabledEdgeReadonlyProps =
export const WorkflowDiagramFilteringDisabledEdgeReadonly = ({
markerStart,
markerEnd,
sourceX,
sourceY,
targetX,
targetY,
}: WorkflowDiagramFilteringDisabledEdgeReadonlyProps) => {
const theme = useTheme();
const [edgePath] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -1,20 +1,21 @@
import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { EdgeProps, getStraightPath } from '@xyflow/react';
import { EdgeProps, getBezierPath } from '@xyflow/react';
type WorkflowDiagramFilteringDisabledEdgeRunProps =
EdgeProps<WorkflowDiagramEdge>;
export const WorkflowDiagramFilteringDisabledEdgeRun = ({
sourceX,
sourceY,
targetX,
targetY,
data,
}: WorkflowDiagramFilteringDisabledEdgeRunProps) => {
const [edgePath] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH,
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX: CREATE_STEP_NODE_WIDTH,
targetX,
targetY,
});
@@ -0,0 +1,90 @@
import styled from '@emotion/styled';
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import { isDefined } from 'twenty-shared/utils';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRef } from 'react';
import { WorkflowDiagramRightClickCommandMenuClickOutsideEffect } from './WorkflowDiagramRightClickCommandMenuClickOutsideEffect';
import { IconPlus, IconReorder } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { useLingui } from '@lingui/react/macro';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu';
import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion';
const StyledContainer = styled.div<{ x: number; y: number }>`
background: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
border-radius: ${({ theme }) => theme.spacing(2)};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
left: ${({ x }) => `${x}px`};
padding: ${({ theme }) => theme.spacing(1)};
position: absolute;
top: ${({ y }) => `${y}px`};
width: 200px;
`;
export const WorkflowDiagramRightClickCommandMenu = () => {
const { t } = useLingui();
const rightClickCommandMenuRef = useRef<HTMLDivElement>(null);
const { startNodeCreation } = useStartNodeCreation();
const { closeRightClickMenu } = useCloseRightClickMenu();
const workflowDiagramRightClickMenuPosition = useRecoilComponentValueV2(
workflowDiagramRightClickMenuPositionState,
);
const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
workflowVisualizerWorkflowIdComponentState,
);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
workflowVisualizerWorkflowId,
);
const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion({
workflow: workflowWithCurrentVersion,
});
const handleReorderWorkflowDiagram = async () => {
await tidyUpWorkflowVersion();
closeRightClickMenu();
};
const addNode = () => {
startNodeCreation({
parentStepId: undefined,
nextStepId: undefined,
position: workflowDiagramRightClickMenuPosition,
});
};
if (!isDefined(workflowDiagramRightClickMenuPosition)) {
return;
}
return (
<>
<StyledContainer
ref={rightClickCommandMenuRef}
x={workflowDiagramRightClickMenuPosition.x}
y={workflowDiagramRightClickMenuPosition.y}
>
<MenuItem text={t`Add node`} LeftIcon={IconPlus} onClick={addNode} />
<MenuItem
text={t`Tidy up workflow`}
LeftIcon={IconReorder}
onClick={handleReorderWorkflowDiagram}
/>
</StyledContainer>
<WorkflowDiagramRightClickCommandMenuClickOutsideEffect
rightClickCommandMenuRef={rightClickCommandMenuRef}
/>
</>
);
};
@@ -0,0 +1,30 @@
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramRightClickMenuClickOutsideId';
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
export const WorkflowDiagramRightClickCommandMenuClickOutsideEffect = ({
rightClickCommandMenuRef,
}: {
rightClickCommandMenuRef: React.RefObject<HTMLDivElement>;
}) => {
const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2(
workflowDiagramRightClickMenuPositionState,
);
useListenClickOutside({
refs: [rightClickCommandMenuRef],
excludedClickOutsideIds: [COMMAND_MENU_CLICK_OUTSIDE_ID],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
setWorkflowDiagramRightClickMenuPosition(undefined);
},
listenerId: WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID,
});
return <></>;
};
@@ -1,4 +1,3 @@
import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle';
import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
@@ -6,17 +5,16 @@ import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/Wo
import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramNodeSelectedColors';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import React from 'react';
import { capitalize, isDefined } from 'twenty-shared/utils';
import React, { MouseEvent } from 'react';
import { capitalize } from 'twenty-shared/utils';
import { Label, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle';
import { Position } from '@xyflow/react';
const StyledStepNodeContainer = styled.div`
display: flex;
flex-direction: column;
padding-block: ${({ theme }) => theme.spacing(3)};
`;
const StyledStepNodeType = styled.div<{
@@ -142,37 +140,41 @@ const StyledStepNodeLabel = styled.div<{
}
`;
const StyledRightFloatingElementContainer = styled.div`
display: flex;
align-items: center;
position: absolute;
right: ${({ theme }) => theme.spacing(-4)};
bottom: 0;
top: 0;
transform: translateX(100%);
`;
export const WorkflowDiagramStepNodeBase = ({
nodeType,
name,
variant,
Icon,
RightFloatingElement,
BottomHoverFloatingElement,
displayHandle = true,
onMouseEnter,
onMouseLeave,
}: {
nodeType: WorkflowDiagramStepNodeData['nodeType'];
name: string;
variant: WorkflowDiagramNodeVariant;
Icon?: React.ReactNode;
RightFloatingElement?: React.ReactNode;
BottomHoverFloatingElement?: React.ReactNode;
displayHandle?: boolean;
onMouseEnter?: (event: MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: MouseEvent<HTMLDivElement>) => void;
}) => {
return (
<StyledStepNodeContainer
className="workflow-node-container"
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{nodeType !== 'trigger' ? (
<WorkflowDiagramBaseHandle type="target" position={Position.Top} />
) : null}
{nodeType !== 'trigger' && (
<WorkflowDiagramBaseHandle
type="target"
position={Position.Top}
isVisible={displayHandle}
/>
)}
<StyledStepNodeType variant="small" nodeVariant={variant}>
{capitalize(nodeType)}
@@ -182,17 +184,19 @@ export const WorkflowDiagramStepNodeBase = ({
<StyledStepNodeLabel variant={variant}>
{variant === 'running' ? <Loader /> : Icon}
<OverflowingTextWithTooltip text={name} />
<OverflowingTextWithTooltip text={name} hideTooltip={true} />
</StyledStepNodeLabel>
{isDefined(RightFloatingElement) ? (
<StyledRightFloatingElementContainer>
{RightFloatingElement}
</StyledRightFloatingElementContainer>
) : null}
{RightFloatingElement}
{BottomHoverFloatingElement}
</StyledStepNodeInnerContainer>
<WorkflowDiagramBaseHandle type="source" position={Position.Bottom} />
<WorkflowDiagramBaseHandle
type="source"
position={Position.Bottom}
isVisible={displayHandle}
/>
</StyledStepNodeContainer>
);
};
@@ -4,6 +4,34 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/W
import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { FloatingIconButton } from 'twenty-ui/input';
import { IconTrash } from 'twenty-ui/display';
import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement';
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
const StyledDeleteButtonContainer = styled.div`
display: flex;
align-items: center;
position: absolute;
right: ${({ theme }) => theme.spacing(-4)};
bottom: 0;
top: 0;
transform: translateX(100%);
`;
const StyledAddStepButtonContainer = styled.div<{
shouldDisplay: boolean;
}>`
display: flex;
align-items: center;
position: absolute;
justify-content: center;
flex-direction: column;
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
left: 50%;
bottom: 0;
transform: translateX(-50%) translateY(100%);
`;
export const WorkflowDiagramStepNodeEditableContent = ({
data,
@@ -16,20 +44,49 @@ export const WorkflowDiagramStepNodeEditableContent = ({
selected: boolean;
onDelete: () => void;
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => {
setIsHovered(false);
};
const { isNodeCreationStarted } = useStartNodeCreation();
return (
<WorkflowDiagramStepNodeBase
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
name={data.name}
variant={variant}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
RightFloatingElement={
selected ? (
<FloatingIconButton
size="medium"
Icon={IconTrash}
onClick={onDelete}
/>
) : undefined
selected && (
<StyledDeleteButtonContainer>
<FloatingIconButton
size="medium"
Icon={IconTrash}
onClick={onDelete}
/>
</StyledDeleteButtonContainer>
)
}
BottomHoverFloatingElement={
!data.hasNextStepIds && (
<StyledAddStepButtonContainer
shouldDisplay={
isHovered ||
selected ||
isNodeCreationStarted({ parentStepId: data.stepId })
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowDiagramCreateStepElement data={data} />
</StyledAddStepButtonContainer>
)
}
/>
);
@@ -2,6 +2,7 @@ import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/compone
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus';
import React from 'react';
export const WorkflowDiagramStepNodeReadonly = ({
data,
@@ -14,6 +15,7 @@ export const WorkflowDiagramStepNodeReadonly = ({
variant={getNodeVariantFromStepRunStatus(data.runStatus)}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
displayHandle={false}
/>
);
};
@@ -17,7 +17,7 @@ export const WorkflowVisualizer = ({ workflowId }: { workflowId: string }) => {
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
<WorkflowDiagramCanvasEditable
versionStatus={workflowWithCurrentVersion.currentVersion.status}
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
</>
);
@@ -1,42 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { ReactFlowProvider } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { WorkflowDiagramCreateStepNode } from '../WorkflowDiagramCreateStepNode';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof WorkflowDiagramCreateStepNode> = {
title: 'Modules/Workflow/WorkflowDiagramCreateStepNode',
component: WorkflowDiagramCreateStepNode,
decorators: [
ComponentDecorator,
(Story) => (
<ReactFlowProvider>
<Story />
</ReactFlowProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof WorkflowDiagramCreateStepNode>;
export const Default: Story = {
decorators: [
(Story) => (
<div style={{ position: 'relative' }}>
<Story />
</div>
),
],
};
export const Selected: Story = {
decorators: [
(Story) => (
<div className="selectable selected" style={{ position: 'relative' }}>
<Story />
</div>
),
],
};
@@ -9,6 +9,8 @@ import { CatalogDecorator, CatalogStory } from 'twenty-ui/testing';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowDiagramStepNodeEditableContent } from '../WorkflowDiagramStepNodeEditableContent';
import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
type ComponentState = 'default' | 'hover' | 'selected';
@@ -37,41 +39,89 @@ const ALL_STEPS = [
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Record is Created',
hasNextStepIds: true,
stepId: 'trigger',
position: {
x: 0,
y: 0,
},
},
{
nodeType: 'trigger',
triggerType: 'MANUAL',
name: 'Manual',
hasNextStepIds: true,
stepId: 'step1',
position: {
x: 0,
y: 150,
},
},
{
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Record',
hasNextStepIds: true,
stepId: 'step2',
position: {
x: 0,
y: 300,
},
},
{
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Record',
hasNextStepIds: true,
stepId: 'step3',
position: {
x: 0,
y: 450,
},
},
{
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Record',
hasNextStepIds: true,
stepId: 'step4',
position: {
x: 0,
y: 600,
},
},
{
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
hasNextStepIds: true,
stepId: 'step5',
position: {
x: 0,
y: 750,
},
},
{
nodeType: 'action',
actionType: 'CODE',
name: 'Code',
hasNextStepIds: true,
stepId: 'step6',
position: {
x: 0,
y: 900,
},
},
{
nodeType: 'action',
actionType: 'HTTP_REQUEST',
name: 'HTTP Request',
hasNextStepIds: true,
stepId: 'step7',
position: {
x: 0,
y: 1050,
},
},
] satisfies WorkflowDiagramStepNodeData[];
@@ -120,7 +170,13 @@ export const Catalog: CatalogStory<Story, typeof Wrapper> = {
<div
className={`selectable ${args.state === 'selected' ? 'selected' : args.state === 'hover' ? 'workflow-node-container hover' : ''}`}
>
<Story />
<RecoilRoot>
<WorkflowVisualizerComponentInstanceContext.Provider
value={{ instanceId: 'workflow-visualizer-instance-id' }}
>
<Story />
</WorkflowVisualizerComponentInstanceContext.Provider>
</RecoilRoot>
</div>
);
},
@@ -1 +0,0 @@
export const CREATE_STEP_STEP_ID = 'create-step';
@@ -1 +1 @@
export const NODE_HANDLE_WIDTH_PX = 4;
export const NODE_HANDLE_WIDTH_PX = 7;
@@ -7,6 +7,10 @@ export const WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION = {
type: 'empty-trigger',
data: {
nodeType: 'empty-trigger',
position: {
x: 0,
y: 0,
},
} satisfies WorkflowDiagramEmptyTriggerNodeData,
position: {
x: 0,
@@ -0,0 +1,2 @@
export const WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID =
'workflow-diagram-right-click-menu-click-outside';
@@ -64,6 +64,8 @@ describe('useTriggerNodeSelection', () => {
});
expect(result.current.workflowDiagramTriggerNodeSelection).toBeUndefined();
expect(mockSetNodes).toHaveBeenCalled();
});
it('should not trigger update if state is not defined', () => {
@@ -0,0 +1,14 @@
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
export const useCloseRightClickMenu = () => {
const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2(
workflowDiagramRightClickMenuPositionState,
);
const closeRightClickMenu = () => {
setWorkflowDiagramRightClickMenuPosition(undefined);
};
return { closeRightClickMenu };
};
@@ -2,15 +2,14 @@ import { useCallback } from 'react';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { isDefined } from 'twenty-shared/utils';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
export const useStartNodeCreation = () => {
const setWorkflowInsertStepIds = useSetRecoilComponentStateV2(
workflowInsertStepIdsComponentState,
);
const [workflowInsertStepIds, setWorkflowInsertStepIds] =
useRecoilComponentStateV2(workflowInsertStepIdsComponentState);
const { openStepSelectInCommandMenu } = useWorkflowCommandMenu();
@@ -26,11 +25,13 @@ export const useStartNodeCreation = () => {
({
parentStepId,
nextStepId,
position,
}: {
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
}) => {
setWorkflowInsertStepIds({ parentStepId, nextStepId });
setWorkflowInsertStepIds({ parentStepId, nextStepId, position });
if (isDefined(workflowVisualizerWorkflowId)) {
openStepSelectInCommandMenu(workflowVisualizerWorkflowId);
@@ -44,7 +45,21 @@ export const useStartNodeCreation = () => {
],
);
const isNodeCreationStarted = ({
parentStepId,
nextStepId,
}: {
parentStepId?: string;
nextStepId?: string;
}) => {
return (
workflowInsertStepIds.parentStepId === parentStepId &&
workflowInsertStepIds.nextStepId === nextStepId
);
};
return {
startNodeCreation,
isNodeCreationStarted,
};
};
@@ -0,0 +1,16 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
type WorkflowDiagramRightClickMenuPositionState =
| {
x: number;
y: number;
}
| undefined;
export const workflowDiagramRightClickMenuPositionState =
createComponentStateV2<WorkflowDiagramRightClickMenuPositionState>({
key: 'workflowDiagramRightClickMenuPositionState',
defaultValue: undefined,
componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
});
@@ -30,12 +30,24 @@ export type WorkflowDiagramStepNodeData =
name: string;
icon?: string;
runStatus?: WorkflowRunStepStatus;
hasNextStepIds: boolean;
stepId: string;
position: {
x: number;
y: number;
};
}
| {
nodeType: 'action';
actionType: WorkflowActionType;
name: string;
runStatus?: WorkflowRunStepStatus;
hasNextStepIds: boolean;
stepId: string;
position: {
x: number;
y: number;
};
};
export type WorkflowRunDiagramStepNodeData = Exclude<
@@ -45,18 +57,16 @@ export type WorkflowRunDiagramStepNodeData = Exclude<
runStatus: WorkflowRunStepStatus;
};
export type WorkflowDiagramCreateStepNodeData = {
nodeType: 'create-step';
parentNodeId: string;
};
export type WorkflowDiagramEmptyTriggerNodeData = {
nodeType: 'empty-trigger';
position: {
x: number;
y: number;
};
};
export type WorkflowDiagramNodeData =
| WorkflowDiagramStepNodeData
| WorkflowDiagramCreateStepNodeData
| WorkflowDiagramEmptyTriggerNodeData;
export type WorkflowRunDiagramNodeData = Exclude<
@@ -82,10 +92,7 @@ export type WorkflowDiagramEdgeData =
| WorkflowDiagramFilterEdgeData
| WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'
| 'create-step';
export type WorkflowDiagramNodeType = 'default' | 'empty-trigger';
export type WorkflowDiagramEdgeType =
| 'blank'
@@ -1,81 +0,0 @@
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { addCreateStepNodes } from '../addCreateStepNodes';
describe('addCreateStepNodes', () => {
it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const steps: WorkflowStep[] = [
{
id: 'step1',
name: 'Step 1',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: ['step2'],
},
{
id: 'step2',
name: 'Step 2',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: undefined,
},
];
const diagramInitial = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
});
expect(diagramInitial.nodes).toHaveLength(3);
expect(diagramInitial.edges).toHaveLength(2);
const diagramWithCreateStepNodes = addCreateStepNodes(diagramInitial);
expect(diagramWithCreateStepNodes.nodes).toHaveLength(4);
expect(diagramWithCreateStepNodes.edges).toHaveLength(3);
expect(diagramWithCreateStepNodes.nodes[0].type).toBe(undefined);
expect(diagramWithCreateStepNodes.nodes[0].data.nodeType).toBe('trigger');
expect(diagramWithCreateStepNodes.nodes[1].type).toBe(undefined);
expect(diagramWithCreateStepNodes.nodes[1].data.nodeType).toBe('action');
expect(diagramWithCreateStepNodes.nodes[2].type).toBe(undefined);
expect(diagramWithCreateStepNodes.nodes[2].data.nodeType).toBe('action');
expect(diagramWithCreateStepNodes.nodes[3].type).toBe('create-step');
});
});
@@ -37,6 +37,7 @@ describe('generateWorkflowDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
{
@@ -97,6 +98,12 @@ describe('generateWorkflowDiagram', () => {
nodeType: 'action',
actionType: 'CODE',
name: step.name,
hasNextStepIds: step.id !== 'step2',
stepId: step.id,
position: {
x: 0,
y: 150 * (index + 1),
},
});
}
});
@@ -109,6 +116,7 @@ describe('generateWorkflowDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
{
@@ -175,6 +183,7 @@ describe('generateWorkflowDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step2'],
};
const steps: WorkflowStep[] = [
{
@@ -241,6 +250,7 @@ describe('generateWorkflowDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step2', 'step3'],
};
const steps: WorkflowStep[] = [
{
@@ -17,6 +17,7 @@ describe('generateWorkflowRunDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
@@ -153,10 +154,16 @@ describe('generateWorkflowRunDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"runStatus": "SUCCESS",
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -168,9 +175,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "FAILED",
"stepId": "step1",
},
"id": "step1",
"position": {
@@ -181,9 +194,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 2",
"nodeType": "action",
"position": {
"x": 0,
"y": 300,
},
"runStatus": "NOT_STARTED",
"stepId": "step2",
},
"id": "step2",
"position": {
@@ -194,9 +213,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "Step 3",
"nodeType": "action",
"position": {
"x": 0,
"y": 450,
},
"runStatus": "NOT_STARTED",
"stepId": "step3",
},
"id": "step3",
"position": {
@@ -219,6 +244,7 @@ describe('generateWorkflowRunDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
@@ -357,10 +383,16 @@ describe('generateWorkflowRunDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"runStatus": "SUCCESS",
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -372,9 +404,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "SUCCESS",
"stepId": "step1",
},
"id": "step1",
"position": {
@@ -385,9 +423,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 2",
"nodeType": "action",
"position": {
"x": 0,
"y": 300,
},
"runStatus": "SUCCESS",
"stepId": "step2",
},
"id": "step2",
"position": {
@@ -398,9 +442,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "Step 3",
"nodeType": "action",
"position": {
"x": 0,
"y": 450,
},
"runStatus": "SUCCESS",
"stepId": "step3",
},
"id": "step3",
"position": {
@@ -423,6 +473,7 @@ describe('generateWorkflowRunDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
@@ -561,10 +612,16 @@ describe('generateWorkflowRunDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"runStatus": "SUCCESS",
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -576,9 +633,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "RUNNING",
"stepId": "step1",
},
"id": "step1",
"position": {
@@ -589,9 +652,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 2",
"nodeType": "action",
"position": {
"x": 0,
"y": 300,
},
"runStatus": "NOT_STARTED",
"stepId": "step2",
},
"id": "step2",
"position": {
@@ -602,9 +671,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "Step 3",
"nodeType": "action",
"position": {
"x": 0,
"y": 450,
},
"runStatus": "NOT_STARTED",
"stepId": "step3",
},
"id": "step3",
"position": {
@@ -627,6 +702,7 @@ describe('generateWorkflowRunDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
@@ -798,10 +874,16 @@ describe('generateWorkflowRunDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"runStatus": "SUCCESS",
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -813,9 +895,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "SUCCESS",
"stepId": "step1",
},
"id": "step1",
"position": {
@@ -826,9 +914,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 2",
"nodeType": "action",
"position": {
"x": 0,
"y": 300,
},
"runStatus": "RUNNING",
"stepId": "step2",
},
"id": "step2",
"position": {
@@ -839,9 +933,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": true,
"name": "Step 3",
"nodeType": "action",
"position": {
"x": 0,
"y": 450,
},
"runStatus": "NOT_STARTED",
"stepId": "step3",
},
"id": "step3",
"position": {
@@ -852,9 +952,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "Step 4",
"nodeType": "action",
"position": {
"x": 0,
"y": 600,
},
"runStatus": "NOT_STARTED",
"stepId": "step4",
},
"id": "step4",
"position": {
@@ -877,6 +983,7 @@ describe('generateWorkflowRunDiagram', () => {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
const steps: WorkflowStep[] = [
{
@@ -953,10 +1060,16 @@ describe('generateWorkflowRunDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"runStatus": "SUCCESS",
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -968,9 +1081,15 @@ describe('generateWorkflowRunDiagram', () => {
{
"data": {
"actionType": "FORM",
"hasNextStepIds": false,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "PENDING",
"stepId": "step1",
},
"id": "step1",
"position": {
@@ -983,9 +1102,15 @@ describe('generateWorkflowRunDiagram', () => {
"stepToOpenByDefault": {
"data": {
"actionType": "FORM",
"hasNextStepIds": false,
"name": "Step 1",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"runStatus": "PENDING",
"stepId": "step1",
},
"id": "step1",
},
@@ -0,0 +1,16 @@
import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus';
import { StepStatus } from 'twenty-shared/workflow';
describe('getNodeVariantFromRunStatus', () => {
it('should return proper variant', () => {
expect(getNodeVariantFromStepRunStatus(StepStatus.SUCCESS)).toBe('success');
expect(getNodeVariantFromStepRunStatus(StepStatus.STOPPED)).toBe('success');
expect(getNodeVariantFromStepRunStatus(StepStatus.FAILED)).toBe('failure');
expect(getNodeVariantFromStepRunStatus(StepStatus.RUNNING)).toBe('running');
expect(getNodeVariantFromStepRunStatus(StepStatus.PENDING)).toBe('running');
expect(getNodeVariantFromStepRunStatus(StepStatus.NOT_STARTED)).toBe(
'not-executed',
);
expect(getNodeVariantFromStepRunStatus(undefined)).toBe('default');
});
});
@@ -18,9 +18,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": "IconHandMove",
"name": "Manual trigger",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "MANUAL",
},
"id": "trigger",
@@ -53,9 +59,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": "IconClock",
"name": "On a schedule",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "CRON",
},
"id": "trigger",
@@ -85,9 +97,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": "IconWebhook",
"name": "Webhook",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "WEBHOOK",
},
"id": "trigger",
@@ -116,9 +134,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": "IconPlaylistAdd",
"name": "Record is created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -145,9 +169,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": undefined,
"name": "",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -177,9 +207,15 @@ describe('getWorkflowDiagramTriggerNode', () => {
expect(result).toMatchInlineSnapshot(`
{
"data": {
"hasNextStepIds": false,
"icon": "IconHandMove",
"name": "Custom Trigger Name",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "MANUAL",
},
"id": "trigger",
@@ -18,10 +18,17 @@ jest.mock(
}),
);
const baseNodeData = {
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '',
};
describe('getWorkflowNodeIconKey', () => {
describe('trigger nodes', () => {
it('should return the icon from trigger node data', () => {
const triggerNodeData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
@@ -35,6 +42,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for manual trigger', () => {
const manualTriggerData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'trigger',
triggerType: 'MANUAL',
name: 'Manual Trigger',
@@ -48,6 +56,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for webhook trigger', () => {
const webhookTriggerData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'trigger',
triggerType: 'WEBHOOK',
name: 'Webhook Trigger',
@@ -63,6 +72,7 @@ describe('getWorkflowNodeIconKey', () => {
describe('action nodes', () => {
it('should return icon for CREATE_RECORD action', () => {
const createActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
@@ -75,6 +85,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for UPDATE_RECORD action', () => {
const updateActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Company',
@@ -87,6 +98,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for DELETE_RECORD action', () => {
const deleteActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Company',
@@ -99,6 +111,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for SEND_EMAIL action', () => {
const emailActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
@@ -111,6 +124,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should return icon for FILTER action', () => {
const filterActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter Records',
@@ -123,6 +137,7 @@ describe('getWorkflowNodeIconKey', () => {
it('should handle unknown action types', () => {
const unknownActionData: WorkflowDiagramStepNodeData = {
...baseNodeData,
nodeType: 'action',
actionType: 'UNKNOWN_ACTION' as any,
name: 'Unknown Action',
@@ -45,6 +45,10 @@ describe('getWorkflowVersionDiagram', () => {
{
"data": {
"nodeType": "empty-trigger",
"position": {
"x": 0,
"y": 0,
},
},
"id": "trigger",
"position": {
@@ -85,9 +89,15 @@ describe('getWorkflowVersionDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": false,
"icon": "IconPlaylistAdd",
"name": "Record is created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -133,6 +143,7 @@ describe('getWorkflowVersionDiagram', () => {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
nextStepIds: ['step-1'],
},
updatedAt: '',
workflowId: '',
@@ -148,11 +159,11 @@ describe('getWorkflowVersionDiagram', () => {
"data": {
"edgeType": "default",
},
"deletable": false,
"deletable": true,
"id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"selectable": true,
"source": "trigger",
"target": "step-1",
"type": "empty-filter--editable",
@@ -161,9 +172,15 @@ describe('getWorkflowVersionDiagram', () => {
"nodes": [
{
"data": {
"hasNextStepIds": true,
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"position": {
"x": 0,
"y": 0,
},
"stepId": "trigger",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
@@ -175,8 +192,14 @@ describe('getWorkflowVersionDiagram', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "",
"nodeType": "action",
"position": {
"x": 0,
"y": 150,
},
"stepId": "step-1",
},
"id": "step-1",
"position": {
@@ -1,132 +0,0 @@
import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isCreateStepNode } from '../isCreateStepNode';
describe('isCreateStepNode', () => {
it('should return true for create-step node with correct type and nodeType', () => {
const createStepNode: WorkflowDiagramNode = {
id: 'create-step-1',
type: 'create-step',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
},
};
const result = isCreateStepNode(createStepNode);
expect(result).toBe(true);
});
it('should return false for node with create-step type but wrong nodeType', () => {
const node: WorkflowDiagramNode = {
id: 'fake-create-step',
type: 'create-step',
position: { x: 0, y: 200 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
} as any,
};
const result = isCreateStepNode(node);
expect(result).toBe(false);
});
it('should return false for node with correct nodeType but wrong type', () => {
const node: WorkflowDiagramNode = {
id: 'fake-create-step',
type: 'action',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
} as any,
};
const result = isCreateStepNode(node);
expect(result).toBe(false);
});
it('should return false for action node', () => {
const actionNode: WorkflowDiagramNode = {
id: 'action-1',
position: { x: 0, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
},
};
const result = isCreateStepNode(actionNode);
expect(result).toBe(false);
});
it('should return false for trigger node', () => {
const triggerNode: WorkflowDiagramNode = {
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
},
};
const result = isCreateStepNode(triggerNode);
expect(result).toBe(false);
});
it('should return false for empty-trigger node', () => {
const emptyTriggerNode: WorkflowDiagramNode = {
id: 'empty-trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'empty-trigger',
},
};
const result = isCreateStepNode(emptyTriggerNode);
expect(result).toBe(false);
});
it('should handle create-step node with additional properties', () => {
const createStepNodeWithExtras: WorkflowDiagramNode = {
id: 'create-step-with-extras',
type: 'create-step',
position: { x: 50, y: 250 },
selected: true,
data: {
nodeType: 'create-step',
parentNodeId: 'action-2',
},
};
const result = isCreateStepNode(createStepNodeWithExtras);
expect(result).toBe(true);
});
it('should return false for node without type property', () => {
const nodeWithoutType: WorkflowDiagramNode = {
id: 'node-without-type',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
} as any,
};
const result = isCreateStepNode(nodeWithoutType);
expect(result).toBe(false);
});
});
@@ -11,6 +11,9 @@ describe('isStepNode', () => {
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
stepId: 'trigger',
hasNextStepIds: false,
position: { x: 0, y: 0 },
},
};
@@ -27,6 +30,9 @@ describe('isStepNode', () => {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
stepId: 'action-1',
hasNextStepIds: false,
position: { x: 0, y: 0 },
},
};
@@ -35,27 +41,13 @@ describe('isStepNode', () => {
expect(result).toBe(true);
});
it('should return false for create-step node', () => {
const createStepNode: WorkflowDiagramNode = {
id: 'create-step-1',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
},
};
const result = isStepNode(createStepNode);
expect(result).toBe(false);
});
it('should return false for empty-trigger node', () => {
const emptyTriggerNode: WorkflowDiagramNode = {
id: 'empty-trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'empty-trigger',
position: { x: 0, y: 0 },
},
};
@@ -74,6 +66,9 @@ describe('isStepNode', () => {
triggerType: 'MANUAL',
name: 'Manual Trigger',
icon: 'IconClick',
stepId: 'trigger-with-extra',
hasNextStepIds: false,
position: { x: 0, y: 0 },
},
};
@@ -9,6 +9,9 @@ it('Preserves the properties defined in the previous version but not in the next
nodeType: 'action',
name: '',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '',
},
id: '1',
position: { x: 0, y: 0 },
@@ -24,6 +27,9 @@ it('Preserves the properties defined in the previous version but not in the next
nodeType: 'action',
name: '',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '',
},
id: '1',
position: { x: 0, y: 0 },
@@ -40,8 +46,14 @@ it('Preserves the properties defined in the previous version but not in the next
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "",
"nodeType": "action",
"position": {
"x": 0,
"y": 0,
},
"stepId": "",
},
"id": "1",
"position": {
@@ -63,6 +75,9 @@ it('Replaces duplicated properties with the next value', () => {
nodeType: 'action',
name: '',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '',
},
id: '1',
position: { x: 0, y: 0 },
@@ -77,6 +92,9 @@ it('Replaces duplicated properties with the next value', () => {
nodeType: 'action',
name: '2',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '',
},
id: '1',
position: { x: 0, y: 0 },
@@ -93,8 +111,14 @@ it('Replaces duplicated properties with the next value', () => {
{
"data": {
"actionType": "CODE",
"hasNextStepIds": false,
"name": "2",
"nodeType": "action",
"position": {
"x": 0,
"y": 0,
},
"stepId": "",
},
"id": "1",
"position": {
@@ -13,6 +13,9 @@ describe('selectWorkflowDiagramNode', () => {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '1',
},
},
{
@@ -23,6 +26,9 @@ describe('selectWorkflowDiagramNode', () => {
name: 'Node 2',
nodeType: 'action',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 150 },
stepId: '2',
},
},
],
@@ -49,6 +55,9 @@ describe('selectWorkflowDiagramNode', () => {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '1',
},
},
],
@@ -74,6 +83,9 @@ describe('selectWorkflowDiagramNode', () => {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: '1',
},
},
],
@@ -7,7 +7,14 @@ describe('transformFilterNodesAsEdges', () => {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
@@ -16,6 +23,9 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
@@ -47,12 +57,26 @@ describe('transformFilterNodesAsEdges', () => {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
{
@@ -61,6 +85,9 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
@@ -91,12 +118,26 @@ describe('transformFilterNodesAsEdges', () => {
expect(result.nodes).toEqual([
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
]);
@@ -123,12 +164,26 @@ describe('transformFilterNodesAsEdges', () => {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B1',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' },
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B1',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B1',
},
position: { x: 0, y: 150 },
},
{
@@ -137,12 +192,22 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
{
id: 'B2',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' },
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B2',
hasNextStepIds: true,
position: { x: 0, y: 450 },
stepId: 'B2',
},
position: { x: 0, y: 450 },
},
{
@@ -151,6 +216,9 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Step D',
hasNextStepIds: true,
position: { x: 0, y: 600 },
stepId: 'D',
},
position: { x: 0, y: 600 },
},
@@ -238,12 +306,26 @@ describe('transformFilterNodesAsEdges', () => {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
],
@@ -267,7 +349,14 @@ describe('transformFilterNodesAsEdges', () => {
expect(result.nodes).toEqual([
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
]);
@@ -285,12 +374,22 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
{
@@ -299,6 +398,9 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
@@ -333,12 +435,22 @@ describe('transformFilterNodesAsEdges', () => {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
]);
@@ -1,46 +0,0 @@
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramEdgeType,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { v4 } from 'uuid';
export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => {
const nodesWithoutTargets = nodes.filter((node) =>
edges.every((edge) => edge.source !== node.id),
);
const updatedNodes: Array<WorkflowDiagramNode> = nodes.slice();
const updatedEdges: Array<WorkflowDiagramEdge> = edges.slice();
for (const node of nodesWithoutTargets) {
const newCreateStepNode: WorkflowDiagramNode = {
// FIXME: We need a stable id for create step nodes to be able to preserve their selected status.
// FIXME: In the future, we'll have conditions and loops. We'll have to set an id to each branch so we can have this stable id.
id: 'branch-1__create-step',
type: 'create-step',
data: {
nodeType: 'create-step',
parentNodeId: node.id,
},
position: { x: 0, y: 0 },
};
updatedNodes.push(newCreateStepNode);
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: 'blank' as WorkflowDiagramEdgeType,
id: v4(),
source: node.id,
target: newCreateStepNode.id,
});
}
return {
nodes: updatedNodes,
edges: updatedEdges,
};
};
@@ -1,7 +1,6 @@
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import { FIRST_NODE_POSITION } from '@/workflow/workflow-diagram/constants/FirstNodePosition';
import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes';
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import {
WorkflowDiagram,
@@ -15,58 +14,8 @@ import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
/**
* Groups workflow steps into levels based on their distance from root nodes.
*
* A root node is one that is not referenced as a `nextStepId` by any other step.
* The function performs a breadth-first traversal from all roots and assigns
* each step to a level indicating its depth in the graph.
*
* Returns an array where each sub-array contains all steps at the same level.
*/
const groupStepsByLevel = (steps: WorkflowStep[]): WorkflowStep[][] => {
const stepMap = new Map<string, WorkflowStep>();
const childIds = new Set<string>();
for (const step of steps) {
stepMap.set(step.id, step);
step.nextStepIds?.forEach((id) => childIds.add(id));
}
const rootSteps = steps.filter((step) => !childIds.has(step.id));
const stepsByLevel: WorkflowStep[][] = [];
const visited = new Set<string>();
const visit = ({ step, level }: { step: WorkflowStep; level: number }) => {
if (visited.has(step.id)) {
return;
}
visited.add(step.id);
if (!isDefined(stepsByLevel[level])) {
stepsByLevel[level] = [];
}
stepsByLevel[level].push(step);
step.nextStepIds?.forEach((childId) => {
const child = stepMap.get(childId);
if (isDefined(child)) {
visit({ step: child, level: level + 1 });
}
});
};
rootSteps.forEach((root) => visit({ step: root, level: 0 }));
return stepsByLevel;
};
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
export const generateWorkflowDiagram = ({
trigger,
@@ -84,40 +33,61 @@ export const generateWorkflowDiagram = ({
nodes.push(getWorkflowDiagramTriggerNode({ trigger }));
} else {
nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION);
}
const stepsGroupedByLevel = groupStepsByLevel(steps);
const triggerNextStepIds = isDefined(steps) ? getRootStepIds(steps) : [];
triggerNextStepIds.forEach((stepId) => {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: defaultEdgeType,
...(defaultEdgeType.includes('editable')
? { deletable: true, selectable: true }
: {}),
id: v4(),
source: 'trigger',
target: stepId,
});
});
}
let levelYPos = FIRST_NODE_POSITION.y;
const xPos = FIRST_NODE_POSITION.x;
for (const stepsByLevel of stepsGroupedByLevel) {
for (const step of steps) {
levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES;
for (const step of stepsByLevel) {
nodes.push({
id: step.id,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
} satisfies WorkflowDiagramStepNodeData,
position: {
nodes.push({
id: step.id,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
hasNextStepIds:
isDefined(step.nextStepIds) && step.nextStepIds.length > 0,
stepId: step.id,
position: step.position ?? {
x: xPos,
y: levelYPos,
},
});
}
} satisfies WorkflowDiagramStepNodeData,
position: step.position ?? {
x: xPos,
y: levelYPos,
},
});
}
for (const firstLevelStep of stepsGroupedByLevel[0] || []) {
for (const stepLinkToTriggerId of trigger?.nextStepIds ?? []) {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: defaultEdgeType,
...(defaultEdgeType.includes('editable')
? { deletable: true, selectable: true }
: {}),
id: v4(),
source: TRIGGER_STEP_ID,
target: firstLevelStep.id,
target: stepLinkToTriggerId,
});
}
@@ -126,6 +96,9 @@ export const generateWorkflowDiagram = ({
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: defaultEdgeType,
...(defaultEdgeType.includes('editable')
? { deletable: true, selectable: true }
: {}),
id: v4(),
source: step.id,
target: child,
@@ -5,17 +5,16 @@ export const getOrganizedDiagram = (
diagram: WorkflowDiagram,
): WorkflowDiagram => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({ rankdir: 'TB' });
const biggestNodeWidth = diagram.nodes.reduce(
(acc, node) => Math.max(acc, node.measured?.width ?? 0),
0,
);
graph.setGraph({
ranksep: 80, // Vertical distance between 2 nodes
nodesep: 200, // Horizontal distance between 2 nodes
rankdir: 'TB',
});
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
diagram.nodes.forEach((node) =>
graph.setNode(node.id, {
width: biggestNodeWidth,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
@@ -63,8 +63,15 @@ export const getWorkflowDiagramTriggerNode = ({
triggerType: trigger.type,
name: isDefined(trigger.name) ? trigger.name : triggerDefaultLabel,
icon: triggerIcon,
stepId: 'trigger',
hasNextStepIds:
isDefined(trigger.nextStepIds) && trigger.nextStepIds.length > 0,
position: trigger.position ?? {
x: 0,
y: 0,
},
} satisfies WorkflowDiagramStepNodeData,
position: {
position: trigger.position ?? {
x: 0,
y: 0,
},
@@ -4,8 +4,8 @@ import {
WorkflowDiagramEdgeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
@@ -1,15 +0,0 @@
import { CREATE_STEP_STEP_ID } from '@/workflow/workflow-diagram/constants/CreateStepStepId';
import {
WorkflowDiagramCreateStepNodeData,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const isCreateStepNode = (
node: WorkflowDiagramNode,
): node is WorkflowDiagramNode & {
data: WorkflowDiagramCreateStepNodeData;
} => {
return (
node.type === CREATE_STEP_STEP_ID && node.data.nodeType === 'create-step'
);
};
@@ -6,7 +6,9 @@ import { useCreateStep } from '../useCreateStep';
const mockCreateDraftFromWorkflowVersion = jest.fn().mockResolvedValue('457');
const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: { createWorkflowVersionStep: { id: '1', type: 'CODE' } },
data: {
createWorkflowVersionStep: { createdStep: { id: '1', type: 'CODE' } },
},
});
jest.mock(
@@ -0,0 +1,50 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useCreateWorkflowVersionEdge } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge';
import { useState } from 'react';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
export const useCreateEdge = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const [isLoading, setIsLoading] = useState(false);
const { createWorkflowVersionEdge } = useCreateWorkflowVersionEdge();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const createEdge = async ({
source,
target,
}: {
source: string;
target: string;
}) => {
if (isLoading === true) {
return;
}
setIsLoading(true);
try {
const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
const createdEdge = (
await createWorkflowVersionEdge({
workflowVersionId,
source,
target,
})
)?.data?.createWorkflowVersionEdge;
if (!createdEdge) {
return;
}
} finally {
setIsLoading(false);
}
};
return { createEdge };
};
@@ -36,10 +36,12 @@ export const useCreateStep = ({
newStepType,
parentStepId,
nextStepId,
position,
}: {
newStepType: WorkflowStepType;
parentStepId: string;
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
}) => {
if (isLoading === true) {
return;
@@ -50,15 +52,18 @@ export const useCreateStep = ({
try {
const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
const createdStep = (
const workflowVersionStepChanges = (
await createWorkflowVersionStep({
workflowVersionId,
stepType: newStepType,
parentStepId,
nextStepId,
position,
})
)?.data?.createWorkflowVersionStep;
const createdStep = workflowVersionStepChanges?.createdStep;
if (!isDefined(createdStep)) {
throw new Error("Couldn't create step");
}
@@ -0,0 +1,37 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useMutation } from '@apollo/client';
import {
CreateWorkflowVersionEdgeMutation,
CreateWorkflowVersionEdgeMutationVariables,
} from '~/generated-metadata/graphql';
import { CREATE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/createWorkflowVersionEdge';
import { CreateWorkflowVersionEdgeInput } from '~/generated/graphql';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
export const useCreateWorkflowVersionEdge = () => {
const apolloCoreClient = useApolloCoreClient();
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const [mutate] = useMutation<
CreateWorkflowVersionEdgeMutation,
CreateWorkflowVersionEdgeMutationVariables
>(CREATE_WORKFLOW_VERSION_EDGE, { client: apolloCoreClient });
const createWorkflowVersionEdge = async (
input: CreateWorkflowVersionEdgeInput,
) => {
const result = await mutate({ variables: { input } });
const workflowVersionStepChanges = result?.data?.createWorkflowVersionEdge;
updateWorkflowVersionCache({
workflowVersionStepChanges,
workflowVersionId: input.workflowVersionId,
});
return result;
};
return { createWorkflowVersionEdge };
};
@@ -1,36 +1,25 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { CREATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/createWorkflowVersionStep';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { useMutation } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import {
CreateWorkflowVersionStepInput,
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables,
} from '~/generated-metadata/graphql';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
export const useCreateWorkflowVersionStep = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const [mutate] = useMutation<
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables
>(CREATE_WORKFLOW_VERSION_STEP, {
client: apolloCoreClient,
});
const createWorkflowVersionStep = async (
input: CreateWorkflowVersionStepInput,
) => {
@@ -38,57 +27,13 @@ export const useCreateWorkflowVersionStep = () => {
variables: { input },
});
const insertedStep = result?.data?.createWorkflowVersionStep;
const workflowVersionStepChanges = result?.data?.createWorkflowVersionStep;
if (!isDefined(insertedStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!isDefined(cachedRecord)) {
return;
}
const { parentStepId, nextStepId } = input;
const updatedExistingSteps =
cachedRecord.steps?.map((existingStep) => {
if (existingStep.id === parentStepId) {
return {
...existingStep,
nextStepIds: [
...new Set([
...(existingStep.nextStepIds?.filter(
(id) => id !== nextStepId,
) || []),
insertedStep.id,
]),
],
};
}
return existingStep;
}) ?? [];
const newCachedRecord = {
...cachedRecord,
steps: [...updatedExistingSteps, insertedStep],
};
const recordGqlFields = {
steps: true,
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: newCachedRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
updateWorkflowVersionCache({
workflowVersionStepChanges,
workflowVersionId: input.workflowVersionId,
});
return result;
};
@@ -0,0 +1,50 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useDeleteWorkflowVersionEdge } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge';
import { useState } from 'react';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
export const useDeleteEdge = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const [isLoading, setIsLoading] = useState(false);
const { deleteWorkflowVersionEdge } = useDeleteWorkflowVersionEdge();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const deleteEdge = async ({
source,
target,
}: {
source: string;
target: string;
}) => {
if (isLoading === true) {
return;
}
setIsLoading(true);
try {
const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
const deletedEdge = (
await deleteWorkflowVersionEdge({
workflowVersionId,
source,
target,
})
)?.data?.deleteWorkflowVersionEdge;
if (!deletedEdge) {
return;
}
} finally {
setIsLoading(false);
}
};
return { deleteEdge };
};
@@ -0,0 +1,37 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useMutation } from '@apollo/client';
import { CreateWorkflowVersionEdgeInput } from '~/generated/graphql';
import { DELETE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/deleteWorkflowVersionEdge';
import {
DeleteWorkflowVersionEdgeMutation,
DeleteWorkflowVersionEdgeMutationVariables,
} from '~/generated-metadata/graphql';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
export const useDeleteWorkflowVersionEdge = () => {
const apolloCoreClient = useApolloCoreClient();
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const [mutate] = useMutation<
DeleteWorkflowVersionEdgeMutation,
DeleteWorkflowVersionEdgeMutationVariables
>(DELETE_WORKFLOW_VERSION_EDGE, { client: apolloCoreClient });
const deleteWorkflowVersionEdge = async (
input: CreateWorkflowVersionEdgeInput,
) => {
const result = await mutate({ variables: { input } });
const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionEdge;
updateWorkflowVersionCache({
workflowVersionStepChanges,
workflowVersionId: input.workflowVersionId,
});
return result;
};
return { deleteWorkflowVersionEdge };
};
@@ -0,0 +1,95 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import {
WorkflowAction,
WorkflowVersionStepChanges,
} from '~/generated/graphql';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
export const useUpdateWorkflowVersionCache = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const updateWorkflowVersionCache = ({
workflowVersionStepChanges,
workflowVersionId,
}: {
workflowVersionStepChanges: WorkflowVersionStepChanges | undefined;
workflowVersionId: string;
}) => {
if (!isDefined(workflowVersionStepChanges)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(workflowVersionId);
if (!isDefined(cachedRecord)) {
return;
}
const { triggerNextStepIds, stepsNextStepIds, createdStep, deletedStepId } =
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,
})),
};
if (isDefined(createdStep)) {
const formattedCreatedStep = {
...createdStep,
nextStepIds: createdStep.nextStepIds || [],
};
newCachedRecord.steps.push(formattedCreatedStep);
}
if (isDefined(deletedStepId)) {
newCachedRecord.steps = newCachedRecord.steps.filter(
(step) => step.id !== deletedStepId,
);
}
const recordGqlFields = {
steps: true,
trigger: true,
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: newCachedRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
};
return { updateWorkflowVersionCache };
};
@@ -4,11 +4,16 @@ import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-
type WorkflowInsertStepIdsState = {
parentStepId: string | undefined;
nextStepId: string | undefined;
position?: { x: number; y: number };
};
export const workflowInsertStepIdsComponentState =
createComponentStateV2<WorkflowInsertStepIdsState>({
key: 'workflowInsertStepIdsComponentState',
defaultValue: { parentStepId: undefined, nextStepId: undefined },
defaultValue: {
parentStepId: undefined,
nextStepId: undefined,
position: undefined,
},
componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
});
@@ -9,7 +9,7 @@ import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/const
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
@@ -17,7 +17,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
@@ -10,7 +10,7 @@ import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/workflow-trigger
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -14,7 +14,7 @@ import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trig
import { WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
@@ -0,0 +1,98 @@
import { act, renderHook } from '@testing-library/react';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import {
WorkflowTrigger,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
const mockUpdateOneRecord = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockComputeStepOutputSchema = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: jest.fn(() => ({
updateOneRecord: mockUpdateOneRecord,
})),
}));
jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersion', () => ({
useGetUpdatableWorkflowVersion: jest.fn(() => ({
getUpdatableWorkflowVersion: mockGetUpdatableWorkflowVersion,
})),
}));
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
useComputeStepOutputSchema: jest.fn(() => ({
computeStepOutputSchema: mockComputeStepOutputSchema,
})),
}));
describe('useUpdateWorkflowVersionTrigger', () => {
const mockWorkflow = {
id: 'workflow-id',
currentVersion: { id: 'version-id' },
} as WorkflowWithCurrentVersion;
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
nextStepIds: ['step1'],
};
beforeEach(() => {
jest.clearAllMocks();
});
it('updates the trigger with computed output schema', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
mockComputeStepOutputSchema.mockResolvedValue({
data: { computeStepOutputSchema: { field1: 'string' } },
});
const { result } = renderHook(() =>
useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }),
);
await act(async () => {
await result.current.updateTrigger(trigger);
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalledWith(mockWorkflow);
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({ step: trigger });
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
trigger: {
...trigger,
settings: { ...trigger.settings, outputSchema: { field1: 'string' } },
},
},
});
});
it('skips output schema computation when disabled', async () => {
mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id');
const { result } = renderHook(() =>
useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }),
);
await act(async () => {
await result.current.updateTrigger(trigger, {
computeOutputSchema: false,
});
});
expect(mockComputeStepOutputSchema).not.toHaveBeenCalled();
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
trigger,
},
});
});
});
@@ -28,6 +28,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -45,6 +50,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.updated`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -62,6 +72,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.deleted`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -79,6 +94,11 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -97,6 +117,11 @@ describe('getTriggerDefaultDefinition', () => {
outputSchema: {},
icon: COMMAND_MENU_DEFAULT_ICON,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -115,6 +140,11 @@ describe('getTriggerDefaultDefinition', () => {
schedule: { day: 1, hour: 0, minute: 0 },
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -133,6 +163,11 @@ describe('getTriggerDefaultDefinition', () => {
httpMethod: 'GET',
authentication: null,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
},
});
});
@@ -1,6 +1,6 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { DatabaseTriggerDefaultLabel } from '@/workflow/workflow-trigger/constants/DatabaseTriggerDefaultLabel';
import { getTriggerDefaultLabel } from '../getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
describe('getTriggerDefaultLabel', () => {
describe('DATABASE_EVENT triggers', () => {
@@ -0,0 +1,39 @@
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
describe('getTriggerIcon', () => {
it('should return proper Icon for DATABASE_EVENT', () => {
expect(
getTriggerIcon({
type: 'DATABASE_EVENT',
settings: { eventName: 'company.created', outputSchema: {} },
}),
).toBe('IconPlaylistAdd');
});
it('should return proper Icon for MANUAL', () => {
expect(
getTriggerIcon({
type: 'MANUAL',
settings: { outputSchema: {} },
}),
).toBe('IconHandMove');
});
it('should return proper Icon for CRON', () => {
expect(
getTriggerIcon({
type: 'CRON',
settings: { outputSchema: {}, type: 'CUSTOM', pattern: '' },
}),
).toBe('IconClock');
});
it('should return proper Icon for CRON', () => {
expect(
getTriggerIcon({
type: 'WEBHOOK',
settings: { outputSchema: {}, httpMethod: 'GET', authentication: null },
}),
).toBe('IconWebhook');
});
});
@@ -0,0 +1,11 @@
import { WorkflowAction } from '@/workflow/types/Workflow';
export const getRootStepIds = (steps: WorkflowAction[]): string[] => {
const childIds = new Set<string>();
for (const step of steps) {
step.nextStepIds?.forEach((id) => childIds.add(id));
}
return steps.filter((step) => !childIds.has(step.id)).map((step) => step.id);
};
@@ -1,20 +1,26 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
WorkflowAction,
WorkflowTrigger,
WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { isDefined } from 'twenty-shared/utils';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
// TODO: This needs to be migrated to the server
export const getTriggerDefaultDefinition = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
steps,
}: {
defaultLabel: string;
type: WorkflowTriggerType;
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
steps?: WorkflowAction[] | null;
}): WorkflowTrigger => {
if (activeNonSystemObjectMetadataItems.length === 0) {
throw new Error(
@@ -22,11 +28,19 @@ export const getTriggerDefaultDefinition = ({
);
}
const nextStepIds = isDefined(steps) ? getRootStepIds(steps) : [];
const baseTriggerDefinition = {
name: defaultLabel,
position: { x: 0, y: 0 },
nextStepIds,
};
switch (type) {
case 'DATABASE_EVENT': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
eventName: `${activeNonSystemObjectMetadataItems[0].nameSingular}.${
DATABASE_TRIGGER_TYPES.find(
@@ -39,8 +53,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'MANUAL': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: getManualTriggerDefaultSettings({
availability: 'WHEN_RECORD_SELECTED',
activeNonSystemObjectMetadataItems,
@@ -49,8 +63,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'CRON': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
type: 'DAYS',
schedule: { day: 1, hour: 0, minute: 0 },
@@ -60,8 +74,8 @@ export const getTriggerDefaultDefinition = ({
}
case 'WEBHOOK': {
return {
...baseTriggerDefinition,
type,
name: defaultLabel,
settings: {
outputSchema: {},
httpMethod: 'GET',
@@ -1,5 +1,5 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { assertUnreachable } from 'twenty-shared/utils';
export const getTriggerHeaderType = (trigger: WorkflowTrigger) => {
@@ -0,0 +1,14 @@
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
describe('isBaseOutputSchema', () => {
// This looks weird, but that is the way this method was built
it('should return false for base output schema', () => {
expect(
isBaseOutputSchema({ _outputSchemaType: 'LINK', link: { isLeaf: true } }),
).toBe(false);
});
it('should return true in other cases', () => {
expect(isBaseOutputSchema({})).toBe(true);
});
});
@@ -0,0 +1,16 @@
import { isFieldTypeCompatibleWithRecordId } from '@/workflow/workflow-variables/utils/isFieldTypeCompatibleWithRecordId';
describe('isFieldTypeCompatibleWithRecordId', () => {
it('should return true', () => {
expect(isFieldTypeCompatibleWithRecordId('string')).toBe(true);
expect(isFieldTypeCompatibleWithRecordId('unknown')).toBe(true);
expect(isFieldTypeCompatibleWithRecordId()).toBe(true);
expect(isFieldTypeCompatibleWithRecordId(undefined)).toBe(true);
});
it('should return false', () => {
expect(isFieldTypeCompatibleWithRecordId('number')).toBe(false);
expect(isFieldTypeCompatibleWithRecordId('boolean')).toBe(false);
expect(isFieldTypeCompatibleWithRecordId('object')).toBe(false);
expect(isFieldTypeCompatibleWithRecordId('array')).toBe(false);
});
});
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKFLOW_VERSION_POSITIONS = gql`
mutation UpdateWorkflowVersionPositions(
$input: UpdateWorkflowVersionPositionsInput!
) {
updateWorkflowVersionPositions(input: $input)
}
`;
@@ -0,0 +1,131 @@
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client';
import {
UpdateWorkflowVersionPositionsMutation,
UpdateWorkflowVersionPositionsMutationVariables,
WorkflowAction,
} from '~/generated-metadata/graphql';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { UPDATE_WORKFLOW_VERSION_POSITIONS } from '@/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
export const useTidyUpWorkflowVersion = ({
workflow,
}: {
workflow?: WorkflowWithCurrentVersion;
}) => {
const [workflowDiagram, setWorkflowDiagram] = useRecoilComponentStateV2(
workflowDiagramComponentState,
);
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
UpdateWorkflowVersionPositionsMutation,
UpdateWorkflowVersionPositionsMutationVariables
>(UPDATE_WORKFLOW_VERSION_POSITIONS, { client: apolloCoreClient });
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const updateWorkflowVersionPosition = async (
positions: { id: string; position: { x: number; y: number } }[],
) => {
if (!isDefined(workflow?.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
await mutate({ variables: { input: { workflowVersionId, positions } } });
const cachedRecord = getRecordFromCache<WorkflowVersion>(workflowVersionId);
if (!isDefined(cachedRecord)) {
return;
}
const triggerPosition = positions.find(
(position) => position.id === 'trigger',
);
const updatedTrigger =
isDefined(triggerPosition) && isDefined(cachedRecord.trigger)
? { ...cachedRecord.trigger, position: triggerPosition.position }
: cachedRecord.trigger;
const updatedSteps = cachedRecord.steps?.map((step: WorkflowAction) => {
const stepPosition = positions.find(
(position) => position.id === step.id,
);
if (!isDefined(stepPosition)) {
return step;
}
return { ...step, position: stepPosition.position };
});
const newCachedRecord = {
...cachedRecord,
trigger: updatedTrigger,
steps: updatedSteps,
};
const recordGqlFields = {
trigger: true,
steps: true,
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: newCachedRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
};
const tidyUpWorkflowVersion = async () => {
if (!isDefined(workflowDiagram) || !isDefined(workflow?.currentVersion)) {
return;
}
const tidiedUpDiagram = getOrganizedDiagram(workflowDiagram);
const positions = tidiedUpDiagram.nodes.map((node) => ({
id: node.id,
position: node.position,
}));
await updateWorkflowVersionPosition(positions);
setWorkflowDiagram(tidiedUpDiagram);
};
return { tidyUpWorkflowVersion };
};
@@ -0,0 +1,92 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@Command({
name: 'upgrade:1-3:add-next-step-ids-to-workflow-runs-trigger',
description: 'Add next step ids to workflow runs trigger',
})
export class AddNextStepIdsToWorkflowRunsTrigger extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
const mainDataSource =
await this.workspaceDataSourceService.connectToMainDataSource();
const schemaName = getWorkspaceSchemaName(workspaceId);
const workflowRuns = await mainDataSource.query(
`SELECT id, state FROM ${schemaName}."workflowRun"`,
);
let updatedWorkflowRunCount = 0;
for (const workflowRun of workflowRuns) {
try {
if (isDefined(workflowRun.state.flow.trigger.nextStepIds)) {
continue;
}
const rootSteps = this.getRootSteps(workflowRun.state.flow.steps || []);
const nextStepIds = rootSteps.map((step) => step.id);
const updatedState = {
...workflowRun.state,
flow: {
...workflowRun.state.flow,
trigger: {
...workflowRun.state.flow.trigger,
nextStepIds,
},
},
};
await mainDataSource.query(
`UPDATE ${schemaName}."workflowRun" SET state = '${JSON.stringify(updatedState)}'::jsonb`,
);
updatedWorkflowRunCount += 1;
} catch (error) {
this.logger.error(
`Error while adding nextStepIds to workflowRun state '${workflowRun.id}'`,
error,
);
}
}
this.logger.log(
`${updatedWorkflowRunCount}/${workflowRuns.length} triggers updated`,
);
}
private getRootSteps(steps: WorkflowAction[]): WorkflowAction[] {
const childIds = new Set<string>();
for (const step of steps) {
step.nextStepIds?.forEach((id) => childIds.add(id));
}
return steps.filter((step) => !childIds.has(step.id));
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command';
import { AssignRolesToExistingApiKeysCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-assign-roles-to-existing-api-keys.command';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
@@ -14,6 +15,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
@@ -28,6 +30,7 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
],
'core',
),
WorkspaceDataSourceModule,
ApiKeyModule,
FeatureFlagModule,
TwentyORMModule,
@@ -35,7 +38,13 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
WorkspacePermissionsCacheModule,
WorkspaceFeatureFlagsMapCacheModule,
],
providers: [AssignRolesToExistingApiKeysCommand],
exports: [AssignRolesToExistingApiKeysCommand],
providers: [
AssignRolesToExistingApiKeysCommand,
AddNextStepIdsToWorkflowRunsTrigger,
],
exports: [
AssignRolesToExistingApiKeysCommand,
AddNextStepIdsToWorkflowRunsTrigger,
],
})
export class V1_3_UpgradeVersionCommandModule {}
@@ -34,6 +34,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major';
import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command';
const execPromise = promisify(exec);
@@ -157,6 +158,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.3 Commands
protected readonly assignRolesToExistingApiKeysCommand: AssignRolesToExistingApiKeysCommand,
// protected readonly addNextStepIdsToWorkflowVersionTriggers: AddNextStepIdsToWorkflowVersionTriggers,
protected readonly addNextStepIdsToWorkflowRunsTrigger: AddNextStepIdsToWorkflowRunsTrigger,
) {
super(
workspaceRepository,
@@ -216,7 +219,10 @@ export class UpgradeCommand extends UpgradeCommandRunner {
};
const commands_130: VersionCommands = {
beforeSyncMetadata: [],
beforeSyncMetadata: [
this.addNextStepIdsToWorkflowVersionTriggers, // We add that command again because nextStepIds where not added on freshly created triggers. It will be done in 1.3
this.addNextStepIdsToWorkflowRunsTrigger,
],
afterSyncMetadata: [],
};
@@ -8,6 +8,7 @@ export enum FeatureFlagKey {
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
@@ -0,0 +1,22 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class CreateWorkflowVersionEdgeInput {
@Field(() => String, {
description: 'Workflow version ID',
nullable: false,
})
workflowVersionId: string;
@Field(() => String, {
description: 'Workflow version source step ID',
nullable: false,
})
source: string;
@Field(() => String, {
description: 'Workflow version target step ID',
nullable: false,
})
target: string;
}
@@ -2,6 +2,7 @@ import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto';
@InputType()
export class CreateWorkflowVersionStepInput {
@@ -17,7 +18,8 @@ export class CreateWorkflowVersionStepInput {
})
stepType: WorkflowActionType;
@Field(() => UUIDScalarType, {
// Typed String as it can be 'trigger'
@Field(() => String, {
description: 'Parent step ID',
nullable: true,
})
@@ -28,4 +30,10 @@ export class CreateWorkflowVersionStepInput {
nullable: true,
})
nextStepId?: string;
@Field(() => WorkflowStepPositionInput, {
description: 'Step position',
nullable: true,
})
position?: WorkflowStepPositionInput;
}
@@ -0,0 +1,10 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class WorkflowStepPositionInput {
@Field(() => Number)
x: number;
@Field(() => Number)
y: number;
}

Some files were not shown because too many files have changed in this diff Show More