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:
Baptiste Devessier
2024-08-23 17:50:13 +02:00
committed by GitHub
parent 873a4c1bd1
commit e49acae851
30 changed files with 1012 additions and 6 deletions

View File

@ -30,6 +30,7 @@
"workerDirectory": "public" "workerDirectory": "public"
}, },
"dependencies": { "dependencies": {
"@xyflow/react": "^12.0.4",
"transliteration": "^2.3.5" "transliteration": "^2.3.5"
} }
} }

View File

@ -84,6 +84,7 @@ import { SettingsBilling } from '~/pages/settings/SettingsBilling';
import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { WorkflowShowPage } from '~/pages/workflows/WorkflowShowPage';
import { getPageTitleFromPath } from '~/utils/title-utils'; import { getPageTitleFromPath } from '~/utils/title-utils';
const ProvidersThatNeedRouterContext = () => { const ProvidersThatNeedRouterContext = () => {
@ -135,6 +136,7 @@ const createRouter = (
isBillingEnabled?: boolean, isBillingEnabled?: boolean,
isCRMMigrationEnabled?: boolean, isCRMMigrationEnabled?: boolean,
isServerlessFunctionSettingsEnabled?: boolean, isServerlessFunctionSettingsEnabled?: boolean,
isWorkflowEnabled?: boolean,
) => ) =>
createBrowserRouter( createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
@ -163,6 +165,13 @@ const createRouter = (
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} /> <Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} /> <Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />
{isWorkflowEnabled === true ? (
<Route
path={AppPath.WorkflowShowPage}
element={<WorkflowShowPage />}
/>
) : null}
<Route <Route
path={AppPath.SettingsCatchAll} path={AppPath.SettingsCatchAll}
element={ element={
@ -326,6 +335,7 @@ export const App = () => {
const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled( const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled(
'IS_FUNCTION_SETTINGS_ENABLED', 'IS_FUNCTION_SETTINGS_ENABLED',
); );
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const isBillingPageEnabled = const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled; billing?.isBillingEnabled && !isFreeAccessEnabled;
@ -336,6 +346,7 @@ export const App = () => {
isBillingPageEnabled, isBillingPageEnabled,
isCRMMigrationEnabled, isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled, isServerlessFunctionSettingsEnabled,
isWorkflowEnabled,
)} )}
/> />
); );

View File

@ -28,4 +28,5 @@ export enum CoreObjectNameSingular {
Webhook = 'webhook', Webhook = 'webhook',
WorkspaceMember = 'workspaceMember', WorkspaceMember = 'workspaceMember',
MessageThreadSubscriber = 'messageThreadSubscriber', MessageThreadSubscriber = 'messageThreadSubscriber',
Workflow = 'workflow',
} }

View File

@ -26,6 +26,8 @@ export enum AppPath {
Developers = `developers`, Developers = `developers`,
DevelopersCatchAll = `/${Developers}/*`, DevelopersCatchAll = `/${Developers}/*`,
WorkflowShowPage = `/workflow/:workflowId`,
// Impersonate // Impersonate
Impersonate = '/impersonate/:userId', Impersonate = '/impersonate/:userId',

View File

@ -12,6 +12,7 @@ import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/Compo
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages'; import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow';
const StyledRightDrawerPage = styled.div` const StyledRightDrawerPage = styled.div`
display: flex; display: flex;
@ -35,6 +36,7 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />, [RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />, [RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
[RightDrawerPages.Copilot]: <RightDrawerAIChat />, [RightDrawerPages.Copilot]: <RightDrawerAIChat />,
[RightDrawerPages.Workflow]: <RightDrawerWorkflow />,
}; };
export const RightDrawerRouter = () => { export const RightDrawerRouter = () => {

View File

@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent', [RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent',
[RightDrawerPages.ViewRecord]: 'Icon123', [RightDrawerPages.ViewRecord]: 'Icon123',
[RightDrawerPages.Copilot]: 'IconSparkles', [RightDrawerPages.Copilot]: 'IconSparkles',
[RightDrawerPages.Workflow]: 'IconSparkles',
}; };

View File

@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.ViewCalendarEvent]: 'Calendar Event', [RightDrawerPages.ViewCalendarEvent]: 'Calendar Event',
[RightDrawerPages.ViewRecord]: 'Record Editor', [RightDrawerPages.ViewRecord]: 'Record Editor',
[RightDrawerPages.Copilot]: 'Copilot', [RightDrawerPages.Copilot]: 'Copilot',
[RightDrawerPages.Workflow]: 'Workflow',
}; };

