Implement workflow loops default state (#14297)

https://github.com/user-attachments/assets/9176ae58-f253-469c-b5b6-98d77909db53
This commit is contained in:
Baptiste Devessier
2025-09-10 16:32:39 +02:00
committed by GitHub
parent 4d6ef9bc34
commit 7b23f5ac91
31 changed files with 712 additions and 147 deletions
@@ -2,10 +2,8 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import {
@@ -13,8 +11,11 @@ import {
type WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge';
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramIteratorEmptyActionEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramIteratorEmptyActionEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable';
import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
@@ -121,6 +122,7 @@ export const WorkflowDiagramCanvasEditable = () => {
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'empty-trigger': WorkflowDiagramEmptyTriggerEditable,
'iterator-empty-action': WorkflowDiagramIteratorEmptyActionEditable,
}}
edgeTypes={{
blank: WorkflowDiagramBlankEdge,
@@ -19,12 +19,19 @@ const StyledVerticalLineContainer = styled.div`
width: 56px;
`;
const StyledLabelContainer = styled.div`
position: absolute;
top: 14px;
`;
type WorkflowDiagramCreateStepElementProps = {
data: WorkflowDiagramStepNodeData;
Label?: React.ReactNode;
};
export const WorkflowDiagramCreateStepElement = ({
data,
Label,
}: WorkflowDiagramCreateStepElementProps) => {
const { startNodeCreation } = useStartNodeCreation();
@@ -44,6 +51,9 @@ export const WorkflowDiagramCreateStepElement = ({
<StyledVerticalLineContainer>
<WorkflowDiagramConnector />
</StyledVerticalLineContainer>
{Label && <StyledLabelContainer>{Label}</StyledLabelContainer>}
<IconButton
Icon={IconPlus}
size="small"
@@ -48,7 +48,7 @@ export const WorkflowDiagramEffect = () => {
const nextWorkflowDiagram = getWorkflowVersionDiagram({
workflowVersion: currentVersion,
isEditable: true,
workflowContext: 'workflow',
});
let mergedWorkflowDiagram = nextWorkflowDiagram;
@@ -60,7 +60,7 @@ export const WorkflowVersionVisualizerEffect = ({
const nextWorkflowDiagram = getWorkflowVersionDiagram({
workflowVersion,
isEditable: false,
workflowContext: 'workflow-version',
});
setWorkflowDiagram(nextWorkflowDiagram);
@@ -6,6 +6,7 @@ import { fn } from '@storybook/test';
import '@xyflow/react/dist/style.css';
import { RecoilRoot } from 'recoil';
import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowDiagramStepNodeEditableContent } from '../../workflow-nodes/components/WorkflowDiagramStepNodeEditableContent';
@@ -16,6 +17,7 @@ const meta: Meta<typeof WorkflowDiagramStepNodeEditableContent> = {
parameters: {
msw: graphqlMocks,
},
decorators: [I18nFrontDecorator],
};
export default meta;
@@ -0,0 +1 @@
export type WorkflowContext = 'workflow' | 'workflow-version' | 'workflow-run';
@@ -3,8 +3,10 @@ import {
type WorkflowRunStepStatus,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { type FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { type Edge, type Node } from '@xyflow/react';
import { type MessageDescriptor } from '@lingui/core';
import { type Edge, type Node, type Position } from '@xyflow/react';
import { type StepStatus } from 'twenty-shared/workflow';
export type WorkflowDiagramStepNode = Node<WorkflowDiagramStepNodeData>;
@@ -23,6 +25,14 @@ export type WorkflowDiagram = {
edges: Array<WorkflowDiagramEdge>;
};
export type WorkflowDiagramNodeRightHandleOptions = {
id: string;
};
export type WorkflowDiagramNodeDefaultHandleOptions = {
label?: MessageDescriptor;
};
export type WorkflowDiagramStepNodeData =
| {
nodeType: 'trigger';
@@ -32,6 +42,8 @@ export type WorkflowDiagramStepNodeData =
runStatus?: WorkflowRunStepStatus;
hasNextStepIds: boolean;
stepId: string;
defaultHandleOptions?: WorkflowDiagramNodeDefaultHandleOptions;
rightHandleOptions?: WorkflowDiagramNodeRightHandleOptions;
position: {
x: number;
y: number;
@@ -44,6 +56,8 @@ export type WorkflowDiagramStepNodeData =
runStatus?: WorkflowRunStepStatus;
hasNextStepIds: boolean;
stepId: string;
defaultHandleOptions?: WorkflowDiagramNodeDefaultHandleOptions;
rightHandleOptions?: WorkflowDiagramNodeRightHandleOptions;
position: {
x: number;
y: number;
@@ -67,13 +81,19 @@ export type WorkflowDiagramEmptyTriggerNodeData = {
export type WorkflowDiagramNodeData =
| WorkflowDiagramStepNodeData
| WorkflowDiagramEmptyTriggerNodeData;
| WorkflowDiagramEmptyTriggerNodeData
| WorkflowDiagramIteratorEmptyActionNodeData;
export type WorkflowRunDiagramNodeData = Exclude<
WorkflowDiagramStepNodeData,
'runStatus'
> & { runStatus: WorkflowRunStepStatus };
export type WorkflowDiagramEdgeLabelOptions = {
position: Position;
label: MessageDescriptor;
};
export type WorkflowDiagramFilterEdgeData = {
edgeType: 'filter';
stepId: string;
@@ -81,18 +101,23 @@ export type WorkflowDiagramFilterEdgeData = {
name: string;
runStatus?: WorkflowRunStepStatus;
edgeExecutionStatus?: StepStatus;
labelOptions?: WorkflowDiagramEdgeLabelOptions;
};
export type WorkflowDiagramDefaultEdgeData = {
edgeType: 'default';
edgeExecutionStatus?: StepStatus;
labelOptions?: WorkflowDiagramEdgeLabelOptions;
};
export type WorkflowDiagramEdgeData =
| WorkflowDiagramFilterEdgeData
| WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramNodeType = 'default' | 'empty-trigger';
export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'
| 'iterator-empty-action';
export type WorkflowDiagramEdgeType =
| 'blank'
@@ -19,7 +19,7 @@ describe('generateWorkflowDiagram', () => {
const result = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
workflowContext: 'workflow',
});
expect(result.nodes).toHaveLength(1);
@@ -86,7 +86,7 @@ describe('generateWorkflowDiagram', () => {
const result = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
workflowContext: 'workflow',
});
expect(result.nodes).toHaveLength(steps.length + 1); // All steps + trigger
@@ -165,7 +165,7 @@ describe('generateWorkflowDiagram', () => {
const result = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
workflowContext: 'workflow',
});
expect(result.edges.length).toEqual(2);
@@ -232,7 +232,7 @@ describe('generateWorkflowDiagram', () => {
const result = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
workflowContext: 'workflow',
});
expect(result.edges.length).toEqual(2);
@@ -318,7 +318,7 @@ describe('generateWorkflowDiagram', () => {
const result = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--editable',
workflowContext: 'workflow',
});
expect(result.edges.length).toEqual(4);
@@ -9,7 +9,7 @@ describe('getWorkflowVersionDiagram', () => {
it('returns an empty diagram if the provided workflow version', () => {
const result = getWorkflowVersionDiagram({
workflowVersion: undefined,
isEditable: true,
workflowContext: 'workflow',
});
expect(result).toMatchInlineSnapshot(`
@@ -33,7 +33,7 @@ describe('getWorkflowVersionDiagram', () => {
updatedAt: '',
workflowId: '',
},
isEditable: true,
workflowContext: 'workflow',
});
expect(result).toMatchInlineSnapshot(`
@@ -77,7 +77,7 @@ describe('getWorkflowVersionDiagram', () => {
updatedAt: '',
workflowId: '',
},
isEditable: true,
workflowContext: 'workflow',
});
expect(result).toMatchInlineSnapshot(`
@@ -145,7 +145,7 @@ describe('getWorkflowVersionDiagram', () => {
updatedAt: '',
workflowId: '',
},
isEditable: true,
workflowContext: 'workflow',
});
expect(result).toMatchInlineSnapshot(`
@@ -45,7 +45,7 @@ describe('transformFilterNodesAsEdges', () => {
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
workflowContext: 'workflow',
});
expect(result.nodes).toEqual(diagram.nodes);
@@ -111,7 +111,7 @@ describe('transformFilterNodesAsEdges', () => {
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
workflowContext: 'workflow',
});
// Should only have nodes A and C
@@ -256,7 +256,7 @@ describe('transformFilterNodesAsEdges', () => {
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
workflowContext: 'workflow',
});
// Should only have nodes A, C, and D
@@ -348,7 +348,7 @@ describe('transformFilterNodesAsEdges', () => {
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
workflowContext: 'workflow',
});
// Should only have node A (filter node B is removed)
@@ -430,7 +430,7 @@ describe('transformFilterNodesAsEdges', () => {
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
workflowContext: 'workflow',
});
// Should have trigger and C nodes
@@ -0,0 +1,75 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import {
type WorkflowDiagramEdge,
type WorkflowDiagramNode,
type WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const generateNodesAndEdgesForDefaultNode = ({
step,
xPos,
yPos,
nodes,
edges,
workflowContext,
}: {
step: WorkflowStep;
yPos: number;
xPos: number;
nodes: readonly WorkflowDiagramNode[];
edges: readonly WorkflowDiagramEdge[];
workflowContext: WorkflowContext;
}): {
nodes: Array<WorkflowDiagramNode>;
edges: Array<WorkflowDiagramEdge>;
} => {
const edgeTypeBetweenTwoNodes = getEdgeTypeBetweenTwoNodes({
workflowContext,
});
const updatedNodes = [...nodes];
const updatedEdges = [...edges];
updatedNodes.push({
id: step.id,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
hasNextStepIds:
isDefined(step.nextStepIds) && step.nextStepIds.length > 0,
stepId: step.id,
position: step.position ?? {
x: xPos,
y: yPos,
},
} satisfies WorkflowDiagramStepNodeData,
position: step.position ?? {
x: xPos,
y: yPos,
},
});
step.nextStepIds?.forEach((child) => {
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
target: child,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
});
});
return {
nodes: updatedNodes,
edges: updatedEdges,
};
};
@@ -0,0 +1,176 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import {
type WorkflowDiagramEdge,
type WorkflowDiagramNode,
type WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramIteratorNodeLoopHandleId';
import { msg } from '@lingui/core/macro';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { Position } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const generateNodesAndEdgesForIteratorNode = ({
step,
xPos,
yPos,
nodes,
edges,
workflowContext,
}: {
step: WorkflowStep & { type: 'ITERATOR' };
yPos: number;
xPos: number;
nodes: readonly WorkflowDiagramNode[];
edges: readonly WorkflowDiagramEdge[];
workflowContext: WorkflowContext;
}): {
nodes: Array<WorkflowDiagramNode>;
edges: Array<WorkflowDiagramEdge>;
} => {
const edgeTypeBetweenTwoNodes = getEdgeTypeBetweenTwoNodes({
workflowContext,
});
const updatedNodes = [...nodes];
const updatedEdges = [...edges];
const iteratorNode: WorkflowDiagramNode = {
id: step.id,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
hasNextStepIds:
isDefined(step.nextStepIds) && step.nextStepIds.length > 0,
stepId: step.id,
position: step.position ?? {
x: xPos,
y: yPos,
},
defaultHandleOptions: {
label: msg`completed`,
},
rightHandleOptions: {
id: WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID,
},
} satisfies WorkflowDiagramStepNodeData,
position: step.position ?? {
x: xPos,
y: yPos,
},
};
updatedNodes.push(iteratorNode);
const initialLoopStepIds = Array.isArray(
step.settings.input.initialLoopStepIds,
)
? step.settings.input.initialLoopStepIds
: isNonEmptyString(step.settings.input.initialLoopStepIds)
? [step.settings.input.initialLoopStepIds]
: [];
if (isNonEmptyArray(initialLoopStepIds)) {
for (const initialLoopStepId of initialLoopStepIds) {
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
sourceHandle: WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID,
target: initialLoopStepId,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
data: {
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION.data,
labelOptions: {
position: Position.Right,
label: msg`loop`,
},
},
});
}
} else {
const emptyNodeId = `${step.id}-empty-loop`;
updatedNodes.push({
id: emptyNodeId,
type: 'iterator-empty-action',
data: {
nodeType: 'iterator-empty-action',
parentIteratorStepId: step.id,
position: {
x: iteratorNode.position.x + 175,
y: iteratorNode.position.y + 75,
},
} satisfies WorkflowDiagramIteratorEmptyActionNodeData,
position: {
x: iteratorNode.position.x + 175,
y: iteratorNode.position.y + 75,
},
draggable: false,
});
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
sourceHandle: WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID,
target: emptyNodeId,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
data: {
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION.data,
labelOptions: {
position: Position.Right,
label: msg`loop`,
},
},
});
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: emptyNodeId,
target: step.id,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
});
}
step.nextStepIds?.forEach((child) => {
updatedEdges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: step.id,
target: child,
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
data: {
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION.data,
labelOptions: {
position: Position.Bottom,
label: msg`completed`,
},
},
});
});
return {
nodes: updatedNodes,
edges: updatedEdges,
};
};
@@ -4,17 +4,18 @@ import {
} from '@/workflow/types/Workflow';
import { FIRST_NODE_POSITION } from '@/workflow/workflow-diagram/constants/FirstNodePosition';
import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes';
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import {
type WorkflowDiagram,
type WorkflowDiagramEdge,
type WorkflowDiagramEdgeType,
type WorkflowDiagramNode,
type WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateNodesAndEdgesForDefaultNode } from '@/workflow/workflow-diagram/utils/generateNodesAndEdgesForDefaultNode';
import { generateNodesAndEdgesForIteratorNode } from '@/workflow/workflow-diagram/utils/generateNodesAndEdgesForIteratorNode';
import { getEdgeTypeBetweenTwoNodes } from '@/workflow/workflow-diagram/utils/getEdgeTypeBetweenTwoNodes';
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { v4 } from 'uuid';
@@ -22,14 +23,18 @@ import { v4 } from 'uuid';
export const generateWorkflowDiagram = ({
trigger,
steps,
defaultEdgeType,
workflowContext,
}: {
trigger: WorkflowTrigger | undefined;
steps: Array<WorkflowStep>;
defaultEdgeType: WorkflowDiagramEdgeType;
workflowContext: WorkflowContext;
}): WorkflowDiagram => {
const nodes: Array<WorkflowDiagramNode> = [];
const edges: Array<WorkflowDiagramEdge> = [];
let nodes: Array<WorkflowDiagramNode> = [];
let edges: Array<WorkflowDiagramEdge> = [];
const edgeTypeBetweenTwoNodes = getEdgeTypeBetweenTwoNodes({
workflowContext,
});
if (isDefined(trigger)) {
nodes.push(getWorkflowDiagramTriggerNode({ trigger }));
@@ -37,60 +42,59 @@ export const generateWorkflowDiagram = ({
nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION);
}
let levelYPos = FIRST_NODE_POSITION.y;
const xPos = FIRST_NODE_POSITION.x;
for (const step of steps) {
levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES;
nodes.push({
id: step.id,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
hasNextStepIds:
isDefined(step.nextStepIds) && step.nextStepIds.length > 0,
stepId: step.id,
position: step.position ?? {
x: xPos,
y: levelYPos,
},
} satisfies WorkflowDiagramStepNodeData,
position: step.position ?? {
x: xPos,
y: levelYPos,
},
});
}
for (const stepLinkToTriggerId of trigger?.nextStepIds ?? []) {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: defaultEdgeType,
type: edgeTypeBetweenTwoNodes,
id: v4(),
source: TRIGGER_STEP_ID,
target: stepLinkToTriggerId,
...(defaultEdgeType.includes('editable')
...(edgeTypeBetweenTwoNodes.includes('editable')
? { deletable: true, selectable: true }
: {}),
});
}
const xPos = FIRST_NODE_POSITION.x;
let levelYPos = FIRST_NODE_POSITION.y;
for (const step of steps) {
step.nextStepIds?.forEach((child) => {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: defaultEdgeType,
id: v4(),
source: step.id,
target: child,
...(defaultEdgeType.includes('editable')
? { deletable: true, selectable: true }
: {}),
});
});
levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES;
switch (step.type) {
case 'ITERATOR': {
const { nodes: iteratorNodes, edges: iteratorEdges } =
generateNodesAndEdgesForIteratorNode({
step,
xPos,
yPos: levelYPos,
nodes,
edges,
workflowContext,
});
nodes = iteratorNodes;
edges = iteratorEdges;
break;
}
default: {
const { nodes: defaultNodes, edges: defaultEdges } =
generateNodesAndEdgesForDefaultNode({
step,
xPos,
yPos: levelYPos,
nodes,
edges,
workflowContext,
});
nodes = defaultNodes;
edges = defaultEdges;
break;
}
}
}
return {
@@ -42,7 +42,7 @@ export const generateWorkflowRunDiagram = ({
const workflowDiagram = generateWorkflowDiagram({
trigger,
steps,
defaultEdgeType: 'empty-filter--readonly',
workflowContext: 'workflow-run',
});
const workflowRunDiagramNodes: WorkflowRunDiagramNode[] =
@@ -104,7 +104,7 @@ export const generateWorkflowRunDiagram = ({
diagram: transformFilterNodesAsEdges({
nodes: workflowRunDiagramNodes,
edges: workflowRunDiagramEdges,
defaultFilterEdgeType: 'filter--run',
workflowContext: 'workflow-run',
}),
stepToOpenByDefault,
};
@@ -0,0 +1,18 @@
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import { type WorkflowDiagramEdgeType } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const getEdgeTypeBetweenTwoNodes = ({
workflowContext,
}: {
workflowContext: WorkflowContext;
}): WorkflowDiagramEdgeType => {
switch (workflowContext) {
case 'workflow': {
return 'empty-filter--editable';
}
case 'workflow-version':
case 'workflow-run': {
return 'empty-filter--readonly';
}
}
};
@@ -0,0 +1,20 @@
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import { type WorkflowDiagramEdgeType } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const getFilterEdgeType = ({
workflowContext,
}: {
workflowContext: WorkflowContext;
}): WorkflowDiagramEdgeType => {
switch (workflowContext) {
case 'workflow': {
return 'filter--editable';
}
case 'workflow-version': {
return 'filter--readonly';
}
case 'workflow-run': {
return 'filter--run';
}
}
};
@@ -1,8 +1,6 @@
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import {
type WorkflowDiagram,
type WorkflowDiagramEdgeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
@@ -12,20 +10,12 @@ const EMPTY_DIAGRAM: WorkflowDiagram = {
edges: [],
};
const getEdgeTypeToCreateByDefault = ({
isEditable,
}: {
isEditable: boolean;
}): WorkflowDiagramEdgeType => {
return isEditable ? 'empty-filter--editable' : 'empty-filter--readonly';
};
export const getWorkflowVersionDiagram = ({
workflowVersion,
isEditable,
workflowContext,
}: {
workflowVersion: WorkflowVersion | undefined;
isEditable: boolean;
workflowContext: WorkflowContext;
}): WorkflowDiagram => {
if (!isDefined(workflowVersion)) {
return EMPTY_DIAGRAM;
@@ -34,14 +24,12 @@ export const getWorkflowVersionDiagram = ({
const diagram = generateWorkflowDiagram({
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
defaultEdgeType: getEdgeTypeToCreateByDefault({
isEditable,
}),
workflowContext,
});
return transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: isEditable ? 'filter--editable' : 'filter--readonly',
workflowContext,
});
};
@@ -1,8 +1,9 @@
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import {
type WorkflowDiagramEdge,
type WorkflowDiagramEdgeType,
type WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getFilterEdgeType } from '@/workflow/workflow-diagram/utils/getFilterEdgeType';
import { isDefined } from 'twenty-shared/utils';
export const transformFilterNodesAsEdges = <
@@ -11,11 +12,11 @@ export const transformFilterNodesAsEdges = <
>({
nodes,
edges,
defaultFilterEdgeType,
workflowContext,
}: {
nodes: T[];
edges: U[];
defaultFilterEdgeType: WorkflowDiagramEdgeType;
workflowContext: WorkflowContext;
}): { nodes: T[]; edges: U[] } => {
const filterNodes = nodes.filter(
(node) =>
@@ -48,9 +49,11 @@ export const transformFilterNodesAsEdges = <
throw new Error('Expected the filter node to be of action type');
}
const filterEdgeType = getFilterEdgeType({ workflowContext });
const newEdge: U = {
...incomingEdge,
type: defaultFilterEdgeType,
type: filterEdgeType,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target,
selectable: true,
@@ -8,12 +8,15 @@ import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStart
import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBaseEdge';
import { WorkflowDiagramEdgeButtonGroup } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeButtonGroup';
import { WorkflowDiagramEdgeLabel } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabel';
import { WorkflowDiagramEdgeLabelContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabelContainer';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useLingui } from '@lingui/react/macro';
import {
EdgeLabelRenderer,
type EdgeProps,
@@ -35,7 +38,10 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
targetY,
markerStart,
markerEnd,
data,
}: WorkflowDiagramDefaultEdgeEditableProps) => {
const { i18n } = useLingui();
const { isInRightDrawer } = useContext(ActionMenuContext);
const { isEdgeHovered } = useEdgeState();
@@ -116,6 +122,16 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
/>
<EdgeLabelRenderer>
{isDefined(data?.labelOptions) && (
<WorkflowDiagramEdgeLabelContainer
sourceX={sourceX}
sourceY={sourceY}
position={data.labelOptions.position}
>
<WorkflowDiagramEdgeLabel label={i18n._(data.labelOptions.label)} />
</WorkflowDiagramEdgeLabelContainer>
)}
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { Label } from 'twenty-ui/display';
const StyledContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.strong};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
padding-block: ${({ theme }) => theme.spacing(0.5)};
padding-inline: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramEdgeLabel = ({ label }: { label: string }) => {
return (
<StyledContainer>
<Label>{label}</Label>
</StyledContainer>
);
};
@@ -0,0 +1,31 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
const StyledWorkflowDiagramEdgeLabelContainer = styled.div<{
sourceX: number;
sourceY: number;
position: Position;
}>`
position: absolute;
width: fit-content;
${({ position, sourceX, sourceY }) => {
switch (position) {
case Position.Right: {
return css`
transform: translate(0%, -50%)
translate(${Math.floor(sourceX)}px, ${sourceY}px) translateX(10px);
`;
}
case Position.Bottom: {
return css`
transform: translate(-50%, 0%)
translate(${sourceX}px, ${Math.floor(sourceY)}px) translateY(10px);
`;
}
}
}}
`;
export { StyledWorkflowDiagramEdgeLabelContainer as WorkflowDiagramEdgeLabelContainer };
@@ -20,6 +20,8 @@ import {
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
import { WorkflowDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBaseEdge';
import { WorkflowDiagramEdgeButtonGroup } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeButtonGroup';
import { WorkflowDiagramEdgeLabel } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabel';
import { WorkflowDiagramEdgeLabelContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabelContainer';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
@@ -39,6 +41,7 @@ import {
} from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
IconDotsVertical,
@@ -94,7 +97,8 @@ export const WorkflowDiagramFilterEdgeEditable = ({
}: WorkflowDiagramFilterEdgeEditableProps) => {
assertFilterEdgeDataOrThrow(data);
const { t } = useLingui();
const { t, i18n } = useLingui();
const theme = useTheme();
const [edgePath, labelX, labelY] = getBezierPath({
@@ -192,6 +196,16 @@ export const WorkflowDiagramFilterEdgeEditable = ({
/>
<EdgeLabelRenderer>
{isDefined(data?.labelOptions) && (
<WorkflowDiagramEdgeLabelContainer
sourceX={sourceX}
sourceY={sourceY}
position={data.labelOptions.position}
>
<WorkflowDiagramEdgeLabel label={i18n._(data.labelOptions.label)} />
</WorkflowDiagramEdgeLabelContainer>
)}
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
@@ -0,0 +1,21 @@
import { useCallback } from 'react';
export const useStartIteratorFirstNodeCreation = () => {
/**
* This function is used in a context where dependencies shouldn't change much.
* That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly.
*/
const startIteratorFirstNodeCreation = useCallback(() => {
// TODO: to implement
}, []);
// TODO: to implement
const isIteratorFirstNodeCreationStarted = () => {
return false;
};
return {
startIteratorFirstNodeCreation,
isIteratorFirstNodeCreationStarted,
};
};
@@ -0,0 +1,8 @@
export type WorkflowDiagramIteratorEmptyActionNodeData = {
nodeType: 'iterator-empty-action';
parentIteratorStepId: string;
position: {
x: number;
y: number;
};
};
@@ -6,13 +6,6 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, Position, type HandleProps } from '@xyflow/react';
type WorkflowDiagramHandleSourceProps = {
selected: boolean;
hovered?: boolean;
readOnly?: boolean;
runStatus?: WorkflowRunStepStatus;
};
const HANDLE_SCALE_ON_HOVER = 1.5;
const StyledHandle = styled(Handle, {
@@ -23,7 +16,7 @@ const StyledHandle = styled(Handle, {
prop !== 'runStatus',
})<{
type: HandleProps['type'];
disableHoverEffect: boolean;
disableHoverEffect?: boolean;
selected: boolean;
hovered?: boolean;
runStatus?: WorkflowRunStepStatus;
@@ -37,7 +30,7 @@ const StyledHandle = styled(Handle, {
if (!selected) {
return css`
background: ${theme.background.primary};
border-color: ${hovered && !disableHoverEffect
border-color: ${hovered && disableHoverEffect !== true
? theme.font.color.light
: theme.border.color.strong};
`;
@@ -56,45 +49,55 @@ const StyledHandle = styled(Handle, {
border-color 0.1s;
z-index: 1;
transform: translate(-50%, 50%);
transform-origin: bottom left;
${({ position }) => {
if (position === Position.Right) {
return css`
transform: translate(50%, -50%);
transform-origin: top right;
`;
}
return css`
transform: translate(-50%, 50%);
transform-origin: bottom left;
`;
}}
&.connectionindicator {
cursor: pointer;
}
${({ disableHoverEffect, theme }) => {
if (disableHoverEffect) {
return undefined;
}
&:hover {
${({ disableHoverEffect, theme }) => {
if (disableHoverEffect === true) {
return undefined;
}
const colors = getWorkflowDiagramColors({ theme });
const colors = getWorkflowDiagramColors({ theme });
return css`
&:hover {
return css`
background: ${colors.selected.background} !important;
border-color: ${colors.selected.borderColor} !important;
transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(-50%, 50%);
`;
}}
${({ disableHoverEffect, position }) => {
if (disableHoverEffect === true) {
return undefined;
}
`;
}}
if (position === Position.Right) {
return css`
transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(50%, -50%);
`;
}
return css`
transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(-50%, 50%);
`;
}}
}
}
`;
export const WorkflowDiagramHandleSource = ({
selected,
hovered = false,
readOnly = false,
runStatus,
}: WorkflowDiagramHandleSourceProps) => {
return (
<StyledHandle
type="source"
position={Position.Bottom}
disableHoverEffect={readOnly}
selected={selected}
hovered={hovered}
runStatus={runStatus}
/>
);
};
export { StyledHandle as WorkflowDiagramHandleSource };
@@ -0,0 +1,82 @@
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { useStartIteratorFirstNodeCreation } from '@/workflow/workflow-diagram/workflow-iterator/hooks/useStartIteratorFirstNodeCreation';
import { type WorkflowDiagramIteratorEmptyActionNodeData } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowDiagramIteratorEmptyActionNodeData';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { useConnectionState } from '@/workflow/workflow-diagram/workflow-nodes/hooks/useConnectionState';
import { useLingui } from '@lingui/react/macro';
import { Position } from '@xyflow/react';
import { useState } from 'react';
export const WorkflowDiagramIteratorEmptyActionEditable = ({
id,
selected,
}: {
id: string;
selected: boolean;
data: WorkflowDiagramIteratorEmptyActionNodeData;
}) => {
const { t } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const { isConnectable, isSourceConnected } = useConnectionState('action');
const { isSourceSelected, isSourceHovered } = useEdgeState();
const { startIteratorFirstNodeCreation } =
useStartIteratorFirstNodeCreation();
const handleClick = () => {
startIteratorFirstNodeCreation();
};
return (
<WorkflowNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowDiagramHandleTarget isConnectable={false} />
<WorkflowNodeIconContainer />
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<WorkflowNodeLabel>{t`Action`}</WorkflowNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<WorkflowNodeTitle>{t`Add an Action`}</WorkflowNodeTitle>
</WorkflowNodeRightPart>
<WorkflowDiagramHandleSource
type="source"
position={Position.Bottom}
disableHoverEffect
selected={
isSourceSelected(id) ||
selected ||
isSourceConnected(id) ||
(isConnectable(id) && isHovered)
}
hovered={isSourceHovered(id) || isHovered}
/>
</WorkflowNodeContainer>
);
};
@@ -2,6 +2,7 @@ import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/co
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramEdgeLabel } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabel';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
@@ -14,8 +15,10 @@ import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-node
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { useConnectionState } from '@/workflow/workflow-diagram/workflow-nodes/hooks/useConnectionState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { Position } from '@xyflow/react';
import { useState } from 'react';
import { capitalize } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { IconTrash } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
@@ -56,6 +59,8 @@ export const WorkflowDiagramStepNodeEditableContent = ({
onDelete: () => void;
onClick?: () => void;
}) => {
const { i18n } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
@@ -89,6 +94,7 @@ export const WorkflowDiagramStepNodeEditableContent = ({
isConnectable={isConnectable(id)}
>
<WorkflowDiagramHandleTarget isConnectable={isConnectable(id)} />
<WorkflowNodeIconContainer>
<WorkflowDiagramStepNodeIcon data={data} />
</WorkflowNodeIconContainer>
@@ -123,11 +129,22 @@ export const WorkflowDiagramStepNodeEditableContent = ({
onMouseLeave={handleMouseLeave}
onClick={handleAddStepButtonContainerClick}
>
<WorkflowDiagramCreateStepElement data={data} />
<WorkflowDiagramCreateStepElement
data={data}
Label={
isDefined(data.defaultHandleOptions?.label) ? (
<WorkflowDiagramEdgeLabel
label={i18n._(data.defaultHandleOptions.label)}
/>
) : undefined
}
/>
</StyledAddStepButtonContainer>
)}
<WorkflowDiagramHandleSource
type="source"
position={Position.Bottom}
selected={
isSourceSelected(id) ||
selected ||
@@ -136,6 +153,22 @@ export const WorkflowDiagramStepNodeEditableContent = ({
}
hovered={isSourceHovered(id) || isHovered}
/>
{isDefined(data.rightHandleOptions) && (
<WorkflowDiagramHandleSource
id={data.rightHandleOptions.id}
type="source"
position={Position.Right}
selected={
isSourceSelected(id) ||
selected ||
isSourceConnected(id) ||
(isConnectable(id) && isHovered)
}
// TODO: fix hovered state when multiple source handles
hovered={isSourceHovered(id) || isHovered}
/>
)}
</>
);
};
@@ -9,6 +9,8 @@ import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/wor
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
@@ -16,12 +18,11 @@ import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/co
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { Position } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
export const WorkflowDiagramStepNodeReadonly = ({
id,
@@ -95,7 +96,12 @@ export const WorkflowDiagramStepNodeReadonly = ({
</WorkflowNodeRightPart>
</WorkflowNodeContainer>
<WorkflowDiagramHandleSource selected={selected} readOnly />
<WorkflowDiagramHandleSource
type="source"
selected={selected}
position={Position.Bottom}
disableHoverEffect
/>
</>
);
};
@@ -9,6 +9,8 @@ import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/wor
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowRunDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
@@ -18,14 +20,13 @@ import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-node
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { StepStatus } from 'twenty-shared/workflow';
import { IconCheck, IconX, useIcons } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { WorkflowDiagramHandleTarget } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleTarget';
import { WorkflowDiagramHandleSource } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleSource';
const StyledNodeLabelWithCounterPart = styled(WorkflowNodeLabelWithCounterPart)`
column-gap: ${({ theme }) => theme.spacing(2)};
@@ -151,9 +152,11 @@ export const WorkflowRunDiagramStepNode = ({
</WorkflowNodeContainer>
<WorkflowDiagramHandleSource
runStatus={data.runStatus}
type="source"
position={Position.Bottom}
disableHoverEffect
selected={selected}
readOnly
runStatus={data.runStatus}
/>
</>
);
@@ -0,0 +1 @@
export const WORKFLOW_DIAGRAM_ITERATOR_NODE_LOOP_HANDLE_ID = 'loop';
@@ -1,9 +1,9 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
export const useActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
const { _ } = useLingui();
const { i18n } = useLingui();
return _(getActionHeaderTypeOrThrow(actionType));
return i18n._(getActionHeaderTypeOrThrow(actionType));
};