Refacto workflow folders (#9302)

- Create separated folders for sections
- Add components
- Add utils and clean old ones
- Add constants
- Rename search variables folder and components

Next steps:
- clean hooks
- clean states
This commit is contained in:
Thomas Trompette
2024-12-31 17:08:14 +01:00
committed by GitHub
parent d4d8883794
commit 9e74ffae52
109 changed files with 195 additions and 840 deletions
@@ -0,0 +1,75 @@
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: {},
},
},
{
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: {},
},
},
];
const diagramInitial = generateWorkflowDiagram({ trigger, steps });
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');
});
});
@@ -0,0 +1,150 @@
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import { generateWorkflowDiagram } from '../generateWorkflowDiagram';
describe('generateWorkflowDiagram', () => {
it('should generate a single trigger node when no step is provided', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const steps: WorkflowStep[] = [];
const result = generateWorkflowDiagram({ trigger, steps });
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(0);
expect(result.nodes[0]).toMatchObject({
data: {
nodeType: 'trigger',
},
});
});
it('should generate a diagram with nodes and edges corresponding to the steps', () => {
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: {},
},
},
{
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: {},
},
},
];
const result = generateWorkflowDiagram({ trigger, steps });
expect(result.nodes).toHaveLength(steps.length + 1); // All steps + trigger
expect(result.edges).toHaveLength(steps.length - 1 + 1); // Edges are one less than nodes + the edge from the trigger to the first node
expect(result.nodes[0].data.nodeType).toBe('trigger');
const stepNodes = result.nodes.slice(1);
for (const [index, step] of steps.entries()) {
expect(stepNodes[index].data).toEqual({
nodeType: 'action',
actionType: 'CODE',
name: step.name,
});
}
});
it('should correctly link nodes with edges', () => {
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: {},
},
},
{
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: {},
},
},
];
const result = generateWorkflowDiagram({ trigger, steps });
expect(result.edges[0].source).toEqual(result.nodes[0].id);
expect(result.edges[0].target).toEqual(result.nodes[1].id);
expect(result.edges[1].source).toEqual(result.nodes[1].id);
expect(result.edges[1].target).toEqual(result.nodes[2].id);
});
});
@@ -0,0 +1,109 @@
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 a diagram with an empty-trigger node 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: [
{
data: {},
id: 'trigger',
position: { x: 0, y: 0 },
type: 'empty-trigger',
},
],
edges: [],
});
});
it('returns a diagram with an empty-trigger node if the provided workflow version has no steps', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: null,
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
expect(result).toEqual({
nodes: [
{
data: {
name: 'Company created',
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
},
id: 'trigger',
position: { x: 0, y: 0 },
},
],
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 },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
type: 'CODE',
valid: true,
},
],
trigger: {
name: 'Company created',
settings: { eventName: 'company.created', outputSchema: {} },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
// Corresponds to the trigger + 1 step
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
});
@@ -0,0 +1,72 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { mergeWorkflowDiagrams } from '../mergeWorkflowDiagrams';
it('Preserves the properties defined in the previous version but not in the next one', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
});
});
it('Replaces duplicated properties with the next value', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', name: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
});
});
@@ -0,0 +1,47 @@
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { MarkerType } from '@xyflow/react';
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({
id: v4(),
source: node.id,
target: newCreateStepNode.id,
markerEnd: {
type: MarkerType.ArrowClosed,
},
deletable: false,
});
}
return {
nodes: updatedNodes,
edges: updatedEdges,
};
};
@@ -0,0 +1,126 @@
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { MarkerType } from '@xyflow/react';
import { isDefined } from 'twenty-ui';
import { v4 } from 'uuid';
import { capitalize } from '~/utils/string/capitalize';
export const generateWorkflowDiagram = ({
trigger,
steps,
}: {
trigger: WorkflowTrigger | undefined;
steps: Array<WorkflowStep>;
}): WorkflowDiagram => {
const nodes: Array<WorkflowDiagramNode> = [];
const edges: Array<WorkflowDiagramEdge> = [];
// Helper function to generate nodes and edges recursively
const processNode = (
step: WorkflowStep,
parentNodeId: string,
xPos: number,
yPos: number,
) => {
const nodeId = step.id;
nodes.push({
id: nodeId,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
},
position: {
x: xPos,
y: yPos,
},
});
// Create an edge from the parent node to this node
edges.push({
id: v4(),
source: parentNodeId,
target: nodeId,
markerEnd: {
type: MarkerType.ArrowClosed,
},
deletable: false,
selectable: false,
});
return nodeId;
};
// Start with the trigger node
const triggerNodeId = TRIGGER_STEP_ID;
if (isDefined(trigger)) {
let triggerLabel: string;
switch (trigger.type) {
case 'MANUAL': {
triggerLabel = 'Manual Trigger';
break;
}
case 'DATABASE_EVENT': {
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
triggerLabel = `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`;
break;
}
default: {
return assertUnreachable(
trigger,
`Expected the trigger "${JSON.stringify(trigger)}" to be supported.`,
);
}
}
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
triggerType: trigger.type,
name: isDefined(trigger.name) ? trigger.name : triggerLabel,
},
position: {
x: 0,
y: 0,
},
});
} else {
nodes.push({
id: triggerNodeId,
type: 'empty-trigger',
data: {} as any,
position: {
x: 0,
y: 0,
},
});
}
let lastStepId = triggerNodeId;
for (const step of steps) {
lastStepId = processNode(step, lastStepId, 150, 100);
}
return {
nodes,
edges,
};
};
@@ -0,0 +1,33 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import Dagre from '@dagrejs/dagre';
export const getOrganizedDiagram = (
diagram: WorkflowDiagram,
): WorkflowDiagram => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({ rankdir: 'TB' });
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
diagram.nodes.forEach((node) =>
graph.setNode(node.id, {
...node,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
Dagre.layout(graph);
return {
nodes: diagram.nodes.map((node) => {
const position = graph.node(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
const x = position.x - (node.measured?.width ?? 0) / 2;
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
}),
edges: diagram.edges,
};
};
@@ -0,0 +1,22 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { isDefined } from 'twenty-ui';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
edges: [],
};
export const getWorkflowVersionDiagram = (
workflowVersion: WorkflowVersion | undefined,
): WorkflowDiagram => {
if (!isDefined(workflowVersion)) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
});
};
@@ -0,0 +1,33 @@
import {
WorkflowDiagram,
WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
const nodePropertiesToPreserve: Array<keyof WorkflowDiagramNode> = ['selected'];
export const mergeWorkflowDiagrams = (
previousDiagram: WorkflowDiagram,
nextDiagram: WorkflowDiagram,
): WorkflowDiagram => {
const lastNodes = nextDiagram.nodes.map((nextNode) => {
const previousNode = previousDiagram.nodes.find(
(previousNode) => previousNode.id === nextNode.id,
);
const nodeWithPreservedProperties = nodePropertiesToPreserve.reduce(
(nodeToSet, propertyToPreserve) => {
return Object.assign(nodeToSet, {
[propertyToPreserve]: previousNode?.[propertyToPreserve],
});
},
{} as Partial<WorkflowDiagramNode>,
);
return Object.assign(nodeWithPreservedProperties, nextNode);
});
return {
nodes: lastNodes,
edges: nextDiagram.edges,
};
};