Iterators design updates (#15146)

A few fixes :
- add a counter of iteration for nodes that have been run
<img width="601" height="538" alt="Capture d’écran 2025-10-16 à 17 50
07"
src="https://github.com/user-attachments/assets/de9d56ed-3057-4a67-b1d7-b421f644d11d"
/>

- if a node has fail, we should end the workflow even if a step is still
running
- fix position for add node button in iterator loop :
Before


https://github.com/user-attachments/assets/eafeda64-977f-415d-bab4-b7d0c44406b0

After


https://github.com/user-attachments/assets/6ff221a3-b287-45f6-977f-b127f9998692
This commit is contained in:
Thomas Trompette
2025-10-16 16:32:47 +00:00
committed by GitHub
parent 3dd70aad03
commit 0d0cce7bb1
6 changed files with 99 additions and 33 deletions
@@ -6,6 +6,8 @@ export const getIsOutputTabDisabled = ({
stepExecutionStatus: WorkflowRunStepStatus;
}) => {
return (
stepExecutionStatus === 'RUNNING' || stepExecutionStatus === 'NOT_STARTED'
stepExecutionStatus === 'RUNNING' ||
stepExecutionStatus === 'NOT_STARTED' ||
stepExecutionStatus === 'SKIPPED'
);
};
@@ -41,7 +41,7 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
const {
segments,
labelPosition: [labelX, labelY],
overlayPosition: [labelX, labelY],
} = getEdgePath({
sourceX,
sourceY,
@@ -32,7 +32,7 @@ export const getEdgePath = ({
strategy,
}: GetEdgePathParams) => {
if (strategy === 'smooth-step-path-to-target') {
const [path, labelX, labelY] = getSmoothStepPath({
const [path] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
@@ -43,6 +43,12 @@ export const getEdgePath = ({
offset: EDGE_PADDING_X,
});
const overlayY =
targetY - sourceY > EDGE_PADDING_BOTTOM &&
sourceX + EDGE_PADDING_X < targetX
? targetY - (targetY - sourceY) / 2
: targetY - EDGE_PADDING_BOTTOM / 2;
return {
segments: [
{
@@ -51,7 +57,7 @@ export const getEdgePath = ({
markerEnd,
},
],
labelPosition: [labelX, labelY],
overlayPosition: [targetX, overlayY],
};
}
@@ -73,7 +79,7 @@ export const getEdgePath = ({
markerEnd,
},
],
labelPosition: [labelX, labelY],
overlayPosition: [labelX, labelY],
};
}
@@ -117,6 +123,9 @@ export const getEdgePath = ({
markerEnd,
},
],
labelPosition: [firstSegmentTargetX, firstSegmentTargetY],
overlayPosition:
strategy === 'bypass-source-node-on-right-side'
? [sourceX, sourceY + EDGE_PADDING_BOTTOM]
: [firstSegmentTargetX, firstSegmentTargetY],
};
};
@@ -3,11 +3,14 @@ import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandM
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowRunDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
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';
@@ -19,7 +22,8 @@ import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/wo
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { WORKFLOW_DIAGRAM_NODE_DEFAULT_SOURCE_HANDLE_ID } from '@/workflow/workflow-diagram/workflow-nodes/constants/WorkflowDiagramNodeDefaultSourceHandleId';
import { useTheme } from '@emotion/react';
import { getNodeIterationCount } from '@/workflow/workflow-diagram/workflow-nodes/utils/getNodeIterationCount';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { useContext } from 'react';
@@ -33,7 +37,7 @@ const StyledNodeLabelWithCounterPart = styled(WorkflowNodeLabelWithCounterPart)`
column-gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledNodeCounter = styled.div`
const StyledStatusIconsContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
@@ -54,6 +58,25 @@ const StyledColorIcon = styled.div<{
background: ${({ color }) => color};
`;
const StyledIterationCounter = styled.div<{
runStatus?: WorkflowRunStepStatus;
}>`
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
${({ theme, runStatus }) => {
const colors = getWorkflowDiagramColors({ theme, runStatus });
return css`
color: ${colors.unselected.color};
`;
}}
`;
const StyledRightPartContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowRunDiagramStepNode = ({
id,
data,
@@ -83,6 +106,15 @@ export const WorkflowRunDiagramStepNode = ({
commandMenuNavigationStackState,
);
const workflowRun = useWorkflowRun({ workflowRunId });
const stepInfo = workflowRun?.state?.stepInfos[data.stepId];
const iterationCount =
data.nodeType === 'action' && isDefined(stepInfo)
? getNodeIterationCount({ stepInfo })
: 0;
const handleClick = () => {
if (!isDefined(workflowId)) {
throw new Error('Workflow ID must be defined');
@@ -122,29 +154,37 @@ export const WorkflowRunDiagramStepNode = ({
{capitalize(data.nodeType)}
</WorkflowNodeLabel>
{(data.runStatus === StepStatus.SUCCESS ||
data.runStatus === StepStatus.STOPPED) && (
<StyledNodeCounter>
<StyledColorIcon color={theme.tag.background.turquoise}>
<IconCheck color={theme.tag.text.turquoise} size={14} />
</StyledColorIcon>
</StyledNodeCounter>
)}
<StyledRightPartContainer>
{iterationCount > 0 && (
<StyledIterationCounter runStatus={data.runStatus}>
{iterationCount}
</StyledIterationCounter>
)}
{data.runStatus === StepStatus.FAILED && (
<StyledNodeCounter>
<StyledColorIcon color={theme.tag.background.red}>
<IconX color={theme.tag.text.red} size={14} />
</StyledColorIcon>
</StyledNodeCounter>
)}
{(data.runStatus === StepStatus.SUCCESS ||
data.runStatus === StepStatus.STOPPED) && (
<StyledStatusIconsContainer>
<StyledColorIcon color={theme.tag.background.turquoise}>
<IconCheck color={theme.tag.text.turquoise} size={14} />
</StyledColorIcon>
</StyledStatusIconsContainer>
)}
{(data.runStatus === StepStatus.RUNNING ||
data.runStatus === StepStatus.PENDING) && (
<StyledNodeCounter>
<Loader color="yellow" />
</StyledNodeCounter>
)}
{data.runStatus === StepStatus.FAILED && (
<StyledStatusIconsContainer>
<StyledColorIcon color={theme.tag.background.red}>
<IconX color={theme.tag.text.red} size={14} />
</StyledColorIcon>
</StyledStatusIconsContainer>
)}
{(data.runStatus === StepStatus.RUNNING ||
data.runStatus === StepStatus.PENDING) && (
<StyledStatusIconsContainer>
<Loader color="yellow" />
</StyledStatusIconsContainer>
)}
</StyledRightPartContainer>
</StyledNodeLabelWithCounterPart>
<WorkflowNodeTitle runStatus={data.runStatus}>
@@ -0,0 +1,15 @@
import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory';
import { StepStatus, type WorkflowRunStepInfo } from 'twenty-shared/workflow';
export const getNodeIterationCount = ({
stepInfo,
}: {
stepInfo: WorkflowRunStepInfo;
}) => {
const stepInfoHistory = getWorkflowRunAllStepInfoHistory({ stepInfo });
return stepInfoHistory.filter(
(stepInfo) =>
stepInfo.status === StepStatus.SUCCESS ||
stepInfo.status === StepStatus.STOPPED,
).length;
};
@@ -224,10 +224,6 @@ export class WorkflowExecutorWorkspaceService {
const steps = workflowRun.state.flow.steps;
if (workflowShouldKeepRunning({ stepInfos, steps })) {
return;
}
if (workflowShouldFail({ stepInfos, steps })) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
@@ -239,6 +235,10 @@ export class WorkflowExecutorWorkspaceService {
return;
}
if (workflowShouldKeepRunning({ stepInfos, steps })) {
return;
}
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,
workspaceId,