Add workflow run visualizer (#10146)
- Remove the tabs from the workflowRun show page; now, we only show the visualizer with the nodes highlighted based on the run's output - Create the `generateWorkflowRunDiagram` function to go other each step and assign a `runStatus` to it based on the workflow run's output Remaining to do: - Show the output of each step in the right drawer when selecting one - The labels (e.g. "1 item") are not set on the edges; we might implement that later https://github.com/user-attachments/assets/bcf22f4c-db8c-4b02-9a1a-62d688b4c28e Closes https://github.com/twentyhq/core-team-issues/issues/338 Closes https://github.com/twentyhq/core-team-issues/issues/336
This commit is contained in:
committed by
GitHub
parent
1863ef7d10
commit
81b2d5bc89
+955
@@ -0,0 +1,955 @@
|
||||
import {
|
||||
WorkflowRunOutput,
|
||||
WorkflowStep,
|
||||
WorkflowTrigger,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock';
|
||||
import { generateWorkflowRunDiagram } from '../generateWorkflowRunDiagram';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: getUuidV4Mock(),
|
||||
}));
|
||||
|
||||
describe('generateWorkflowRunDiagram', () => {
|
||||
it('marks node as failed when not at least one attempt is in output', () => {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const output: WorkflowRunOutput = {
|
||||
steps: {
|
||||
step1: {
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
outputs: [],
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, output });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-0",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-1",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step1",
|
||||
"target": "step2",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-2",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step2",
|
||||
"target": "step3",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"isLeafNode": false,
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "failure",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 2",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step2",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 3",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step3",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('marks node as failed when the last attempt failed', () => {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const output: WorkflowRunOutput = {
|
||||
steps: {
|
||||
step1: {
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
outputs: [
|
||||
{
|
||||
attemptCount: 1,
|
||||
result: undefined,
|
||||
error: '',
|
||||
},
|
||||
],
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, output });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-3",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-4",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step1",
|
||||
"target": "step2",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-5",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step2",
|
||||
"target": "step3",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"isLeafNode": false,
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "failure",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 2",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step2",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 3",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step3",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('marks all nodes as successful when each node has an output', () => {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const output: WorkflowRunOutput = {
|
||||
steps: {
|
||||
step1: {
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
outputs: [
|
||||
{
|
||||
attemptCount: 1,
|
||||
result: {},
|
||||
error: undefined,
|
||||
},
|
||||
],
|
||||
type: 'CODE',
|
||||
},
|
||||
step2: {
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
outputs: [
|
||||
{
|
||||
attemptCount: 1,
|
||||
result: {},
|
||||
error: undefined,
|
||||
},
|
||||
],
|
||||
type: 'CODE',
|
||||
},
|
||||
step3: {
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
outputs: [
|
||||
{
|
||||
attemptCount: 1,
|
||||
result: {},
|
||||
error: undefined,
|
||||
},
|
||||
],
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, output });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-6",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-7",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "step1",
|
||||
"target": "step2",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-8",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "step2",
|
||||
"target": "step3",
|
||||
"type": "success",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"isLeafNode": false,
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "success",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 2",
|
||||
"nodeType": "action",
|
||||
"runStatus": "success",
|
||||
},
|
||||
"id": "step2",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 3",
|
||||
"nodeType": "action",
|
||||
"runStatus": "success",
|
||||
},
|
||||
"id": "step3",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('marks node as running and all other ones as not-executed when no output is available at all', () => {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const output = null;
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, output });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-9",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-10",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step1",
|
||||
"target": "step2",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-11",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step2",
|
||||
"target": "step3",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"isLeafNode": false,
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "running",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 2",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step2",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 3",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step3",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("marks node as running and all other ones as not-executed when a node doesn't have an attached output", () => {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
name: 'Step 4',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const output: WorkflowRunOutput = {
|
||||
steps: {
|
||||
step1: {
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
outputs: [
|
||||
{
|
||||
attemptCount: 1,
|
||||
result: {},
|
||||
error: undefined,
|
||||
},
|
||||
],
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, output });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-12",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-13",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "step1",
|
||||
"target": "step2",
|
||||
"type": "success",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-14",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step2",
|
||||
"target": "step3",
|
||||
},
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-15",
|
||||
"markerEnd": "workflow-edge-arrow-rounded",
|
||||
"markerStart": "workflow-edge-gray-circle",
|
||||
"selectable": false,
|
||||
"source": "step3",
|
||||
"target": "step4",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"isLeafNode": false,
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "success",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 2",
|
||||
"nodeType": "action",
|
||||
"runStatus": "running",
|
||||
},
|
||||
"id": "step2",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 3",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step3",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "CODE",
|
||||
"isLeafNode": false,
|
||||
"name": "Step 4",
|
||||
"nodeType": "action",
|
||||
"runStatus": "not-executed",
|
||||
},
|
||||
"id": "step4",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 450,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -164,8 +164,8 @@ describe('getWorkflowVersionDiagram', () => {
|
||||
},
|
||||
"id": "step-1",
|
||||
"position": {
|
||||
"x": 150,
|
||||
"y": 100,
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+39
-95
@@ -1,18 +1,17 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
|
||||
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,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramEmptyTriggerNodeData,
|
||||
WorkflowDiagramNode,
|
||||
WorkflowDiagramStepNodeData,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
|
||||
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
|
||||
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -26,12 +25,28 @@ export const generateWorkflowDiagram = ({
|
||||
const nodes: Array<WorkflowDiagramNode> = [];
|
||||
const edges: Array<WorkflowDiagramEdge> = [];
|
||||
|
||||
const processNode = (
|
||||
step: WorkflowStep,
|
||||
parentNodeId: string,
|
||||
xPos: number,
|
||||
yPos: number,
|
||||
) => {
|
||||
if (isDefined(trigger)) {
|
||||
nodes.push(getWorkflowDiagramTriggerNode({ trigger }));
|
||||
} else {
|
||||
nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION);
|
||||
}
|
||||
|
||||
const processNode = ({
|
||||
stepIndex,
|
||||
parentNodeId,
|
||||
xPos,
|
||||
yPos,
|
||||
}: {
|
||||
stepIndex: number;
|
||||
parentNodeId: string;
|
||||
xPos: number;
|
||||
yPos: number;
|
||||
}) => {
|
||||
const step = steps.at(stepIndex);
|
||||
if (!isDefined(step)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = step.id;
|
||||
|
||||
nodes.push({
|
||||
@@ -44,7 +59,7 @@ export const generateWorkflowDiagram = ({
|
||||
} satisfies WorkflowDiagramStepNodeData,
|
||||
position: {
|
||||
x: xPos,
|
||||
y: yPos,
|
||||
y: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,91 +70,20 @@ export const generateWorkflowDiagram = ({
|
||||
target: nodeId,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
processNode({
|
||||
stepIndex: stepIndex + 1,
|
||||
parentNodeId: nodeId,
|
||||
xPos,
|
||||
yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
|
||||
});
|
||||
};
|
||||
|
||||
const triggerNodeId = TRIGGER_STEP_ID;
|
||||
|
||||
if (isDefined(trigger)) {
|
||||
let triggerDefaultLabel: string;
|
||||
let triggerIcon: string | undefined;
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'MANUAL': {
|
||||
triggerDefaultLabel = 'Manual Trigger';
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'MANUAL',
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'CRON': {
|
||||
triggerDefaultLabel = 'On a Schedule';
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'CRON',
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'DATABASE_EVENT': {
|
||||
const triggerEvent = splitWorkflowTriggerEventName(
|
||||
trigger.settings.eventName,
|
||||
);
|
||||
|
||||
triggerDefaultLabel =
|
||||
DATABASE_TRIGGER_TYPES.find(
|
||||
(item) => item.event === triggerEvent.event,
|
||||
)?.defaultLabel ?? '';
|
||||
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'DATABASE_EVENT',
|
||||
eventName: 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 : triggerDefaultLabel,
|
||||
icon: triggerIcon,
|
||||
isLeafNode: false,
|
||||
} satisfies WorkflowDiagramStepNodeData,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
nodes.push({
|
||||
id: triggerNodeId,
|
||||
type: 'empty-trigger',
|
||||
data: {
|
||||
nodeType: 'empty-trigger',
|
||||
isLeafNode: false,
|
||||
} satisfies WorkflowDiagramEmptyTriggerNodeData,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let lastStepId = triggerNodeId;
|
||||
|
||||
for (const step of steps) {
|
||||
lastStepId = processNode(step, lastStepId, 150, 100);
|
||||
}
|
||||
processNode({
|
||||
stepIndex: 0,
|
||||
parentNodeId: TRIGGER_STEP_ID,
|
||||
xPos: FIRST_NODE_POSITION.x,
|
||||
yPos: FIRST_NODE_POSITION.y,
|
||||
});
|
||||
|
||||
return {
|
||||
nodes,
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
WorkflowRunOutput,
|
||||
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_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration';
|
||||
import { WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeSuccessConfiguration';
|
||||
import {
|
||||
WorkflowDiagramRunStatus,
|
||||
WorkflowRunDiagram,
|
||||
WorkflowRunDiagramEdge,
|
||||
WorkflowRunDiagramNode,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const generateWorkflowRunDiagram = ({
|
||||
trigger,
|
||||
steps,
|
||||
output,
|
||||
}: {
|
||||
trigger: WorkflowTrigger;
|
||||
steps: Array<WorkflowStep>;
|
||||
output: WorkflowRunOutput | null;
|
||||
}): WorkflowRunDiagram => {
|
||||
const triggerBase = getWorkflowDiagramTriggerNode({ trigger });
|
||||
|
||||
const nodes: Array<WorkflowRunDiagramNode> = [
|
||||
{
|
||||
...triggerBase,
|
||||
data: {
|
||||
...triggerBase.data,
|
||||
runStatus: 'success',
|
||||
},
|
||||
},
|
||||
];
|
||||
const edges: Array<WorkflowRunDiagramEdge> = [];
|
||||
|
||||
const processNode = ({
|
||||
stepIndex,
|
||||
parentNodeId,
|
||||
parentRunStatus,
|
||||
xPos,
|
||||
yPos,
|
||||
skippedExecution,
|
||||
}: {
|
||||
stepIndex: number;
|
||||
parentNodeId: string;
|
||||
parentRunStatus: WorkflowDiagramRunStatus;
|
||||
xPos: number;
|
||||
yPos: number;
|
||||
skippedExecution: boolean;
|
||||
}) => {
|
||||
const step = steps.at(stepIndex);
|
||||
if (!isDefined(step)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = step.id;
|
||||
|
||||
if (parentRunStatus === 'success') {
|
||||
edges.push({
|
||||
...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION,
|
||||
id: v4(),
|
||||
source: parentNodeId,
|
||||
target: nodeId,
|
||||
});
|
||||
} else {
|
||||
edges.push({
|
||||
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
|
||||
id: v4(),
|
||||
source: parentNodeId,
|
||||
target: nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
const runResult = output?.steps[nodeId];
|
||||
|
||||
let runStatus: WorkflowDiagramRunStatus;
|
||||
if (skippedExecution) {
|
||||
runStatus = 'not-executed';
|
||||
} else if (!isDefined(runResult)) {
|
||||
runStatus = 'running';
|
||||
} else {
|
||||
const lastAttempt = runResult.outputs.at(-1);
|
||||
|
||||
if (!isDefined(lastAttempt)) {
|
||||
// Should never happen. Should we throw instead?
|
||||
runStatus = 'failure';
|
||||
} else if (isDefined(lastAttempt.error)) {
|
||||
runStatus = 'failure';
|
||||
} else {
|
||||
runStatus = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: step.type,
|
||||
name: step.name,
|
||||
isLeafNode: false,
|
||||
runStatus,
|
||||
},
|
||||
position: {
|
||||
x: xPos,
|
||||
y: yPos,
|
||||
},
|
||||
});
|
||||
|
||||
processNode({
|
||||
stepIndex: stepIndex + 1,
|
||||
parentNodeId: nodeId,
|
||||
parentRunStatus: runStatus,
|
||||
xPos,
|
||||
yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
|
||||
skippedExecution: skippedExecution
|
||||
? true
|
||||
: runStatus === 'failure' || runStatus === 'running',
|
||||
});
|
||||
};
|
||||
|
||||
processNode({
|
||||
stepIndex: 0,
|
||||
parentNodeId: TRIGGER_STEP_ID,
|
||||
parentRunStatus: 'success',
|
||||
xPos: FIRST_NODE_POSITION.x,
|
||||
yPos: FIRST_NODE_POSITION.y,
|
||||
skippedExecution: false,
|
||||
});
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
|
||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
|
||||
import { Node } from '@xyflow/react';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const getWorkflowDiagramTriggerNode = ({
|
||||
trigger,
|
||||
}: {
|
||||
trigger: WorkflowTrigger;
|
||||
}): Node<WorkflowDiagramStepNodeData> => {
|
||||
let triggerDefaultLabel: string;
|
||||
let triggerIcon: string | undefined;
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'MANUAL': {
|
||||
triggerDefaultLabel = 'Manual Trigger';
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'MANUAL',
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'CRON': {
|
||||
triggerDefaultLabel = 'On a Schedule';
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'CRON',
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'DATABASE_EVENT': {
|
||||
const triggerEvent = splitWorkflowTriggerEventName(
|
||||
trigger.settings.eventName,
|
||||
);
|
||||
|
||||
triggerDefaultLabel =
|
||||
DATABASE_TRIGGER_TYPES.find((item) => item.event === triggerEvent.event)
|
||||
?.defaultLabel ?? '';
|
||||
|
||||
triggerIcon = getTriggerIcon({
|
||||
type: 'DATABASE_EVENT',
|
||||
eventName: triggerEvent.event,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return assertUnreachable(
|
||||
trigger,
|
||||
`Expected the trigger "${JSON.stringify(trigger)}" to be supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: TRIGGER_STEP_ID,
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
triggerType: trigger.type,
|
||||
name: isDefined(trigger.name) ? trigger.name : triggerDefaultLabel,
|
||||
icon: triggerIcon,
|
||||
isLeafNode: false,
|
||||
} satisfies WorkflowDiagramStepNodeData,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user