Create new steps in workflow editor (#6764)

This PR adds the possibility of creating new steps. For now, only
actions are available. The steps are stored on the server, and the
visualizer is reloaded to include them.

Selecting a step opens the right drawer and shows its details. For now,
it's only the id of the step, but in the future, it will be the
parameters of the step.

In the future we'll want to let users add steps at any point in the
diagram. As a consequence, it's crucial to be able to walk in the tree
that make the steps to find the correct place where to put the new step.
I wrote a function that returns where the new step should be inserted.
This function will become recursive once we get branching implemented.

Things to mention:

- Reactflow needs every node and edge to have a unique identifier. In
this PR, I chose to use steps' id as nodes' id. That way, it's easy to
move from a node to a step, which helps make operations on a step
without resolving the step's id from the node's id.
This commit is contained in:
Baptiste Devessier
2024-08-30 15:51:36 +02:00
committed by GitHub
parent 26eba76fb5
commit f7c99ddc7a
33 changed files with 766 additions and 67 deletions

View File

@ -70,8 +70,10 @@ describe('generateWorkflowDiagram', () => {
const stepNodes = result.nodes.slice(1);
for (const [index, step] of steps.entries()) {
expect(stepNodes[index].data.nodeType).toBe('action');
expect(stepNodes[index].data.label).toBe(step.name);
expect(stepNodes[index].data).toEqual({
nodeType: 'action',
label: step.name,
});
}
});

View File

@ -0,0 +1,217 @@
import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow';
import { insertStep } from '../insertStep';
describe('insertStep', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => {
const workflowVersionInitial: WorkflowVersion = {
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
};
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const stepsUpdated = insertStep({
steps: workflowVersionInitial.steps,
stepToAdd,
parentStepId: undefined,
});
expect(workflowVersionInitial.steps).not.toBe(stepsUpdated);
});
it('adds the step when the steps array is empty', () => {
const workflowVersionInitial: WorkflowVersion = {
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
};
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const stepsUpdated = insertStep({
steps: workflowVersionInitial.steps,
stepToAdd,
parentStepId: undefined,
});
const expectedUpdatedSteps: Array<WorkflowStep> = [stepToAdd];
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
});
it('adds the step at the end of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
__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,
},
{
id: 'step-2',
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 stepToAdd: WorkflowStep = {
id: 'step-3',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const stepsUpdated = insertStep({
steps: workflowVersionInitial.steps,
stepToAdd,
parentStepId: workflowVersionInitial.steps[1].id, // Note the selected step.
});
const expectedUpdatedSteps: Array<WorkflowStep> = [
workflowVersionInitial.steps[0],
workflowVersionInitial.steps[1],
stepToAdd,
];
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
});
it('adds the step in the middle of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
__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,
},
{
id: 'step-2',
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 stepToAdd: WorkflowStep = {
id: 'step-3',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const stepsUpdated = insertStep({
steps: workflowVersionInitial.steps,
stepToAdd,
parentStepId: workflowVersionInitial.steps[0].id, // Note the selected step.
});
const expectedUpdatedSteps: Array<WorkflowStep> = [
workflowVersionInitial.steps[0],
stepToAdd,
workflowVersionInitial.steps[1],
];
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
});
});

View File

@ -18,7 +18,10 @@ export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => {
const newCreateStepNode: WorkflowDiagramNode = {
id: v4(),
type: 'create-step',
data: {},
data: {
nodeType: 'create-step',
parentNodeId: node.id,
},
position: { x: 0, y: 0 },
};

View File

@ -24,7 +24,7 @@ export const generateWorkflowDiagram = ({
xPos: number,
yPos: number,
) => {
const nodeId = v4();
const nodeId = step.id;
nodes.push({
id: nodeId,
data: {
@ -58,7 +58,7 @@ export const generateWorkflowDiagram = ({
};
// Start with the trigger node
const triggerNodeId = v4();
const triggerNodeId = 'trigger';
nodes.push({
id: triggerNodeId,
data: {

View File

@ -1,9 +1,6 @@
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import Dagre from '@dagrejs/dagre';
/**
* Set the position of the nodes in the diagram. The positions are computed with a layouting algorithm.
*/
export const getOrganizedDiagram = (
diagram: WorkflowDiagram,
): WorkflowDiagram => {

View File

@ -1,6 +1,7 @@
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 = {
@ -15,7 +16,7 @@ export const getWorkflowLastDiagramVersion = (
return EMPTY_DIAGRAM;
}
const lastVersion = workflow.versions.at(-1);
const lastVersion = getWorkflowLastVersion(workflow);
if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) {
return EMPTY_DIAGRAM;
}

View File

@ -0,0 +1,10 @@
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,61 @@
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}.`);
};
export const insertStep = ({
steps: stepsInitial,
stepToAdd,
parentStepId,
}: {
steps: Array<WorkflowStep>;
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,
stepId: parentStepId,
});
parentStepPosition.steps.splice(
parentStepPosition.index + 1, // The "+ 1" means that we add the step after its parent and not before.
0,
stepToAdd,
);
return steps;
};