mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	Improve the layout of the Workflow Visualizer (#8372)
- Increase the dimensions of the ReactFlow nodes. This allows to ditch scaling which made it hard to get the width of the nodes as they were visually scaled by 1.3. - Center the flow when the flow mounts and when the state of the right drawer opens. - Put the node type inside of the node so it doesn't overlap with the arrow - Make the edges non deletable We'll have to make a refactor so the viewport can be animated properly: https://github.com/twentyhq/twenty/issues/8387. https://github.com/user-attachments/assets/69494a32-5403-4898-be75-7fc38058e263 --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
		 Baptiste Devessier
					Baptiste Devessier
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							aadcb49dcb
						
					
				
				
					commit
					31f03764d6
				
			| @@ -21,13 +21,12 @@ const StyledStepNodeType = styled.div` | |||||||
|     ${({ theme }) => theme.border.radius.sm} 0 0; |     ${({ theme }) => theme.border.radius.sm} 0 0; | ||||||
|  |  | ||||||
|   color: ${({ theme }) => theme.color.gray50}; |   color: ${({ theme }) => theme.color.gray50}; | ||||||
|   font-size: ${({ theme }) => theme.font.size.xs}; |   font-size: ${({ theme }) => theme.font.size.md}; | ||||||
|   font-weight: ${({ theme }) => theme.font.weight.semiBold}; |   font-weight: ${({ theme }) => theme.font.weight.semiBold}; | ||||||
|  |  | ||||||
|  |   margin-left: ${({ theme }) => theme.spacing(2)}; | ||||||
|   padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; |   padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; | ||||||
|   position: absolute; |   align-self: flex-start; | ||||||
|   top: 0; |  | ||||||
|   transform: translateY(-100%); |  | ||||||
|  |  | ||||||
|   .selectable.selected &, |   .selectable.selected &, | ||||||
|   .selectable:focus &, |   .selectable:focus &, | ||||||
| @@ -62,9 +61,9 @@ const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>` | |||||||
| const StyledStepNodeLabel = styled.div<{ variant?: Variant }>` | const StyledStepNodeLabel = styled.div<{ variant?: Variant }>` | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   display: flex; |   display: flex; | ||||||
|   font-size: ${({ theme }) => theme.font.size.md}; |   font-size: ${({ theme }) => theme.font.size.lg}; | ||||||
|   font-weight: ${({ theme }) => theme.font.weight.medium}; |   font-weight: ${({ theme }) => theme.font.weight.medium}; | ||||||
|   column-gap: ${({ theme }) => theme.spacing(2)}; |   column-gap: ${({ theme }) => theme.spacing(3)}; | ||||||
|   color: ${({ variant, theme }) => |   color: ${({ variant, theme }) => | ||||||
|     variant === 'placeholder' |     variant === 'placeholder' | ||||||
|       ? theme.font.color.extraLight |       ? theme.font.color.extraLight | ||||||
| @@ -80,9 +79,13 @@ export const StyledTargetHandle = styled(Handle)` | |||||||
| `; | `; | ||||||
|  |  | ||||||
| const StyledRightFloatingElementContainer = styled.div` | const StyledRightFloatingElementContainer = styled.div` | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|   position: absolute; |   position: absolute; | ||||||
|  |   right: ${({ theme }) => theme.spacing(-3)}; | ||||||
|  |   bottom: 0; | ||||||
|  |   top: 0; | ||||||
|   transform: translateX(100%); |   transform: translateX(100%); | ||||||
|   right: ${({ theme }) => theme.spacing(-2)}; |  | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export const WorkflowDiagramBaseStepNode = ({ | export const WorkflowDiagramBaseStepNode = ({ | ||||||
| @@ -104,9 +107,9 @@ export const WorkflowDiagramBaseStepNode = ({ | |||||||
|         <StyledTargetHandle type="target" position={Position.Top} /> |         <StyledTargetHandle type="target" position={Position.Top} /> | ||||||
|       ) : null} |       ) : null} | ||||||
|  |  | ||||||
|       <StyledStepNodeInnerContainer variant={variant}> |  | ||||||
|       <StyledStepNodeType>{capitalize(nodeType)}</StyledStepNodeType> |       <StyledStepNodeType>{capitalize(nodeType)}</StyledStepNodeType> | ||||||
|  |  | ||||||
|  |       <StyledStepNodeInnerContainer variant={variant}> | ||||||
|         <StyledStepNodeLabel variant={variant}> |         <StyledStepNodeLabel variant={variant}> | ||||||
|           {Icon} |           {Icon} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,6 @@ | |||||||
|  | import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; | ||||||
|  | import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; | ||||||
|  | import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; | ||||||
| import { WorkflowVersionStatusTag } from '@/workflow/components/WorkflowVersionStatusTag'; | import { WorkflowVersionStatusTag } from '@/workflow/components/WorkflowVersionStatusTag'; | ||||||
| import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; | import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; | ||||||
| import { WorkflowVersionStatus } from '@/workflow/types/Workflow'; | import { WorkflowVersionStatus } from '@/workflow/types/Workflow'; | ||||||
| @@ -15,14 +18,16 @@ import { | |||||||
|   Background, |   Background, | ||||||
|   EdgeChange, |   EdgeChange, | ||||||
|   FitViewOptions, |   FitViewOptions, | ||||||
|  |   getNodesBounds, | ||||||
|   NodeChange, |   NodeChange, | ||||||
|   NodeProps, |   NodeProps, | ||||||
|   ReactFlow, |   ReactFlow, | ||||||
|  |   useReactFlow, | ||||||
| } from '@xyflow/react'; | } from '@xyflow/react'; | ||||||
| import '@xyflow/react/dist/style.css'; | import '@xyflow/react/dist/style.css'; | ||||||
| import React, { useMemo } from 'react'; | import React, { useEffect, useMemo, useRef } from 'react'; | ||||||
| import { useSetRecoilState } from 'recoil'; | import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||||
| import { GRAY_SCALE, isDefined } from 'twenty-ui'; | import { GRAY_SCALE, isDefined, THEME_COMMON } from 'twenty-ui'; | ||||||
|  |  | ||||||
| const StyledResetReactflowStyles = styled.div` | const StyledResetReactflowStyles = styled.div` | ||||||
|   height: 100%; |   height: 100%; | ||||||
| @@ -35,6 +40,9 @@ const StyledResetReactflowStyles = styled.div` | |||||||
|   .react-flow__node-output, |   .react-flow__node-output, | ||||||
|   .react-flow__node-group { |   .react-flow__node-group { | ||||||
|     padding: 0; |     padding: 0; | ||||||
|  |     width: auto; | ||||||
|  |     text-align: start; | ||||||
|  |     white-space: nowrap; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   --xy-node-border-radius: none; |   --xy-node-border-radius: none; | ||||||
| @@ -51,10 +59,10 @@ const StyledStatusTagContainer = styled.div` | |||||||
|   padding: ${({ theme }) => theme.spacing(2)}; |   padding: ${({ theme }) => theme.spacing(2)}; | ||||||
| `; | `; | ||||||
|  |  | ||||||
| const defaultFitViewOptions: FitViewOptions = { | const defaultFitViewOptions = { | ||||||
|   minZoom: 1.3, |   minZoom: 1, | ||||||
|   maxZoom: 1.3, |   maxZoom: 1, | ||||||
| }; | } satisfies FitViewOptions; | ||||||
|  |  | ||||||
| export const WorkflowDiagramCanvasBase = ({ | export const WorkflowDiagramCanvasBase = ({ | ||||||
|   diagram, |   diagram, | ||||||
| @@ -77,11 +85,29 @@ export const WorkflowDiagramCanvasBase = ({ | |||||||
|   >; |   >; | ||||||
|   children?: React.ReactNode; |   children?: React.ReactNode; | ||||||
| }) => { | }) => { | ||||||
|  |   const reactflow = useReactFlow(); | ||||||
|  |  | ||||||
|   const { nodes, edges } = useMemo( |   const { nodes, edges } = useMemo( | ||||||
|     () => getOrganizedDiagram(diagram), |     () => getOrganizedDiagram(diagram), | ||||||
|     [diagram], |     [diagram], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  |   const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState); | ||||||
|  |   const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState); | ||||||
|  |   const isMobile = useIsMobile(); | ||||||
|  |  | ||||||
|  |   const rightDrawerState = !isRightDrawerOpen | ||||||
|  |     ? 'closed' | ||||||
|  |     : isRightDrawerMinimized | ||||||
|  |       ? 'minimized' | ||||||
|  |       : isMobile | ||||||
|  |         ? 'fullScreen' | ||||||
|  |         : 'normal'; | ||||||
|  |  | ||||||
|  |   const rightDrawerWidth = Number( | ||||||
|  |     THEME_COMMON.rightDrawerWidth.replace('px', ''), | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); |   const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); | ||||||
|  |  | ||||||
|   const handleNodesChange = ( |   const handleNodesChange = ( | ||||||
| @@ -118,27 +144,68 @@ export const WorkflowDiagramCanvasBase = ({ | |||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const containerRef = useRef<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!isDefined(containerRef.current) || !reactflow.viewportInitialized) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const currentViewport = reactflow.getViewport(); | ||||||
|  |  | ||||||
|  |     const flowBounds = getNodesBounds(reactflow.getNodes()); | ||||||
|  |  | ||||||
|  |     let visibleRightDrawerWidth = 0; | ||||||
|  |     if (rightDrawerState === 'normal') { | ||||||
|  |       visibleRightDrawerWidth = rightDrawerWidth; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const viewportX = | ||||||
|  |       (containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 - | ||||||
|  |       flowBounds.width / 2; | ||||||
|  |  | ||||||
|  |     reactflow.setViewport( | ||||||
|  |       { | ||||||
|  |         ...currentViewport, | ||||||
|  |         x: viewportX - visibleRightDrawerWidth, | ||||||
|  |       }, | ||||||
|  |       { duration: 300 }, | ||||||
|  |     ); | ||||||
|  |   }, [reactflow, rightDrawerState, rightDrawerWidth]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <StyledResetReactflowStyles> |     <StyledResetReactflowStyles ref={containerRef}> | ||||||
|       <ReactFlow |       <ReactFlow | ||||||
|         onInit={({ fitView }) => { |         onInit={() => { | ||||||
|           fitView(defaultFitViewOptions); |           if (!isDefined(containerRef.current)) { | ||||||
|  |             throw new Error('Expect the container ref to be defined'); | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           const flowBounds = getNodesBounds(reactflow.getNodes()); | ||||||
|  |  | ||||||
|  |           reactflow.setViewport({ | ||||||
|  |             x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2, | ||||||
|  |             y: 150, | ||||||
|  |             zoom: defaultFitViewOptions.maxZoom, | ||||||
|  |           }); | ||||||
|         }} |         }} | ||||||
|  |         minZoom={defaultFitViewOptions.minZoom} | ||||||
|  |         maxZoom={defaultFitViewOptions.maxZoom} | ||||||
|         nodeTypes={nodeTypes} |         nodeTypes={nodeTypes} | ||||||
|         fitView |  | ||||||
|         nodes={nodes.map((node) => ({ ...node, draggable: false }))} |         nodes={nodes.map((node) => ({ ...node, draggable: false }))} | ||||||
|         edges={edges} |         edges={edges} | ||||||
|         onNodesChange={handleNodesChange} |         onNodesChange={handleNodesChange} | ||||||
|         onEdgesChange={handleEdgesChange} |         onEdgesChange={handleEdgesChange} | ||||||
|  |         proOptions={{ hideAttribution: true }} | ||||||
|       > |       > | ||||||
|         <Background color={GRAY_SCALE.gray25} size={2} /> |         <Background color={GRAY_SCALE.gray25} size={2} /> | ||||||
|  |  | ||||||
|         {children} |         {children} | ||||||
|  |       </ReactFlow> | ||||||
|  |  | ||||||
|       <StyledStatusTagContainer> |       <StyledStatusTagContainer> | ||||||
|         <WorkflowVersionStatusTag versionStatus={status} /> |         <WorkflowVersionStatusTag versionStatus={status} /> | ||||||
|       </StyledStatusTagContainer> |       </StyledStatusTagContainer> | ||||||
|       </ReactFlow> |  | ||||||
|     </StyledResetReactflowStyles> |     </StyledResetReactflowStyles> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagr | |||||||
| import { WorkflowDiagramStepNodeEditable } from '@/workflow/components/WorkflowDiagramStepNodeEditable'; | import { WorkflowDiagramStepNodeEditable } from '@/workflow/components/WorkflowDiagramStepNodeEditable'; | ||||||
| import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; | import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; | ||||||
| import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | ||||||
|  | import { ReactFlowProvider } from '@xyflow/react'; | ||||||
|  |  | ||||||
| export const WorkflowDiagramCanvasEditable = ({ | export const WorkflowDiagramCanvasEditable = ({ | ||||||
|   diagram, |   diagram, | ||||||
| @@ -14,6 +15,7 @@ export const WorkflowDiagramCanvasEditable = ({ | |||||||
|   workflowWithCurrentVersion: WorkflowWithCurrentVersion; |   workflowWithCurrentVersion: WorkflowWithCurrentVersion; | ||||||
| }) => { | }) => { | ||||||
|   return ( |   return ( | ||||||
|  |     <ReactFlowProvider> | ||||||
|       <WorkflowDiagramCanvasBase |       <WorkflowDiagramCanvasBase | ||||||
|         key={workflowWithCurrentVersion.currentVersion.id} |         key={workflowWithCurrentVersion.currentVersion.id} | ||||||
|         diagram={diagram} |         diagram={diagram} | ||||||
| @@ -26,5 +28,6 @@ export const WorkflowDiagramCanvasEditable = ({ | |||||||
|       > |       > | ||||||
|         <WorkflowDiagramCanvasEditableEffect /> |         <WorkflowDiagramCanvasEditableEffect /> | ||||||
|       </WorkflowDiagramCanvasBase> |       </WorkflowDiagramCanvasBase> | ||||||
|  |     </ReactFlowProvider> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagr | |||||||
| import { WorkflowDiagramStepNodeReadonly } from '@/workflow/components/WorkflowDiagramStepNodeReadonly'; | import { WorkflowDiagramStepNodeReadonly } from '@/workflow/components/WorkflowDiagramStepNodeReadonly'; | ||||||
| import { WorkflowVersion } from '@/workflow/types/Workflow'; | import { WorkflowVersion } from '@/workflow/types/Workflow'; | ||||||
| import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | ||||||
|  | import { ReactFlowProvider } from '@xyflow/react'; | ||||||
|  |  | ||||||
| export const WorkflowDiagramCanvasReadonly = ({ | export const WorkflowDiagramCanvasReadonly = ({ | ||||||
|   diagram, |   diagram, | ||||||
| @@ -13,6 +14,7 @@ export const WorkflowDiagramCanvasReadonly = ({ | |||||||
|   workflowVersion: WorkflowVersion; |   workflowVersion: WorkflowVersion; | ||||||
| }) => { | }) => { | ||||||
|   return ( |   return ( | ||||||
|  |     <ReactFlowProvider> | ||||||
|       <WorkflowDiagramCanvasBase |       <WorkflowDiagramCanvasBase | ||||||
|         key={workflowVersion.id} |         key={workflowVersion.id} | ||||||
|         diagram={diagram} |         diagram={diagram} | ||||||
| @@ -24,5 +26,6 @@ export const WorkflowDiagramCanvasReadonly = ({ | |||||||
|       > |       > | ||||||
|         <WorkflowDiagramCanvasReadonlyEffect /> |         <WorkflowDiagramCanvasReadonlyEffect /> | ||||||
|       </WorkflowDiagramCanvasBase> |       </WorkflowDiagramCanvasBase> | ||||||
|  |     </ReactFlowProvider> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ export const WorkflowDiagramCreateStepNode = () => { | |||||||
|     <> |     <> | ||||||
|       <StyledTargetHandle type="target" position={Position.Top} /> |       <StyledTargetHandle type="target" position={Position.Top} /> | ||||||
|  |  | ||||||
|       <IconButton Icon={IconPlus} size="small" /> |       <IconButton Icon={IconPlus} size="medium" /> | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -31,7 +31,7 @@ export const WorkflowDiagramStepNodeBase = ({ | |||||||
|             return ( |             return ( | ||||||
|               <StyledStepNodeLabelIconContainer> |               <StyledStepNodeLabelIconContainer> | ||||||
|                 <IconPlaylistAdd |                 <IconPlaylistAdd | ||||||
|                   size={theme.icon.size.sm} |                   size={theme.icon.size.lg} | ||||||
|                   color={theme.font.color.tertiary} |                   color={theme.font.color.tertiary} | ||||||
|                 /> |                 /> | ||||||
|               </StyledStepNodeLabelIconContainer> |               </StyledStepNodeLabelIconContainer> | ||||||
| @@ -41,7 +41,7 @@ export const WorkflowDiagramStepNodeBase = ({ | |||||||
|             return ( |             return ( | ||||||
|               <StyledStepNodeLabelIconContainer> |               <StyledStepNodeLabelIconContainer> | ||||||
|                 <IconHandMove |                 <IconHandMove | ||||||
|                   size={theme.icon.size.sm} |                   size={theme.icon.size.lg} | ||||||
|                   color={theme.font.color.tertiary} |                   color={theme.font.color.tertiary} | ||||||
|                 /> |                 /> | ||||||
|               </StyledStepNodeLabelIconContainer> |               </StyledStepNodeLabelIconContainer> | ||||||
| @@ -57,7 +57,7 @@ export const WorkflowDiagramStepNodeBase = ({ | |||||||
|             return ( |             return ( | ||||||
|               <StyledStepNodeLabelIconContainer> |               <StyledStepNodeLabelIconContainer> | ||||||
|                 <IconCode |                 <IconCode | ||||||
|                   size={theme.icon.size.sm} |                   size={theme.icon.size.lg} | ||||||
|                   color={theme.color.orange} |                   color={theme.color.orange} | ||||||
|                 /> |                 /> | ||||||
|               </StyledStepNodeLabelIconContainer> |               </StyledStepNodeLabelIconContainer> | ||||||
| @@ -66,7 +66,7 @@ export const WorkflowDiagramStepNodeBase = ({ | |||||||
|           case 'SEND_EMAIL': { |           case 'SEND_EMAIL': { | ||||||
|             return ( |             return ( | ||||||
|               <StyledStepNodeLabelIconContainer> |               <StyledStepNodeLabelIconContainer> | ||||||
|                 <IconMail size={theme.icon.size.sm} color={theme.color.blue} /> |                 <IconMail size={theme.icon.size.lg} color={theme.color.blue} /> | ||||||
|               </StyledStepNodeLabelIconContainer> |               </StyledStepNodeLabelIconContainer> | ||||||
|             ); |             ); | ||||||
|           } |           } | ||||||
|   | |||||||
| @@ -32,6 +32,7 @@ export const WorkflowDiagramStepNodeEditable = ({ | |||||||
|       RightFloatingElement={ |       RightFloatingElement={ | ||||||
|         selected ? ( |         selected ? ( | ||||||
|           <FloatingIconButton |           <FloatingIconButton | ||||||
|  |             size="medium" | ||||||
|             Icon={IconTrash} |             Icon={IconTrash} | ||||||
|             onClick={() => { |             onClick={() => { | ||||||
|               return deleteOneStep(); |               return deleteOneStep(); | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => { | |||||||
|       markerEnd: { |       markerEnd: { | ||||||
|         type: MarkerType.ArrowClosed, |         type: MarkerType.ArrowClosed, | ||||||
|       }, |       }, | ||||||
|  |       deletable: false, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,6 +51,7 @@ export const generateWorkflowDiagram = ({ | |||||||
|       markerEnd: { |       markerEnd: { | ||||||
|         type: MarkerType.ArrowClosed, |         type: MarkerType.ArrowClosed, | ||||||
|       }, |       }, | ||||||
|  |       deletable: false, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     return nodeId; |     return nodeId; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user