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:
Baptiste Devessier
2025-02-13 18:57:54 +01:00
committed by GitHub
parent 1863ef7d10
commit 81b2d5bc89
24 changed files with 1374 additions and 170 deletions

View File

@ -8,8 +8,7 @@ import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableE
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer';
import { WorkflowRunOutputVisualizer } from '@/workflow/components/WorkflowRunOutputVisualizer';
import { WorkflowRunVersionVisualizer } from '@/workflow/components/WorkflowRunVersionVisualizer';
import { WorkflowRunVisualizer } from '@/workflow/components/WorkflowRunVisualizer';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
@ -94,10 +93,6 @@ export const CardComponents: Record<CardType, CardComponentType> = {
),
[CardType.WorkflowRunCard]: ({ targetableObject }) => (
<WorkflowRunVersionVisualizer workflowRunId={targetableObject.id} />
),
[CardType.WorkflowRunOutputCard]: ({ targetableObject }) => (
<WorkflowRunOutputVisualizer workflowRunId={targetableObject.id} />
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
),
};

View File

@ -13,7 +13,6 @@ import {
IconCalendarEvent,
IconMail,
IconNotes,
IconPrinter,
IconSettings,
} from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -180,20 +179,6 @@ export const useRecordShowContainerTabs = (
},
[CoreObjectNameSingular.WorkflowRun]: {
tabs: {
workflowRunOutput: {
title: 'Output',
position: 0,
Icon: IconPrinter,
cards: [{ type: CardType.WorkflowRunOutputCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [FeatureFlagKey.IsWorkflowEnabled],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
workflowRunFlow: {
title: 'Flow',
position: 0,

View File

@ -9,6 +9,5 @@ export enum CardType {
WorkflowCard = 'WorkflowCard',
WorkflowVersionCard = 'WorkflowVersionCard',
WorkflowRunCard = 'WorkflowRunCard',
WorkflowRunOutputCard = 'WorkflowRunOutputCard',
RichTextCard = 'RichTextCard',
}

View File

@ -1,29 +0,0 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVersionVisualizer = ({
workflowRunId,
}: {
workflowRunId: string;
}) => {
const workflowRun = useWorkflowRun({
workflowRunId,
});
if (!isDefined(workflowRun)) {
return null;
}
return (
<>
<WorkflowVersionVisualizerEffect
workflowVersionId={workflowRun.workflowVersionId}
/>
<WorkflowVersionVisualizer
workflowVersionId={workflowRun.workflowVersionId}
/>
</>
);
};

View File

@ -1,13 +1,13 @@
import { WorkflowRunVisualizerContent } from '@/workflow/components/WorkflowRunVisualizerContent';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { CodeEditor } from 'twenty-ui';
const StyledSourceCodeContainer = styled.div`
margin: ${({ theme }) => theme.spacing(4)};
height: 100%;
`;
export const WorkflowRunOutputVisualizer = ({
export const WorkflowRunVisualizer = ({
workflowRunId,
}: {
workflowRunId: string;
@ -19,11 +19,7 @@ export const WorkflowRunOutputVisualizer = ({
return (
<StyledSourceCodeContainer>
<CodeEditor
value={JSON.stringify(workflowRun.output, null, 2)}
language="json"
options={{ readOnly: true, domReadOnly: true }}
/>
<WorkflowRunVisualizerContent workflowRun={workflowRun} />
</StyledSourceCodeContainer>
);
};

View File

@ -0,0 +1,27 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonly';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerContent = ({
workflowRun,
}: {
workflowRun: WorkflowRun;
}) => {
const workflowVersion = useWorkflowVersion(workflowRun.workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return (
<>
<WorkflowRunVisualizerEffect
workflowRun={workflowRun}
workflowVersionId={workflowVersion.id}
/>
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
</>
);
};

View File

@ -206,7 +206,7 @@ export type WorkflowRun = {
__typename: 'WorkflowRun';
id: string;
workflowVersionId: string;
output: WorkflowRunOutput;
output: WorkflowRunOutput | null;
};
export type Workflow = {

View File

@ -1,4 +1,4 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode';
@ -8,14 +8,14 @@ import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/com
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasEditable = ({
workflowWithCurrentVersion,
versionStatus,
}: {
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
versionStatus: WorkflowVersionStatus;
}) => {
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={workflowWithCurrentVersion.currentVersion.status}
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'create-step': WorkflowDiagramCreateStepNode,

View File

@ -1,4 +1,4 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasReadonlyEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
@ -8,14 +8,14 @@ import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/componen
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasReadonly = ({
workflowVersion,
versionStatus,
}: {
workflowVersion: WorkflowVersion;
versionStatus: WorkflowVersionStatus;
}) => {
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={workflowVersion.status}
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
'empty-trigger': WorkflowDiagramEmptyTrigger,

View File

@ -1,6 +1,26 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import {
WorkflowDiagramRunStatus,
WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
const getNodeVariantFromRunStatus = (
runStatus: WorkflowDiagramRunStatus | undefined,
) => {
switch (runStatus) {
case 'success':
return 'success';
case 'failure':
return 'failure';
case 'running':
return 'default';
case 'not-executed':
return 'not-executed';
default:
return 'default';
}
};
export const WorkflowDiagramStepNodeReadonly = ({
data,
@ -10,7 +30,7 @@ export const WorkflowDiagramStepNodeReadonly = ({
return (
<WorkflowDiagramStepNodeBase
name={data.name}
variant="default"
variant={getNodeVariantFromRunStatus(data.runStatus)}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
isLeafNode={data.isLeafNode}

View File

@ -0,0 +1,49 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerEffect = ({
workflowVersionId,
workflowRun,
}: {
workflowVersionId: string;
workflowRun: WorkflowRun;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
setWorkflowVersionId(workflowVersionId);
}, [setWorkflowVersionId, workflowVersionId]);
useEffect(() => {
if (
!(
isDefined(workflowVersion) &&
isDefined(workflowVersion.trigger) &&
isDefined(workflowVersion.steps)
)
) {
setWorkflowDiagram(undefined);
return;
}
const nextWorkflowDiagram = generateWorkflowRunDiagram({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
output: workflowRun.output,
});
setWorkflowDiagram(nextWorkflowDiagram);
}, [setWorkflowDiagram, workflowRun.output, workflowVersion]);
return null;
};

View File

@ -11,6 +11,6 @@ export const WorkflowVersionVisualizer = ({
const workflowVersion = useWorkflowVersion(workflowVersionId);
return isDefined(workflowVersion) ? (
<WorkflowDiagramCanvasReadonly workflowVersion={workflowVersion} />
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
) : null;
};

View File

@ -15,7 +15,7 @@ export const WorkflowVisualizer = ({ workflowId }: { workflowId: string }) => {
{isDefined(workflowWithCurrentVersion) ? (
<WorkflowDiagramCanvasEditable
workflowWithCurrentVersion={workflowWithCurrentVersion}
versionStatus={workflowWithCurrentVersion.currentVersion.status}
/>
) : null}
</>

View File

@ -0,0 +1,4 @@
export const FIRST_NODE_POSITION = {
x: 0,
y: 0,
};

View File

@ -0,0 +1 @@
export const VERTICAL_DISTANCE_BETWEEN_TWO_NODES = 150;

View File

@ -0,0 +1,16 @@
import { WorkflowDiagramEmptyTriggerNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { Node } from '@xyflow/react';
export const WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION = {
id: TRIGGER_STEP_ID,
type: 'empty-trigger',
data: {
nodeType: 'empty-trigger',
isLeafNode: false,
} satisfies WorkflowDiagramEmptyTriggerNodeData,
position: {
x: 0,
y: 0,
},
} satisfies Node<WorkflowDiagramEmptyTriggerNodeData>;

View File

@ -0,0 +1,4 @@
import { WorkflowDiagramEdgeType } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const WORKFLOW_DIAGRAM_SUCCESS_EDGE_TYPE =
'success' satisfies WorkflowDiagramEdgeType;

View File

@ -1,8 +1,10 @@
import { EDGE_GREEN_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenCircleMarkedId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerId';
import { WORKFLOW_DIAGRAM_SUCCESS_EDGE_TYPE } from '@/workflow/workflow-diagram/constants/WorkflowDiagramSuccessEdgeType';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION = {
type: WORKFLOW_DIAGRAM_SUCCESS_EDGE_TYPE,
markerStart: EDGE_GREEN_CIRCLE_MARKED_ID,
markerEnd: EDGE_GREEN_ROUNDED_ARROW_MARKER_ID,
deletable: false,

View File

@ -7,23 +7,39 @@ import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
export type WorkflowDiagramEdge = Edge;
export type WorkflowRunDiagramNode = Node<WorkflowRunDiagramNodeData>;
export type WorkflowRunDiagramEdge = Edge;
export type WorkflowRunDiagram = {
nodes: Array<WorkflowRunDiagramNode>;
edges: Array<WorkflowRunDiagramEdge>;
};
export type WorkflowDiagram = {
nodes: Array<WorkflowDiagramNode>;
edges: Array<WorkflowDiagramEdge>;
};
export type WorkflowDiagramRunStatus =
| 'running'
| 'success'
| 'failure'
| 'not-executed';
export type WorkflowDiagramStepNodeData =
| {
nodeType: 'trigger';
triggerType: WorkflowTriggerType;
name: string;
icon?: string;
runStatus?: WorkflowDiagramRunStatus;
isLeafNode: boolean;
}
| {
nodeType: 'action';
actionType: WorkflowActionType;
name: string;
runStatus?: WorkflowDiagramRunStatus;
isLeafNode: boolean;
};
@ -43,6 +59,11 @@ export type WorkflowDiagramNodeData =
| WorkflowDiagramCreateStepNodeData
| WorkflowDiagramEmptyTriggerNodeData;
export type WorkflowRunDiagramNodeData = Exclude<
WorkflowDiagramStepNodeData,
'runStatus'
> & { runStatus: WorkflowDiagramRunStatus };
export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'

View File

@ -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,
},
},
],
}
`);
});
});

View File

@ -164,8 +164,8 @@ describe('getWorkflowVersionDiagram', () => {
},
"id": "step-1",
"position": {
"x": 150,
"y": 100,
"x": 0,
"y": 150,
},
},
],

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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,
},
};
};