Visualize Workflows (#6697)
## Features
- Fetch a workflow and display it in a tree with the React Flow library
- The nodes are positioned by an algorithm
- The feature is put behind a feature flag. The `/workflow/:id` route is
disabled if the flag is off.
- I started implementing a right drawer. That's a big WIP and it will be
finished in another PR.
## How to test this feature
1. Create a workflow instance in the database through a GraphQL query.
See below for instructions.
2. After enabling the feature flag, you should be able to see the
workflow you created in the workflows list. To visualize the workflow,
go to the `/workflow/:id` page where the id is the id of the workflow.
See the video for a quick way to do so.
```gql
// First
mutation createWorkflow($data: WorkflowCreateInput!) {
createWorkflow(data: $data) {
id
}
}
// Result
{
"data": {
"name": "test"
}
}
// Second
mutation createWorkflowVersion($data: WorkflowVersionCreateInput!) {
createWorkflowVersion (data: $data) {
id
}
}
// Result
{
"data": {
"name": "v1",
"trigger": {
"name": "trigger",
"displayName": "New or Updated Row",
"type": "DATABASE_EVENT",
"settings": {
"eventName": "company.created",
"triggerName": "Company Created"
},
"nextAction": {
"name": "step_1",
"displayName": "Code",
"type": "CODE",
"valid": true,
"settings": {
"serverlessFunctionId": "function_id",
"errorHandlingOptions": {
"retryOnFailure": {
"value": false
},
"continueOnFailure": {
"value": false
}
}
}
}
},
"workflowId": "workflow_id"
}
}
```
https://github.com/user-attachments/assets/42bbd98c-5e13-447c-9307-461a18ac2195
This commit is contained in:
committed by
GitHub
parent
873a4c1bd1
commit
e49acae851
@ -30,6 +30,7 @@
|
|||||||
"workerDirectory": "public"
|
"workerDirectory": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.0.4",
|
||||||
"transliteration": "^2.3.5"
|
"transliteration": "^2.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,4 +28,5 @@ export enum CoreObjectNameSingular {
|
|||||||
Webhook = 'webhook',
|
Webhook = 'webhook',
|
||||||
WorkspaceMember = 'workspaceMember',
|
WorkspaceMember = 'workspaceMember',
|
||||||
MessageThreadSubscriber = 'messageThreadSubscriber',
|
MessageThreadSubscriber = 'messageThreadSubscriber',
|
||||||
|
Workflow = 'workflow',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { ApolloError } from '@apollo/client';
|
||||||
|
import { createState } from 'twenty-ui';
|
||||||
|
|
||||||
|
export const showPageWorkflowErrorState = createState<ApolloError | undefined>({
|
||||||
|
key: 'showPageWorkflowErrorState',
|
||||||
|
defaultValue: undefined,
|
||||||
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createState } from 'twenty-ui';
|
||||||
|
|
||||||
|
export const showPageWorkflowLoadingState = createState<boolean>({
|
||||||
|
key: 'showPageWorkflowLoadingState',
|
||||||
|
defaultValue: true,
|
||||||
|
});
|
||||||
66
packages/twenty-front/src/modules/workflow/types/Workflow.ts
Normal file
66
packages/twenty-front/src/modules/workflow/types/Workflow.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||||
|
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
|
||||||
|
import { addCreateStepNodes } from '../addCreateStepNodes';
|
||||||
|
|
||||||
|
describe('addCreateStepNodes', () => {
|
||||||
|
it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => {
|
||||||
|
const trigger: WorkflowTrigger = {
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
settings: {
|
||||||
|
eventName: 'company.created',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const steps: WorkflowStep[] = [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const diagramInitial = generateWorkflowDiagram({ trigger, steps });
|
||||||
|
|
||||||
|
expect(diagramInitial.nodes).toHaveLength(3);
|
||||||
|
expect(diagramInitial.edges).toHaveLength(2);
|
||||||
|
|
||||||
|
const diagramWithCreateStepNodes = addCreateStepNodes(diagramInitial);
|
||||||
|
|
||||||
|
expect(diagramWithCreateStepNodes.nodes).toHaveLength(4);
|
||||||
|
expect(diagramWithCreateStepNodes.edges).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[0].type).toBe(undefined);
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[0].data.nodeType).toBe('trigger');
|
||||||
|
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[1].type).toBe(undefined);
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[1].data.nodeType).toBe('action');
|
||||||
|
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[2].type).toBe(undefined);
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[2].data.nodeType).toBe('action');
|
||||||
|
|
||||||
|
expect(diagramWithCreateStepNodes.nodes[3].type).toBe('create-step');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||||
|
import { generateWorkflowDiagram } from '../generateWorkflowDiagram';
|
||||||
|
|
||||||
|
describe('generateWorkflowDiagram', () => {
|
||||||
|
it('should generate a single trigger node when no step is provided', () => {
|
||||||
|
const trigger: WorkflowTrigger = {
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
settings: {
|
||||||
|
eventName: 'company.created',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const steps: WorkflowStep[] = [];
|
||||||
|
|
||||||
|
const result = generateWorkflowDiagram({ trigger, steps });
|
||||||
|
|
||||||
|
expect(result.nodes).toHaveLength(1);
|
||||||
|
expect(result.edges).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(result.nodes[0]).toMatchObject({
|
||||||
|
data: {
|
||||||
|
label: trigger.settings.eventName,
|
||||||
|
nodeType: 'trigger',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a diagram with nodes and edges corresponding to the steps', () => {
|
||||||
|
const trigger: WorkflowTrigger = {
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
settings: {
|
||||||
|
eventName: 'company.created',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const steps: WorkflowStep[] = [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateWorkflowDiagram({ trigger, steps });
|
||||||
|
|
||||||
|
expect(result.nodes).toHaveLength(steps.length + 1); // All steps + trigger
|
||||||
|
expect(result.edges).toHaveLength(steps.length - 1 + 1); // Edges are one less than nodes + the edge from the trigger to the first node
|
||||||
|
|
||||||
|
expect(result.nodes[0].data.nodeType).toBe('trigger');
|
||||||
|
|
||||||
|
const stepNodes = result.nodes.slice(1);
|
||||||
|
|
||||||
|
for (const [index, step] of steps.entries()) {
|
||||||
|
expect(stepNodes[index].data.nodeType).toBe('action');
|
||||||
|
expect(stepNodes[index].data.label).toBe(step.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly link nodes with edges', () => {
|
||||||
|
const trigger: WorkflowTrigger = {
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
settings: {
|
||||||
|
eventName: 'company.created',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const steps: WorkflowStep[] = [
|
||||||
|
{
|
||||||
|
id: 'step1',
|
||||||
|
name: 'Step 1',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step2',
|
||||||
|
name: 'Step 2',
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateWorkflowDiagram({ trigger, steps });
|
||||||
|
|
||||||
|
expect(result.edges[0].source).toEqual(result.nodes[0].id);
|
||||||
|
expect(result.edges[0].target).toEqual(result.nodes[1].id);
|
||||||
|
|
||||||
|
expect(result.edges[1].source).toEqual(result.nodes[1].id);
|
||||||
|
expect(result.edges[1].target).toEqual(result.nodes[2].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { Workflow } from '@/workflow/types/Workflow';
|
||||||
|
import { getWorkflowLastDiagramVersion } from '../getWorkflowLastDiagramVersion';
|
||||||
|
|
||||||
|
describe('getWorkflowLastDiagramVersion', () => {
|
||||||
|
it('returns an empty diagram if the provided workflow is undefined', () => {
|
||||||
|
const result = getWorkflowLastDiagramVersion(undefined);
|
||||||
|
|
||||||
|
expect(result).toEqual({ nodes: [], edges: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty diagram if the provided workflow has no versions', () => {
|
||||||
|
const result = getWorkflowLastDiagramVersion({
|
||||||
|
__typename: 'Workflow',
|
||||||
|
id: 'aa',
|
||||||
|
name: 'aa',
|
||||||
|
publishedVersionId: '',
|
||||||
|
versions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ nodes: [], edges: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the diagram for the last version', () => {
|
||||||
|
const workflow: Workflow = {
|
||||||
|
__typename: 'Workflow',
|
||||||
|
id: 'aa',
|
||||||
|
name: 'aa',
|
||||||
|
publishedVersionId: '',
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
__typename: 'WorkflowVersion',
|
||||||
|
createdAt: '',
|
||||||
|
id: '1',
|
||||||
|
name: '',
|
||||||
|
steps: [],
|
||||||
|
trigger: {
|
||||||
|
settings: { eventName: 'company.created' },
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
},
|
||||||
|
updatedAt: '',
|
||||||
|
workflowId: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
__typename: 'WorkflowVersion',
|
||||||
|
createdAt: '',
|
||||||
|
id: '1',
|
||||||
|
name: '',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step-1',
|
||||||
|
name: '',
|
||||||
|
settings: {
|
||||||
|
errorHandlingOptions: {
|
||||||
|
retryOnFailure: { value: true },
|
||||||
|
continueOnFailure: { value: false },
|
||||||
|
},
|
||||||
|
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||||
|
},
|
||||||
|
type: 'CODE_ACTION',
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trigger: {
|
||||||
|
settings: { eventName: 'company.created' },
|
||||||
|
type: 'DATABASE_EVENT',
|
||||||
|
},
|
||||||
|
updatedAt: '',
|
||||||
|
workflowId: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getWorkflowLastDiagramVersion(workflow);
|
||||||
|
|
||||||
|
// Corresponds to the trigger + 1 step
|
||||||
|
expect(result.nodes).toHaveLength(2);
|
||||||
|
expect(result.edges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
WorkflowDiagram,
|
||||||
|
WorkflowDiagramEdge,
|
||||||
|
WorkflowDiagramNode,
|
||||||
|
} from '@/workflow/types/WorkflowDiagram';
|
||||||
|
import { MarkerType } from '@xyflow/react';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => {
|
||||||
|
const nodesWithoutTargets = nodes.filter((node) =>
|
||||||
|
edges.every((edge) => edge.source !== node.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedNodes: Array<WorkflowDiagramNode> = nodes.slice();
|
||||||
|
const updatedEdges: Array<WorkflowDiagramEdge> = edges.slice();
|
||||||
|
|
||||||
|
for (const node of nodesWithoutTargets) {
|
||||||
|
const newCreateStepNode: WorkflowDiagramNode = {
|
||||||
|
id: v4(),
|
||||||
|
type: 'create-step',
|
||||||
|
data: {},
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
updatedNodes.push(newCreateStepNode);
|
||||||
|
|
||||||
|
updatedEdges.push({
|
||||||
|
id: v4(),
|
||||||
|
source: node.id,
|
||||||
|
target: newCreateStepNode.id,
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: updatedNodes,
|
||||||
|
edges: updatedEdges,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||||
|
import {
|
||||||
|
WorkflowDiagram,
|
||||||
|
WorkflowDiagramEdge,
|
||||||
|
WorkflowDiagramNode,
|
||||||
|
} from '@/workflow/types/WorkflowDiagram';
|
||||||
|
import { MarkerType } from '@xyflow/react';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
export const generateWorkflowDiagram = ({
|
||||||
|
trigger,
|
||||||
|
steps,
|
||||||
|
}: {
|
||||||
|
trigger: WorkflowTrigger;
|
||||||
|
steps: Array<WorkflowStep>;
|
||||||
|
}): WorkflowDiagram => {
|
||||||
|
const nodes: Array<WorkflowDiagramNode> = [];
|
||||||
|
const edges: Array<WorkflowDiagramEdge> = [];
|
||||||
|
|
||||||
|
// Helper function to generate nodes and edges recursively
|
||||||
|
const processNode = (
|
||||||
|
step: WorkflowStep,
|
||||||
|
parentNodeId: string,
|
||||||
|
xPos: number,
|
||||||
|
yPos: number,
|
||||||
|
) => {
|
||||||
|
const nodeId = v4();
|
||||||
|
nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
data: {
|
||||||
|
nodeType: 'action',
|
||||||
|
label: step.name,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
x: xPos,
|
||||||
|
y: yPos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an edge from the parent node to this node
|
||||||
|
edges.push({
|
||||||
|
id: v4(),
|
||||||
|
source: parentNodeId,
|
||||||
|
target: nodeId,
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recursively generate flow for the next action if it exists
|
||||||
|
if (step.type !== 'CODE_ACTION') {
|
||||||
|
// processNode(action.nextAction, nodeId, xPos + 150, yPos + 100);
|
||||||
|
|
||||||
|
throw new Error('Other types as code actions are not supported yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start with the trigger node
|
||||||
|
const triggerNodeId = v4();
|
||||||
|
nodes.push({
|
||||||
|
id: triggerNodeId,
|
||||||
|
data: {
|
||||||
|
nodeType: 'trigger',
|
||||||
|
label: trigger.settings.eventName,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastStepId = triggerNodeId;
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
lastStepId = processNode(step, lastStepId, 150, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
|
||||||
|
import Dagre from '@dagrejs/dagre';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the position of the nodes in the diagram. The positions are computed with a layouting algorithm.
|
||||||
|
*/
|
||||||
|
export const getOrganizedDiagram = (
|
||||||
|
diagram: WorkflowDiagram,
|
||||||
|
): WorkflowDiagram => {
|
||||||
|
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
graph.setGraph({ rankdir: 'TB' });
|
||||||
|
|
||||||
|
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
||||||
|
diagram.nodes.forEach((node) =>
|
||||||
|
graph.setNode(node.id, {
|
||||||
|
...node,
|
||||||
|
width: node.measured?.width ?? 0,
|
||||||
|
height: node.measured?.height ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Dagre.layout(graph);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: diagram.nodes.map((node) => {
|
||||||
|
const position = graph.node(node.id);
|
||||||
|
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||||
|
// so it matches the React Flow node anchor point (top left).
|
||||||
|
const x = position.x - (node.measured?.width ?? 0) / 2;
|
||||||
|
const y = position.y - (node.measured?.height ?? 0) / 2;
|
||||||
|
|
||||||
|
return { ...node, position: { x, y } };
|
||||||
|
}),
|
||||||
|
edges: diagram.edges,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { Workflow } from '@/workflow/types/Workflow';
|
||||||
|
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
|
||||||
|
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
|
||||||
|
import { isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
|
const EMPTY_DIAGRAM: WorkflowDiagram = {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkflowLastDiagramVersion = (
|
||||||
|
workflow: Workflow | undefined,
|
||||||
|
): WorkflowDiagram => {
|
||||||
|
if (!isDefined(workflow)) {
|
||||||
|
return EMPTY_DIAGRAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastVersion = workflow.versions.at(-1);
|
||||||
|
if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) {
|
||||||
|
return EMPTY_DIAGRAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateWorkflowDiagram({
|
||||||
|
trigger: lastVersion.trigger,
|
||||||
|
steps: lastVersion.steps,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -153,6 +153,7 @@ export {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconSend,
|
IconSend,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconSettingsAutomation,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconSql,
|
IconSql,
|
||||||
|
|||||||
40
yarn.lock
40
yarn.lock
@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user