Skip to content

Commit

Permalink
Visualize Workflows (#6697)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
Devessier authored Aug 23, 2024
1 parent 873a4c1 commit e49acae
Show file tree
Hide file tree
Showing 30 changed files with 1,012 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/twenty-front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"workerDirectory": "public"
},
"dependencies": {
"@xyflow/react": "^12.0.4",
"transliteration": "^2.3.5"
}
}
11 changes: 11 additions & 0 deletions packages/twenty-front/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { SettingsBilling } from '~/pages/settings/SettingsBilling';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { WorkflowShowPage } from '~/pages/workflows/WorkflowShowPage';
import { getPageTitleFromPath } from '~/utils/title-utils';

const ProvidersThatNeedRouterContext = () => {
Expand Down Expand Up @@ -135,6 +136,7 @@ const createRouter = (
isBillingEnabled?: boolean,
isCRMMigrationEnabled?: boolean,
isServerlessFunctionSettingsEnabled?: boolean,
isWorkflowEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
Expand Down Expand Up @@ -163,6 +165,13 @@ const createRouter = (
<Route path={AppPath.RecordIndexPage} element={<RecordIndexPage />} />
<Route path={AppPath.RecordShowPage} element={<RecordShowPage />} />

{isWorkflowEnabled === true ? (
<Route
path={AppPath.WorkflowShowPage}
element={<WorkflowShowPage />}
/>
) : null}

<Route
path={AppPath.SettingsCatchAll}
element={
Expand Down Expand Up @@ -326,6 +335,7 @@ export const App = () => {
const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled(
'IS_FUNCTION_SETTINGS_ENABLED',
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');

const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;
Expand All @@ -336,6 +346,7 @@ export const App = () => {
isBillingPageEnabled,
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isWorkflowEnabled,
)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export enum CoreObjectNameSingular {
Webhook = 'webhook',
WorkspaceMember = 'workspaceMember',
MessageThreadSubscriber = 'messageThreadSubscriber',
Workflow = 'workflow',
}
2 changes: 2 additions & 0 deletions packages/twenty-front/src/modules/types/AppPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export enum AppPath {
Developers = `developers`,
DevelopersCatchAll = `/${Developers}/*`,

WorkflowShowPage = `/workflow/:workflowId`,

// Impersonate
Impersonate = '/impersonate/:userId',

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/Compo
import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow';

const StyledRightDrawerPage = styled.div`
display: flex;
Expand All @@ -35,6 +36,7 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
[RightDrawerPages.Copilot]: <RightDrawerAIChat />,
[RightDrawerPages.Workflow]: <RightDrawerWorkflow />,
};

export const RightDrawerRouter = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent',
[RightDrawerPages.ViewRecord]: 'Icon123',
[RightDrawerPages.Copilot]: 'IconSparkles',
[RightDrawerPages.Workflow]: 'IconSparkles',
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.ViewCalendarEvent]: 'Calendar Event',
[RightDrawerPages.ViewRecord]: 'Record Editor',
[RightDrawerPages.Copilot]: 'Copilot',
[RightDrawerPages.Workflow]: 'Workflow',
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export enum RightDrawerPages {
ViewCalendarEvent = 'view-calendar-event',
ViewRecord = 'view-record',
Copilot = 'copilot',
Workflow = 'workflow',
}
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit e49acae

Please sign in to comment.