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
2024-11-12 17:52:12 +01:00
committed by GitHub
parent aadcb49dcb
commit 31f03764d6
9 changed files with 131 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => {
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
}, },
deletable: false,
}); });
} }

View File

@@ -51,6 +51,7 @@ export const generateWorkflowDiagram = ({
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
}, },
deletable: false,
}); });
return nodeId; return nodeId;