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
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user