View File

@ -3,4 +3,5 @@ export enum RightDrawerPages {
ViewCalendarEvent = 'view-calendar-event', ViewCalendarEvent = 'view-calendar-event',
ViewRecord = 'view-record', ViewRecord = 'view-record',
Copilot = 'copilot', Copilot = 'copilot',
Workflow = 'workflow',
} }

View File

@ -0,0 +1,40 @@
import styled from '@emotion/styled';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
justify-content: flex-start;
overflow-y: auto;
position: relative;
`;
const StyledChatArea = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(6)};
padding-bottom: 0px;
`;
const StyledNewMessageArea = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
padding-top: 0px;
`;
export const RightDrawerWorkflow = () => {
const handleCreateCodeBlock = () => {};
return (
<StyledContainer>
<StyledChatArea>{/* TODO */}</StyledChatArea>
<StyledNewMessageArea>
<button onClick={handleCreateCodeBlock}>Create code block</button>
</StyledNewMessageArea>
</StyledContainer>
);
};

View File

@ -0,0 +1,86 @@
import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx';
import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/utils/getOrganizedDiagram';
import {
applyEdgeChanges,
applyNodeChanges,
Background,
EdgeChange,
NodeChange,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMemo } from 'react';
import { useSetRecoilState } from 'recoil';
import { GRAY_SCALE, isDefined } from 'twenty-ui';
export const WorkflowShowPageDiagram = ({
diagram,
}: {
diagram: WorkflowDiagram;
}) => {
const { nodes, edges } = useMemo(
() => getOrganizedDiagram(diagram),
[diagram],
);
const setShowPageWorkflowDiagram = useSetRecoilState(
showPageWorkflowDiagramState,
);
const handleNodesChange = (
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
) => {
setShowPageWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
throw new Error(
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
);
}
return {
...diagram,
nodes: applyNodeChanges(nodeChanges, diagram.nodes),
};
});
};
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
) => {
setShowPageWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
throw new Error(
'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
);
}
return {
...diagram,
edges: applyEdgeChanges(edgeChanges, diagram.edges),
};
});
};
return (
<ReactFlow
nodeTypes={{
default: WorkflowShowPageDiagramStepNode,
'create-step': WorkflowShowPageDiagramCreateStepNode,
}}
fitView
nodes={nodes.map((node) => ({ ...node, draggable: false }))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
>
<Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow>
);
};

View File

@ -0,0 +1,26 @@
import { IconButton } from '@/ui/input/button/components/IconButton';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import { IconPlus } from 'twenty-ui';
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
export const WorkflowShowPageDiagramCreateStepNode = () => {
const { openRightDrawer } = useRightDrawer();
const handleCreateStepNodeButtonClick = () => {
openRightDrawer(RightDrawerPages.Workflow);
};
return (
<div>
<StyledTargetHandle type="target" position={Position.Top} />
<IconButton Icon={IconPlus} onClick={handleCreateStepNodeButtonClick} />
</div>
);
};

View File

@ -0,0 +1,87 @@
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
const StyledStepNodeContainer = styled.div`
display: flex;
flex-direction: column;
padding-bottom: 12px;
padding-top: 6px;
`;
const StyledStepNodeType = styled.div`
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm} 0 0;
color: ${({ theme }) => theme.color.gray50};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
position: absolute;
top: 0;
transform: translateY(-100%);
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
}
`;
const StyledStepNodeInnerContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
box-shadow: ${({ theme }) => theme.boxShadow.superHeavy};
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue10};
border-color: ${({ theme }) => theme.color.blue};
}
`;
const StyledStepNodeLabel = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSourceHandle = styled(Handle)`
background-color: ${({ theme }) => theme.color.gray50};
`;
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
export const WorkflowShowPageDiagramStepNode = ({
data,
}: {
data: WorkflowDiagramStepNodeData;
}) => {
return (
<StyledStepNodeContainer>
{data.nodeType !== 'trigger' ? (
<StyledTargetHandle type="target" position={Position.Top} />
) : null}
<StyledStepNodeInnerContainer>
<StyledStepNodeType>{data.nodeType}</StyledStepNodeType>
<StyledStepNodeLabel>{data.label}</StyledStepNodeLabel>
</StyledStepNodeInnerContainer>
<StyledSourceHandle type="source" position={Position.Bottom} />
</StyledStepNodeContainer>
);
};

