Visualize Workflows (#6697)
## Features
- Fetch a workflow and display it in a tree with the React Flow library
- The nodes are positioned by an algorithm
- The feature is put behind a feature flag. The `/workflow/:id` route is
disabled if the flag is off.
- I started implementing a right drawer. That's a big WIP and it will be
finished in another PR.
## How to test this feature
1. Create a workflow instance in the database through a GraphQL query.
See below for instructions.
2. After enabling the feature flag, you should be able to see the
workflow you created in the workflows list. To visualize the workflow,
go to the `/workflow/:id` page where the id is the id of the workflow.
See the video for a quick way to do so.
```gql
// First
mutation createWorkflow($data: WorkflowCreateInput!) {
createWorkflow(data: $data) {
id
}
}
// Result
{
"data": {
"name": "test"
}
}
// Second
mutation createWorkflowVersion($data: WorkflowVersionCreateInput!) {
createWorkflowVersion (data: $data) {
id
}
}
// Result
{
"data": {
"name": "v1",
"trigger": {
"name": "trigger",
"displayName": "New or Updated Row",
"type": "DATABASE_EVENT",
"settings": {
"eventName": "company.created",
"triggerName": "Company Created"
},
"nextAction": {
"name": "step_1",
"displayName": "Code",
"type": "CODE",
"valid": true,
"settings": {
"serverlessFunctionId": "function_id",
"errorHandlingOptions": {
"retryOnFailure": {
"value": false
},
"continueOnFailure": {
"value": false
}
}
}
}
},
"workflowId": "workflow_id"
}
}
```
https://github.com/user-attachments/assets/42bbd98c-5e13-447c-9307-461a18ac2195
This commit is contained in:
committed by
GitHub
parent
873a4c1bd1
commit
e49acae851
@ -0,0 +1,63 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { generateWorkflowDiagram } from '@/workflow/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 = {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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,122 @@
|
||||
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 = {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
const result = generateWorkflowDiagram({ trigger, steps });
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
|
||||
expect(result.nodes[0]).toMatchObject({
|
||||
data: {
|
||||
label: trigger.settings.eventName,
|
||||
nodeType: 'trigger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a diagram with nodes and edges corresponding to the steps', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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.nodeType).toBe('action');
|
||||
expect(stepNodes[index].data.label).toBe(step.name);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly link nodes with edges', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE_ACTION',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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,79 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramNode,
|
||||
} from '@/workflow/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 = {
|
||||
id: v4(),
|
||||
type: 'create-step',
|
||||
data: {},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
updatedNodes.push(newCreateStepNode);
|
||||
|
||||
updatedEdges.push({
|
||||
id: v4(),
|
||||
source: node.id,
|
||||
target: newCreateStepNode.id,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: updatedNodes,
|
||||
edges: updatedEdges,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,84 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramNode,
|
||||
} from '@/workflow/types/WorkflowDiagram';
|
||||
import { MarkerType } from '@xyflow/react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const generateWorkflowDiagram = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: {
|
||||
trigger: WorkflowTrigger;
|
||||
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 = v4();
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
label: 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,
|
||||
},
|
||||
});
|
||||
|
||||
// Recursively generate flow for the next action if it exists
|
||||
if (step.type !== 'CODE_ACTION') {
|
||||
// processNode(action.nextAction, nodeId, xPos + 150, yPos + 100);
|
||||
|
||||
throw new Error('Other types as code actions are not supported yet.');
|
||||
}
|
||||
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
// Start with the trigger node
|
||||
const triggerNodeId = v4();
|
||||
nodes.push({
|
||||
id: triggerNodeId,
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
label: trigger.settings.eventName,
|
||||
},
|
||||
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,36 @@
|
||||
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 => {
|
||||
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,27 @@
|
||||
import { Workflow } 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 getWorkflowLastDiagramVersion = (
|
||||
workflow: Workflow | undefined,
|
||||
): WorkflowDiagram => {
|
||||
if (!isDefined(workflow)) {
|
||||
return EMPTY_DIAGRAM;
|
||||
}
|
||||
|
||||
const lastVersion = workflow.versions.at(-1);
|
||||
if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) {
|
||||
return EMPTY_DIAGRAM;
|
||||
}
|
||||
|
||||
return generateWorkflowDiagram({
|
||||
trigger: lastVersion.trigger,
|
||||
steps: lastVersion.steps,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user