Add workflow success edge (#10120)

- Refactor the handles: the source handles are now part of the edges as
markerStart
- **As the source handles are now part of the edges, we can delete the
`markLeafNodes` logic; this can be done in another PR**. See
https://github.com/twentyhq/core-team-issues/issues/386
- Create a custom edge component for the default edge
- Create a custom edge component for the success edge; this includes a
label

**The edges can be tested in Storybook. I wrote two stories for the
edges.**

| Default | Success |
|--------|--------|
| ![CleanShot 2025-02-11 at 11 46
09@2x](https://github.com/user-attachments/assets/c7c42328-6502-4c77-bdc9-dea825d4651a)
| ![CleanShot 2025-02-11 at 11 46
16@2x](https://github.com/user-attachments/assets/572204de-299c-4cbc-9900-46744b59c351)
|
This commit is contained in:
Baptiste Devessier
2025-02-11 14:01:11 +01:00
committed by GitHub
parent 4f06b83d7f
commit 179d3ae2a4
20 changed files with 376 additions and 18 deletions

View File

@ -28,7 +28,9 @@ test('Create workflow', async ({ page }) => {
const nameInput = page.getByRole('textbox');
await nameInput.fill(NEW_WORKFLOW_NAME);
await nameInput.press('Enter');
const workflowDiagramContainer = page.locator('.react-flow__renderer');
await workflowDiagramContainer.click();
const body = await createWorkflowResponse.json();
const newWorkflowId = body.data.createWorkflow.id;

View File

@ -80,7 +80,7 @@ export const CardComponents: Record<CardType, CardComponentType> = {
[CardType.WorkflowCard]: ({ targetableObject }) => (
<>
<WorkflowVisualizerEffect workflowId={targetableObject.id} />
<WorkflowVisualizer targetableObject={targetableObject} />
<WorkflowVisualizer workflowId={targetableObject.id} />
</>
),

View File

@ -8,6 +8,7 @@ import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflo
import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/workflowReactFlowRefState';
import {
WorkflowDiagramEdge,
WorkflowDiagramEdgeType,
WorkflowDiagramNode,
WorkflowDiagramNodeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
@ -17,6 +18,7 @@ import styled from '@emotion/styled';
import {
Background,
EdgeChange,
EdgeProps,
FitViewOptions,
NodeChange,
NodeProps,
@ -61,8 +63,6 @@ const StyledResetReactflowStyles = styled.div`
cursor: pointer;
}
--xy-edge-stroke: ${({ theme }) => theme.border.color.strong};
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
@ -85,6 +85,7 @@ const defaultFitViewOptions = {
export const WorkflowDiagramCanvasBase = ({
status,
nodeTypes,
edgeTypes,
children,
}: {
status: WorkflowVersionStatus;
@ -99,6 +100,17 @@ export const WorkflowDiagramCanvasBase = ({
>
>
>;
edgeTypes: Partial<
Record<
WorkflowDiagramEdgeType,
React.ComponentType<
EdgeProps & {
data: any;
type: any;
}
>
>
>;
children?: React.ReactNode;
}) => {
const theme = useTheme();
@ -223,6 +235,7 @@ export const WorkflowDiagramCanvasBase = ({
minZoom={defaultFitViewOptions.minZoom}
maxZoom={defaultFitViewOptions.maxZoom}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}

View File

@ -2,6 +2,7 @@ import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable';
import { ReactFlowProvider } from '@xyflow/react';
@ -20,6 +21,9 @@ export const WorkflowDiagramCanvasEditable = ({
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
edgeTypes={{
default: WorkflowDiagramDefaultEdge,
}}
/>
<WorkflowDiagramCanvasEditableEffect />
</ReactFlowProvider>

View File

@ -1,8 +1,10 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasReadonlyEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasReadonly = ({
@ -18,6 +20,10 @@ export const WorkflowDiagramCanvasReadonly = ({
default: WorkflowDiagramStepNodeReadonly,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
edgeTypes={{
default: WorkflowDiagramDefaultEdge,
success: WorkflowDiagramSuccessEdge,
}}
/>
<WorkflowDiagramCanvasReadonlyEffect />
</ReactFlowProvider>

View File

@ -1,4 +1,10 @@
import { EDGE_GRAY_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGrayCircleMarkedId';
import { EDGE_GREEN_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenCircleMarkedId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerWidthPx';
import { EDGE_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeRoundedArrowMarkerId';
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { useTheme } from '@emotion/react';
export const WorkflowDiagramCustomMarkers = () => {
@ -19,6 +25,53 @@ export const WorkflowDiagramCustomMarkers = () => {
fill={theme.border.color.strong}
/>
</marker>
<marker
id={EDGE_GREEN_ROUNDED_ARROW_MARKER_ID}
markerHeight={5}
markerWidth={EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX}
refX={EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX / 2}
refY={2.5}
>
<path
d="M0.31094 1.1168C0.178029 0.917434 0.320947 0.650391 0.560555 0.650391H5.43945C5.67905 0.650391 5.82197 0.917434 5.68906 1.1168L3.62404 4.21433C3.32717 4.65963 2.67283 4.65963 2.37596 4.21433L0.31094 1.1168Z"
fill={theme.tag.text.turquoise}
/>
</marker>
<marker
markerHeight={NODE_HANDLE_HEIGHT_PX}
markerWidth={NODE_HANDLE_WIDTH_PX}
refX={NODE_HANDLE_WIDTH_PX / 2}
refY={NODE_HANDLE_HEIGHT_PX}
id={EDGE_GRAY_CIRCLE_MARKED_ID}
>
<rect
height={NODE_HANDLE_HEIGHT_PX}
width={NODE_HANDLE_WIDTH_PX}
rx="2"
fill={theme.border.color.strong}
/>
</marker>
<marker
markerHeight={5}
markerWidth={5}
refX={2}
refY={2.5}
id={EDGE_GREEN_CIRCLE_MARKED_ID}
>
<rect
x={0.5}
y={0.5}
height={3}
width={3}
rx="1.5"
fill="white"
stroke={theme.tag.text.turquoise}
strokeWidth={1}
/>
</marker>
</defs>
</svg>
);

View File

@ -0,0 +1,31 @@
import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
type WorkflowDiagramDefaultEdgeProps = EdgeProps;
export const WorkflowDiagramDefaultEdge = ({
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
}: WorkflowDiagramDefaultEdgeProps) => {
const theme = useTheme();
const [edgePath] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<BaseEdge
markerStart={markerStart}
markerEnd={markerEnd}
path={edgePath}
style={{ stroke: theme.border.color.strong }}
/>
);
};

View File

@ -150,15 +150,13 @@ const StyledStepNodeLabel = styled.div<{
`;
export const StyledHandle = styled(Handle)`
background-color: ${({ theme }) => theme.grayScale.gray25};
border: none;
width: ${NODE_HANDLE_WIDTH_PX}px;
height: ${NODE_HANDLE_HEIGHT_PX}px;
width: ${NODE_HANDLE_WIDTH_PX}px;
`;
const StyledSourceHandle = styled(StyledHandle)`
background-color: ${({ theme }) => theme.border.color.strong};
left: ${NODE_ICON_WIDTH + NODE_ICON_LEFT_MARGIN + NODE_BORDER_WIDTH}px;
visibility: hidden;
`;
const StyledTargetHandle = styled(StyledSourceHandle)`

View File

@ -0,0 +1,60 @@
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerWidthPx';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getStraightPath,
} from '@xyflow/react';
import { Label } from 'twenty-ui';
const StyledLabel = styled(Label)`
color: ${({ theme }) => theme.tag.text.turquoise};
`;
type WorkflowDiagramSuccessEdgeProps = EdgeProps;
export const WorkflowDiagramSuccessEdge = ({
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
label,
}: WorkflowDiagramSuccessEdgeProps) => {
const theme = useTheme();
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<>
<BaseEdge
markerStart={markerStart}
markerEnd={markerEnd}
path={edgePath}
style={{ stroke: theme.tag.text.turquoise }}
/>
<EdgeLabelRenderer>
<StyledLabel
variant="small"
style={{
position: 'absolute',
transform: `translate(0, -50%) translate(${labelX}px, ${labelY}px) translateX(${EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX / 2 + 3}px)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
{label}
</StyledLabel>
</EdgeLabelRenderer>
</>
);
};

View File

@ -1,17 +1,10 @@
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { WorkflowDiagramCanvasEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable';
import { WorkflowDiagramEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramEffect';
import '@xyflow/react/dist/style.css';
import { isDefined } from 'twenty-shared';
export const WorkflowVisualizer = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const workflowId = targetableObject.id;
export const WorkflowVisualizer = ({ workflowId }: { workflowId: string }) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
return (

View File

@ -0,0 +1,178 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeSuccessConfiguration';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { workflowDiagramState } from '../../states/workflowDiagramState';
import { WorkflowDiagramCanvasBase } from '../WorkflowDiagramCanvasBase';
const StyledContainer = styled.div`
height: 400px;
width: 100%;
position: relative;
`;
const meta: Meta = {
title: 'Modules/Workflow/WorkflowDiagram/WorkflowDiagramCustomMarkers',
component: WorkflowDiagramCanvasBase,
parameters: {
msw: graphqlMocks,
},
decorators: [
WorkspaceDecorator,
ObjectMetadataItemsDecorator,
ReactflowDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowDiagramCanvasBase>;
export const DefaultEdge: Story = {
args: {
status: 'DRAFT',
nodeTypes: {
default: WorkflowDiagramStepNodeReadonly,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
},
edgeTypes: {
default: WorkflowDiagramDefaultEdge,
},
},
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(workflowDiagramState, {
nodes: [
{
id: 'trigger-1',
type: 'default',
position: { x: 100, y: 100 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'When record is created',
isLeafNode: false,
},
},
{
id: 'action-1',
type: 'default',
position: { x: 300, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create record',
isLeafNode: false,
},
},
{
id: 'create-step-1',
type: 'create-step',
position: { x: 500, y: 100 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
},
},
],
edges: [
{
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
id: 'edge-1',
source: 'trigger-1',
target: 'action-1',
},
{
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
id: 'edge-2',
source: 'action-1',
target: 'create-step-1',
},
],
});
}}
>
<StyledContainer>
<Story />
</StyledContainer>
</RecoilRoot>
),
],
};
export const SuccessEdge: Story = {
args: {
status: 'DRAFT',
nodeTypes: {
default: WorkflowDiagramStepNodeReadonly,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
},
edgeTypes: {
default: WorkflowDiagramDefaultEdge,
success: WorkflowDiagramSuccessEdge,
},
},
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(workflowDiagramState, {
nodes: [
{
id: 'trigger-1',
type: 'default',
position: { x: 100, y: 100 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'When record is created',
isLeafNode: false,
},
},
{
id: 'action-1',
type: 'default',
position: { x: 300, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create record',
isLeafNode: false,
},
},
],
edges: [
{
...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION,
id: 'edge-1',
source: 'trigger-1',
target: 'action-1',
type: 'success',
label: '1 item',
},
],
});
}}
>
<StyledContainer>
<Story />
</StyledContainer>
</RecoilRoot>
),
],
};

View File

@ -0,0 +1 @@
export const EDGE_GRAY_CIRCLE_MARKED_ID = 'workflow-edge-gray-circle';

View File

@ -0,0 +1 @@
export const EDGE_GREEN_CIRCLE_MARKED_ID = 'workflow-edge-green-circle';

View File

@ -0,0 +1,2 @@
export const EDGE_GREEN_ROUNDED_ARROW_MARKER_ID =
'workflow-edge-green-arrow-rounded';

View File

@ -0,0 +1 @@
export const EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX = 6;

View File

@ -1 +1 @@
export const EDGE_ROUNDED_ARROW_MARKER_ID = 'arrow-rounded';
export const EDGE_ROUNDED_ARROW_MARKER_ID = 'workflow-edge-arrow-rounded';

View File

@ -1,7 +1,9 @@
import { EDGE_GRAY_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGrayCircleMarkedId';
import { EDGE_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeRoundedArrowMarkerId';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION = {
markerStart: EDGE_GRAY_CIRCLE_MARKED_ID,
markerEnd: EDGE_ROUNDED_ARROW_MARKER_ID,
deletable: false,
selectable: false,

View File

@ -0,0 +1,10 @@
import { EDGE_GREEN_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenCircleMarkedId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerId';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION = {
markerStart: EDGE_GREEN_CIRCLE_MARKED_ID,
markerEnd: EDGE_GREEN_ROUNDED_ARROW_MARKER_ID,
deletable: false,
selectable: false,
} satisfies Partial<WorkflowDiagramEdge>;

View File

@ -47,3 +47,5 @@ export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'
| 'create-step';
export type WorkflowDiagramEdgeType = 'default' | 'success';

View File

@ -133,7 +133,8 @@ describe('getWorkflowVersionDiagram', () => {
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "arrow-rounded",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "trigger",
"target": "step-1",