View File

@ -0,0 +1,61 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState';
import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState';
import { Workflow } from '@/workflow/types/Workflow';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowLastDiagramVersion } from '@/workflow/utils/getWorkflowLastDiagramVersion';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
type WorkflowShowPageEffectProps = {
workflowId: string;
};
export const WorkflowShowPageEffect = ({
workflowId,
}: WorkflowShowPageEffectProps) => {
const {
record: workflow,
loading,
error,
} = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowId,
recordGqlFields: {
id: true,
name: true,
versions: true,
publishedVersionId: true,
},
});
const setCurrentWorkflowData = useSetRecoilState(
showPageWorkflowDiagramState,
);
const setCurrentWorkflowLoading = useSetRecoilState(
showPageWorkflowLoadingState,
);
const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState);
useEffect(() => {
const flowLastVersion = getWorkflowLastDiagramVersion(workflow);
const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion);
setCurrentWorkflowData(
isDefined(workflow) ? flowWithCreateStepNodes : undefined,
);
}, [setCurrentWorkflowData, workflow]);
useEffect(() => {
setCurrentWorkflowLoading(loading);
}, [loading, setCurrentWorkflowLoading]);
useEffect(() => {
setCurrentWorkflowError(error);
}, [error, setCurrentWorkflowError]);
return null;
};

View File

@ -0,0 +1,30 @@
import { PageHeader } from '@/ui/layout/page/PageHeader';
import { useNavigate } from 'react-router-dom';
import { IconComponent } from 'twenty-ui';
export const WorkflowShowPageHeader = ({
workflowName,
headerIcon,
children,
}: {
workflowName: string;
headerIcon: IconComponent;
children?: React.ReactNode;
}) => {
const navigate = useNavigate();
return (
<PageHeader
hasClosePageButton
onClosePage={() => {
navigate({
pathname: '/objects/workflows',
});
}}
title={workflowName}
Icon={headerIcon}
>
{children}
</PageHeader>
);
};

View File

@ -0,0 +1,9 @@
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { createState } from 'twenty-ui';
export const showPageWorkflowDiagramState = createState<
WorkflowDiagram | undefined
>({
key: 'showPageWorkflowDiagramState',
defaultValue: undefined,
});

View File

