mirror of
https://github.com/lingble/twenty.git
synced 2025-11-01 05:07:56 +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:
committed by
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}
|
||||||
|
|
||||||
<StyledStatusTagContainer>
|
|
||||||
<WorkflowVersionStatusTag versionStatus={status} />
|
|
||||||
</StyledStatusTagContainer>
|
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
|
<StyledStatusTagContainer>
|
||||||
|
<WorkflowVersionStatusTag versionStatus={status} />
|
||||||
|
</StyledStatusTagContainer>
|
||||||
</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,17 +15,19 @@ export const WorkflowDiagramCanvasEditable = ({
|
|||||||
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
|
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<WorkflowDiagramCanvasBase
|
<ReactFlowProvider>
|
||||||
key={workflowWithCurrentVersion.currentVersion.id}
|
<WorkflowDiagramCanvasBase
|
||||||
diagram={diagram}
|
key={workflowWithCurrentVersion.currentVersion.id}
|
||||||
status={workflowWithCurrentVersion.currentVersion.status}
|
diagram={diagram}
|
||||||
nodeTypes={{
|
status={workflowWithCurrentVersion.currentVersion.status}
|
||||||
default: WorkflowDiagramStepNodeEditable,
|
nodeTypes={{
|
||||||
'create-step': WorkflowDiagramCreateStepNode,
|
default: WorkflowDiagramStepNodeEditable,
|
||||||
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
'create-step': WorkflowDiagramCreateStepNode,
|
||||||
}}
|
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
||||||
>
|
}}
|
||||||
<WorkflowDiagramCanvasEditableEffect />
|
>
|
||||||
</WorkflowDiagramCanvasBase>
|
<WorkflowDiagramCanvasEditableEffect />
|
||||||
|
</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,16 +14,18 @@ export const WorkflowDiagramCanvasReadonly = ({
|
|||||||
workflowVersion: WorkflowVersion;
|
workflowVersion: WorkflowVersion;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<WorkflowDiagramCanvasBase
|
<ReactFlowProvider>
|
||||||
key={workflowVersion.id}
|
<WorkflowDiagramCanvasBase
|
||||||
diagram={diagram}
|
key={workflowVersion.id}
|
||||||
status={workflowVersion.status}
|
diagram={diagram}
|
||||||
nodeTypes={{
|
status={workflowVersion.status}
|
||||||
default: WorkflowDiagramStepNodeReadonly,
|
nodeTypes={{
|
||||||
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
default: WorkflowDiagramStepNodeReadonly,
|
||||||
}}
|
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
||||||
>
|
}}
|
||||||
<WorkflowDiagramCanvasReadonlyEffect />
|
>
|
||||||
</WorkflowDiagramCanvasBase>
|
<WorkflowDiagramCanvasReadonlyEffect />
|
||||||
|
</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