Update workflow nodes configuration (#6861)

- Improve the design of the right drawer
- Allow to update the trigger of the workflow: the object and the event
listened to
- Allow to update the selected serverless function that a code action
should execute
- Change how we determine which workflow version to display in the
visualizer. We fetch the selected workflow's data, including whether it
has a draft or a published version. If the workflow has a draft version,
it gets displayed; otherwise, we display the last published version.
- I used the type `WorkflowWithCurrentVersion` to forward the currently
edited workflow with its _current_ version embedded across the app.
- I created single-responsibility hooks like
`useFindWorkflowWithCurrentVersion`, `useFindShowPageWorkflow`,
`useUpdateWorkflowVersionTrigger` or `useUpdateWorkflowVersionStep`.
- I updated the types for workflow related objects, like `Workflow` and
`WorkflowVersion`. See
`packages/twenty-front/src/modules/workflow/types/Workflow.ts`.
- This introduced the possibility to have `null` values for triggers and
steps. I made the according changes in the codebase and in the tests.
- I created a utility function to extract both parts of object-event
format (`company.created`):
`packages/twenty-front/src/modules/workflow/utils/splitWorkflowTriggerEventName.ts`
This commit is contained in:
Baptiste Devessier
2024-09-04 17:39:28 +02:00
committed by GitHub
parent c55dfbde6e
commit a2b1062db6
46 changed files with 1056 additions and 498 deletions

View File

@ -18,7 +18,7 @@ describe('generateWorkflowDiagram', () => {
expect(result.nodes[0]).toMatchObject({
data: {
label: trigger.settings.eventName,
label: 'Company is Created',
nodeType: 'trigger',
},
});

View File

@ -1,79 +0,0 @@
import { Workflow } from '@/workflow/types/Workflow';
import { getWorkflowLastDiagramVersion } from '../getWorkflowLastDiagramVersion';
describe('getWorkflowLastDiagramVersion', () => {
it('returns an empty diagram if the provided workflow is undefined', () => {
const result = getWorkflowLastDiagramVersion(undefined);
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow has no versions', () => {
const result = getWorkflowLastDiagramVersion({
__typename: 'Workflow',
id: 'aa',
name: 'aa',
publishedVersionId: '',
versions: [],
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns the diagram for the last version', () => {
const workflow: Workflow = {
__typename: 'Workflow',
id: 'aa',
name: 'aa',
publishedVersionId: '',
versions: [
{
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
},
{
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
},
],
};
const result = getWorkflowLastDiagramVersion(workflow);
// Corresponds to the trigger + 1 step
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
});

View File

@ -0,0 +1,79 @@
import { getWorkflowVersionDiagram } from '../getWorkflowVersionDiagram';
describe('getWorkflowVersionDiagram', () => {
it('returns an empty diagram if the provided workflow version', () => {
const result = getWorkflowVersionDiagram(undefined);
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow version has no trigger', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: null,
updatedAt: '',
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow version has no steps', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: null,
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns the diagram for the last version', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
// Corresponds to the trigger + 1 step
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
});

View File

@ -3,8 +3,9 @@ import { insertStep } from '../insertStep';
describe('insertStep', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -15,7 +16,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
@ -40,8 +41,9 @@ describe('insertStep', () => {
});
it('adds the step when the steps array is empty', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -52,7 +54,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
@ -78,8 +80,9 @@ describe('insertStep', () => {
});
it('adds the step at the end of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -117,7 +120,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-3',
name: '',
@ -147,8 +150,9 @@ describe('insertStep', () => {
});
it('adds the step in the middle of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -186,7 +190,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-3',
name: '',

View File

@ -0,0 +1,127 @@
import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow';
import { replaceStep } from '../replaceStep';
describe('replaceStep', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => {
const stepToBeReplaced = {
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'first',
},
type: 'CODE_ACTION',
valid: true,
} satisfies WorkflowStep;
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [stepToBeReplaced],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
} satisfies WorkflowVersion;
const stepsUpdated = replaceStep({
steps: workflowVersionInitial.steps,
stepToReplace: {
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'second',
},
},
stepId: stepToBeReplaced.id,
});
expect(workflowVersionInitial.steps).not.toBe(stepsUpdated);
});
it('replaces a step in a non-empty steps array', () => {
const stepToBeReplaced: WorkflowStep = {
id: 'step-2',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
stepToBeReplaced,
{
id: 'step-3',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
} satisfies WorkflowVersion;
const updatedStepName = "that's another name";
const stepsUpdated = replaceStep({
stepId: stepToBeReplaced.id,
steps: workflowVersionInitial.steps,
stepToReplace: {
name: updatedStepName,
},
});
const expectedUpdatedSteps: Array<WorkflowStep> = [
workflowVersionInitial.steps[0],
{
...stepToBeReplaced,
name: updatedStepName,
},
workflowVersionInitial.steps[2],
];
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
});
});

View File

@ -0,0 +1,41 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStep } from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
/**
* This function returns the reference of the array where the step should be positioned
* and at which index.
*/
export const findStepPositionOrThrow = ({
steps,
stepId,
}: {
steps: Array<WorkflowStep>;
stepId: string | undefined;
}): { steps: Array<WorkflowStep>; index: number } => {
if (!isDefined(stepId) || stepId === TRIGGER_STEP_ID) {
return {
steps,
index: 0,
};
}
for (const [index, step] of steps.entries()) {
if (step.id === stepId) {
return {
steps,
index,
};
}
// TODO: When condition will have been implemented, put recursivity here.
// if (step.type === "CONDITION") {
// return findNodePosition({
// workflowSteps: step.conditions,
// stepId,
// })
// }
}
throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`);
};

View File

@ -1,11 +1,14 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { MarkerType } from '@xyflow/react';
import { v4 } from 'uuid';
import { capitalize } from '~/utils/string/capitalize';
export const generateWorkflowDiagram = ({
trigger,
@ -58,12 +61,15 @@ export const generateWorkflowDiagram = ({
};
// Start with the trigger node
const triggerNodeId = 'trigger';
const triggerNodeId = TRIGGER_STEP_ID;
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
label: trigger.settings.eventName,
label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
},
position: {
x: 0,

View File

@ -0,0 +1,33 @@
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { v4 } from 'uuid';
export const getStepDefaultDefinition = (
type: WorkflowStepType,
): WorkflowStep => {
const newStepId = v4();
switch (type) {
case 'CODE_ACTION': {
return {
id: newStepId,
name: 'Code',
type: 'CODE_ACTION',
valid: false,
settings: {
serverlessFunctionId: '',
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
};
}
default: {
throw new Error(`Unknown type: ${type}`);
}
}
};

View File

@ -1,28 +0,0 @@
import { Workflow } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion';
import { isDefined } from 'twenty-ui';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
edges: [],
};
export const getWorkflowLastDiagramVersion = (
workflow: Workflow | undefined,
): WorkflowDiagram => {
if (!isDefined(workflow)) {
return EMPTY_DIAGRAM;
}
const lastVersion = getWorkflowLastVersion(workflow);
if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: lastVersion.trigger,
steps: lastVersion.steps,
});
};

View File

@ -1,10 +0,0 @@
import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow';
export const getWorkflowLastVersion = (
workflow: Workflow,
): WorkflowVersion | undefined => {
return workflow.versions
.slice()
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
.at(-1);
};

View File

@ -0,0 +1,28 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
import { isDefined } from 'twenty-ui';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
edges: [],
};
export const getWorkflowVersionDiagram = (
workflowVersion: WorkflowVersion | undefined,
): WorkflowDiagram => {
if (
!(
isDefined(workflowVersion) &&
isDefined(workflowVersion.trigger) &&
isDefined(workflowVersion.steps)
)
) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
};

View File

@ -1,38 +1,5 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
const findStepPositionOrThrow = ({
steps,
stepId,
}: {
steps: Array<WorkflowStep>;
stepId: string | undefined;
}): { steps: Array<WorkflowStep>; index: number } => {
if (stepId === undefined) {
return {
steps,
index: 0,
};
}
for (const [index, step] of steps.entries()) {
if (step.id === stepId) {
return {
steps,
index,
};
}
// TODO: When condition will have been implemented, put recursivity here.
// if (step.type === "CONDITION") {
// return findNodePosition({
// workflowSteps: step.conditions,
// stepId,
// })
// }
}
throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`);
};
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const insertStep = ({
steps: stepsInitial,
@ -43,11 +10,10 @@ export const insertStep = ({
parentStepId: string | undefined;
stepToAdd: WorkflowStep;
}): Array<WorkflowStep> => {
// Make a deep copy of the nested object to prevent unwanted side effects.
const steps = structuredClone(stepsInitial);
const parentStepPosition = findStepPositionOrThrow({
steps: steps,
steps,
stepId: parentStepId,
});

View File

@ -0,0 +1,26 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const replaceStep = ({
steps: stepsInitial,
stepId,
stepToReplace,
}: {
steps: Array<WorkflowStep>;
stepId: string;
stepToReplace: Partial<Omit<WorkflowStep, 'id'>>;
}) => {
const steps = structuredClone(stepsInitial);
const parentStepPosition = findStepPositionOrThrow({
steps,
stepId,
});
parentStepPosition.steps[parentStepPosition.index] = {
...parentStepPosition.steps[parentStepPosition.index],
...stepToReplace,
};
return steps;
};

View File

@ -0,0 +1,8 @@
export const splitWorkflowTriggerEventName = (eventName: string) => {
const [objectType, event] = eventName.split('.');
return {
objectType,
event,
};
};