@ -0,0 +1,7 @@
import { ApolloError } from '@apollo/client';
import { createState } from 'twenty-ui';
export const showPageWorkflowErrorState = createState<ApolloError | undefined>({
key: 'showPageWorkflowErrorState',
defaultValue: undefined,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowLoadingState = createState<boolean>({
key: 'showPageWorkflowLoadingState',
defaultValue: true,
});

View File

@ -0,0 +1,66 @@
type WorkflowBaseSettingsType = {
errorHandlingOptions: {
retryOnFailure: {
value: boolean;
};
continueOnFailure: {
value: boolean;
};
};
};
export type WorkflowCodeSettingsType = WorkflowBaseSettingsType & {
serverlessFunctionId: string;
};
export type WorkflowActionType = 'CODE_ACTION';
type CommonWorkflowAction = {
id: string;
name: string;
valid: boolean;
};
type WorkflowCodeAction = CommonWorkflowAction & {
type: 'CODE_ACTION';
settings: WorkflowCodeSettingsType;
};
export type WorkflowAction = WorkflowCodeAction;
export type WorkflowStep = WorkflowAction;
export type WorkflowTriggerType = 'DATABASE_EVENT';
type BaseTrigger = {
type: WorkflowTriggerType;
input?: object;
};
export type WorkflowDatabaseEventTrigger = BaseTrigger & {
type: 'DATABASE_EVENT';
settings: {
eventName: string;
};
};
export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
export type WorkflowVersion = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
workflowId: string;
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
__typename: 'WorkflowVersion';
};
export type Workflow = {
__typename: 'Workflow';
id: string;
name: string;
versions: Array<WorkflowVersion>;
publishedVersionId: string;
};

View File

@ -0,0 +1,20 @@
import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
export type WorkflowDiagramEdge = Edge;
export type WorkflowDiagram = {
nodes: Array<WorkflowDiagramNode>;
edges: Array<WorkflowDiagramEdge>;
};
export type WorkflowDiagramStepNodeData = {
nodeType: 'trigger' | 'condition' | 'action';
label: string;
};
export type WorkflowDiagramCreateStepNodeData = Record<string, never>;
export type WorkflowDiagramNodeData =
| WorkflowDiagramStepNodeData
| WorkflowDiagramCreateStepNodeData;

View File

@ -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');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,4 +8,5 @@ export type FeatureFlagKey =
| 'IS_COPILOT_ENABLED' | 'IS_COPILOT_ENABLED'
| 'IS_CRM_MIGRATION_ENABLED' | 'IS_CRM_MIGRATION_ENABLED'
| 'IS_FREE_ACCESS_ENABLED' | 'IS_FREE_ACCESS_ENABLED'
| 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'; | 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED'
| 'IS_WORKFLOW_ENABLED';

View File

@ -0,0 +1,64 @@
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { WorkflowShowPageDiagram } from '@/workflow/components/WorkflowShowPageDiagram';
import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect';
import { WorkflowShowPageHeader } from '@/workflow/components/WorkflowShowPageHeader';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import styled from '@emotion/styled';
import '@xyflow/react/dist/style.css';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconSettingsAutomation } from 'twenty-ui';
const StyledFlowContainer = styled.div`
height: 100%;
width: 100%;
/* Below we reset the default styling of Reactflow */
.react-flow__node-input,
.react-flow__node-default,
.react-flow__node-output,
.react-flow__node-group {
padding: 0;
}
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
--xy-node-boxshadow-hover: none;
--xy-node-boxshadow-selected: none;
`;
export const WorkflowShowPage = () => {
const parameters = useParams<{
workflowId: string;
}>();
const workflowName = 'Test Workflow';
const showPageWorkflowDiagram = useRecoilValue(showPageWorkflowDiagramState);
if (parameters.workflowId === undefined) {
return null;
}
return (
<PageContainer>
<WorkflowShowPageEffect workflowId={parameters.workflowId} />
<PageTitle title={workflowName} />
<WorkflowShowPageHeader
workflowName={workflowName}
headerIcon={IconSettingsAutomation}
/>
<PageBody>
<StyledFlowContainer>
{showPageWorkflowDiagram === undefined ? null : (
<WorkflowShowPageDiagram diagram={showPageWorkflowDiagram} />
)}
</StyledFlowContainer>
</PageBody>
</PageContainer>
);
};

View File

@ -153,6 +153,7 @@ export {
IconSearch, IconSearch,
IconSend, IconSend,
IconSettings, IconSettings,
IconSettingsAutomation,
IconSortDescending, IconSortDescending,
IconSparkles, IconSparkles,
IconSql, IconSql,

View File

@ -15782,7 +15782,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.1": "@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.1, @types/d3-drag@npm:^3.0.7":
version: 3.0.7 version: 3.0.7
resolution: "@types/d3-drag@npm:3.0.7" resolution: "@types/d3-drag@npm:3.0.7"
dependencies: dependencies:
@ -15920,7 +15920,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.3": "@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10, @types/d3-selection@npm:^3.0.3":
version: 3.0.10 version: 3.0.10
resolution: "@types/d3-selection@npm:3.0.10" resolution: "@types/d3-selection@npm:3.0.10"
checksum: 10c0/de1f99ab186a08999bf394a645fd76911add1b02316270d4c07616c8383903a2b068d7e02b73b6a99a1f26bb49a2e99ef4b55a5d2ddfa165f6f3c53144897920 checksum: 10c0/de1f99ab186a08999bf394a645fd76911add1b02316270d4c07616c8383903a2b068d7e02b73b6a99a1f26bb49a2e99ef4b55a5d2ddfa165f6f3c53144897920
@ -15987,7 +15987,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/d3-transition@npm:*": "@types/d3-transition@npm:*, @types/d3-transition@npm:^3.0.8":
version: 3.0.8 version: 3.0.8
resolution: "@types/d3-transition@npm:3.0.8" resolution: "@types/d3-transition@npm:3.0.8"
dependencies: dependencies:
@ -15996,7 +15996,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.1": "@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.1, @types/d3-zoom@npm:^3.0.8":
version: 3.0.8 version: 3.0.8
resolution: "@types/d3-zoom@npm:3.0.8" resolution: "@types/d3-zoom@npm:3.0.8"
dependencies: dependencies:
@ -18303,6 +18303,35 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@xyflow/react@npm:^12.0.4":
version: 12.0.4
resolution: "@xyflow/react@npm:12.0.4"
dependencies:
"@xyflow/system": "npm:0.0.37"
classcat: "npm:^5.0.3"
zustand: "npm:^4.4.0"
peerDependencies:
react: ">=17"
react-dom: ">=17"
checksum: 10c0/57b04024c3cca1b5d19b5625b92a5ca5015870a5b6adf2ab2c0bcfa701f93929805777ad081e7142b9c94846ad83d65abb65041b50134515b135b6514d74766e
languageName: node
linkType: hard
"@xyflow/system@npm:0.0.37":
version: 0.0.37
resolution: "@xyflow/system@npm:0.0.37"
dependencies:
"@types/d3-drag": "npm:^3.0.7"
"@types/d3-selection": "npm:^3.0.10"
"@types/d3-transition": "npm:^3.0.8"
"@types/d3-zoom": "npm:^3.0.8"
d3-drag: "npm:^3.0.0"
d3-selection: "npm:^3.0.0"
d3-zoom: "npm:^3.0.0"
checksum: 10c0/60b2de70a53dc3f2b691d837f2adcd2324f2e3e19258d6928e58578ad896a7f9fa7dd20938b224e7054284542135e0d7519ab34c012d69a8ed0e15ecf452d1ee
languageName: node
linkType: hard
"@yarnpkg/esbuild-plugin-pnp@npm:^3.0.0-rc.10": "@yarnpkg/esbuild-plugin-pnp@npm:^3.0.0-rc.10":
version: 3.0.0-rc.15 version: 3.0.0-rc.15
resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15" resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15"
@ -47029,6 +47058,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "twenty-front@workspace:packages/twenty-front" resolution: "twenty-front@workspace:packages/twenty-front"
dependencies: dependencies:
"@xyflow/react": "npm:^12.0.4"
transliteration: "npm:^2.3.5" transliteration: "npm:^2.3.5"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -50710,7 +50740,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zustand@npm:^4.4.1": "zustand@npm:^4.4.0, zustand@npm:^4.4.1":
version: 4.5.4 version: 4.5.4
resolution: "zustand@npm:4.5.4" resolution: "zustand@npm:4.5.4"
dependencies: dependencies: