965 flow control arrow menu 1/3 add insert step button (#12519)

Add insert step button to workflow edges



https://github.com/user-attachments/assets/7144f722-f1c7-450f-a8eb-c902071986a1



Also fixes `iconButtonGroup` UI component

## Before


https://github.com/user-attachments/assets/7b5f0245-d0e8-48af-9aa5-a29388a1caea


## After



https://github.com/user-attachments/assets/1820874f-aa99-41ae-8254-c76c275ee3ae
This commit is contained in:
martmull
2025-06-12 14:14:21 +02:00
committed by GitHub
parent a189f15313
commit cf01faf276
31 changed files with 755 additions and 291 deletions

View File

@ -204,8 +204,7 @@ export const SettingsDataModelOverview = () => {
> >
<Background /> <Background />
<IconButtonGroup <IconButtonGroup
className="react-flow__panel react-flow__controls bottom left" className="react-flow__panel react-flow__controls bottom left horizontal"
size="small"
iconButtons={[ iconButtons={[
{ {
Icon: IconPlus, Icon: IconPlus,

View File

@ -35,7 +35,9 @@ export const useDeleteWorkflowVersionStep = () => {
input: DeleteWorkflowVersionStepInput, input: DeleteWorkflowVersionStepInput,
) => { ) => {
const result = await mutate({ variables: { input } }); const result = await mutate({ variables: { input } });
const deletedStep = result?.data?.deleteWorkflowVersionStep; const deletedStep = result?.data?.deleteWorkflowVersionStep;
if (!isDefined(deletedStep)) { if (!isDefined(deletedStep)) {
return; return;
} }
@ -43,6 +45,7 @@ export const useDeleteWorkflowVersionStep = () => {
const cachedRecord = getRecordFromCache<WorkflowVersion>( const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId, input.workflowVersionId,
); );
if (!isDefined(cachedRecord)) { if (!isDefined(cachedRecord)) {
return; return;
} }
@ -51,12 +54,21 @@ export const useDeleteWorkflowVersionStep = () => {
...cachedRecord, ...cachedRecord,
steps: (cachedRecord.steps || []) steps: (cachedRecord.steps || [])
.filter((step: WorkflowAction) => step.id !== deletedStep.id) .filter((step: WorkflowAction) => step.id !== deletedStep.id)
.map((step) => { .map((step: WorkflowAction) => {
if (!step.nextStepIds?.includes(deletedStep.id)) {
return step;
}
return { return {
...step, ...step,
nextStepIds: step.nextStepIds?.filter( nextStepIds: [
(nextStepId) => nextStepId !== deletedStep.id, ...new Set([
), ...(step.nextStepIds?.filter(
(nextStepId) => nextStepId !== deletedStep.id,
) || []),
...(deletedStep.nextStepIds || []),
]),
],
}; };
}), }),
}; };
@ -64,6 +76,7 @@ export const useDeleteWorkflowVersionStep = () => {
const recordGqlFields = { const recordGqlFields = {
steps: true, steps: true,
}; };
updateRecordFromCache({ updateRecordFromCache({
objectMetadataItems, objectMetadataItems,
objectMetadataItem, objectMetadataItem,

View File

@ -68,7 +68,10 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
} }
if (isCreateStepNode(selectedNode)) { if (isCreateStepNode(selectedNode)) {
startNodeCreation(selectedNode.data.parentNodeId); startNodeCreation({
parentStepId: selectedNode.data.parentNodeId,
nextStepId: undefined,
});
return; return;
} }

View File

@ -1,18 +1,23 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import { WorkflowDiagramEdgeOptions } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeOptions';
import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
type WorkflowDiagramDefaultEdgeProps = EdgeProps; type WorkflowDiagramDefaultEdgeProps = EdgeProps<WorkflowDiagramEdge>;
export const WorkflowDiagramDefaultEdge = ({ export const WorkflowDiagramDefaultEdge = ({
source,
target,
sourceY, sourceY,
targetY, targetY,
markerStart, markerStart,
markerEnd, markerEnd,
data,
}: WorkflowDiagramDefaultEdgeProps) => { }: WorkflowDiagramDefaultEdgeProps) => {
const theme = useTheme(); const theme = useTheme();
const [edgePath] = getStraightPath({ const [edgePath, labelX, labelY] = getStraightPath({
sourceX: CREATE_STEP_NODE_WIDTH, sourceX: CREATE_STEP_NODE_WIDTH,
sourceY, sourceY,
targetX: CREATE_STEP_NODE_WIDTH, targetX: CREATE_STEP_NODE_WIDTH,
@ -20,11 +25,21 @@ export const WorkflowDiagramDefaultEdge = ({
}); });
return ( return (
<BaseEdge <>
markerStart={markerStart} <BaseEdge
markerEnd={markerEnd} markerStart={markerStart}
path={edgePath} markerEnd={markerEnd}
style={{ stroke: theme.border.color.strong }} path={edgePath}
/> style={{ stroke: theme.border.color.strong }}
/>
{data?.shouldDisplayEdgeOptions && (
<WorkflowDiagramEdgeOptions
labelX={labelX}
labelY={labelY}
parentStepId={source}
nextStepId={target}
/>
)}
</>
); );
}; };

View File

@ -0,0 +1,56 @@
import { EdgeLabelRenderer } from '@xyflow/react';
import { STEP_ICON_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth';
import styled from '@emotion/styled';
import { IconButtonGroup } from 'twenty-ui/input';
import { IconPlus } from 'twenty-ui/display';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { isDefined } from 'twenty-shared/utils';
const EDGE_OPTION_BUTTON_LEFT_MARGIN = 8;
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
`;
const StyledContainer = styled.div<{
labelX?: number;
labelY?: number;
}>`
position: absolute;
transform: ${({ labelX, labelY }) =>
`translate(${labelX || 0}px, ${isDefined(labelY) ? labelY - STEP_ICON_WIDTH / 2 : 0}px) translateX(${EDGE_OPTION_BUTTON_LEFT_MARGIN}px)`};
`;
type WorkflowDiagramEdgeOptionsProps = {
labelX?: number;
labelY?: number;
parentStepId: string;
nextStepId: string;
};
export const WorkflowDiagramEdgeOptions = ({
labelX,
labelY,
parentStepId,
nextStepId,
}: WorkflowDiagramEdgeOptionsProps) => {
const { startNodeCreation } = useStartNodeCreation();
return (
<EdgeLabelRenderer>
<StyledContainer labelX={labelX} labelY={labelY}>
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconPlus,
onClick: () => {
startNodeCreation({ parentStepId, nextStepId });
},
},
]}
/>
</StyledContainer>
</EdgeLabelRenderer>
);
};

View File

@ -16,6 +16,7 @@ import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWo
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { addEdgeOptions } from '@/workflow/workflow-diagram/utils/addEdgeOptions';
export const WorkflowDiagramEffect = ({ export const WorkflowDiagramEffect = ({
workflowWithCurrentVersion, workflowWithCurrentVersion,
@ -44,10 +45,11 @@ export const WorkflowDiagramEffect = ({
); );
const nextWorkflowDiagram = addCreateStepNodes( const nextWorkflowDiagram = addCreateStepNodes(
getWorkflowVersionDiagram(currentVersion), addEdgeOptions(getWorkflowVersionDiagram(currentVersion)),
); );
let mergedWorkflowDiagram = nextWorkflowDiagram; let mergedWorkflowDiagram = nextWorkflowDiagram;
if (isDefined(previousWorkflowDiagram)) { if (isDefined(previousWorkflowDiagram)) {
mergedWorkflowDiagram = mergeWorkflowDiagrams( mergedWorkflowDiagram = mergeWorkflowDiagrams(
previousWorkflowDiagram, previousWorkflowDiagram,
@ -59,6 +61,7 @@ export const WorkflowDiagramEffect = ({
snapshot, snapshot,
workflowLastCreatedStepIdState, workflowLastCreatedStepIdState,
); );
if (isDefined(lastCreatedStepId)) { if (isDefined(lastCreatedStepId)) {
mergedWorkflowDiagram.nodes = mergedWorkflowDiagram.nodes.map( mergedWorkflowDiagram.nodes = mergedWorkflowDiagram.nodes.map(
(node) => { (node) => {
@ -79,6 +82,7 @@ export const WorkflowDiagramEffect = ({
); );
const currentVersion = workflowWithCurrentVersion?.currentVersion; const currentVersion = workflowWithCurrentVersion?.currentVersion;
useEffect(() => { useEffect(() => {
if (!isDefined(currentVersion)) { if (!isDefined(currentVersion)) {
setFlow(undefined); setFlow(undefined);

View File

@ -1,6 +1,6 @@
import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth'; import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth';
const STEP_ICON_WIDTH = 24; export const STEP_ICON_WIDTH = 24;
const STEP_PADDING = 8; const STEP_PADDING = 8;

View File

@ -4,12 +4,12 @@ import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandM
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
export const useStartNodeCreation = () => { export const useStartNodeCreation = () => {
const setWorkflowCreateStepFromParentStepId = useSetRecoilComponentStateV2( const setWorkflowInsertStepIds = useSetRecoilComponentStateV2(
workflowCreateStepFromParentStepIdComponentState, workflowInsertStepIdsComponentState,
); );
const { openStepSelectInCommandMenu } = useWorkflowCommandMenu(); const { openStepSelectInCommandMenu } = useWorkflowCommandMenu();
@ -22,8 +22,14 @@ export const useStartNodeCreation = () => {
* That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly. * That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly.
*/ */
const startNodeCreation = useCallback( const startNodeCreation = useCallback(
(parentNodeId: string) => { ({
setWorkflowCreateStepFromParentStepId(parentNodeId); parentStepId,
nextStepId,
}: {
parentStepId: string | undefined;
nextStepId: string | undefined;
}) => {
setWorkflowInsertStepIds({ parentStepId, nextStepId });
if (isDefined(workflowVisualizerWorkflowId)) { if (isDefined(workflowVisualizerWorkflowId)) {
openStepSelectInCommandMenu(workflowVisualizerWorkflowId); openStepSelectInCommandMenu(workflowVisualizerWorkflowId);
@ -31,7 +37,7 @@ export const useStartNodeCreation = () => {
} }
}, },
[ [
setWorkflowCreateStepFromParentStepId, setWorkflowInsertStepIds,
workflowVisualizerWorkflowId, workflowVisualizerWorkflowId,
openStepSelectInCommandMenu, openStepSelectInCommandMenu,
], ],

View File

@ -4,11 +4,12 @@ import {
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { Edge, Node } from '@xyflow/react'; import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramStepNode = Node<WorkflowDiagramStepNodeData>;
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>; export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
export type WorkflowDiagramEdge = Edge; export type WorkflowDiagramEdge = Edge<EdgeData>;
export type WorkflowRunDiagramNode = Node<WorkflowRunDiagramNodeData>; export type WorkflowRunDiagramNode = Node<WorkflowRunDiagramNodeData>;
export type WorkflowRunDiagramEdge = Edge; export type WorkflowRunDiagramEdge = Edge<EdgeData>;
export type WorkflowRunDiagram = { export type WorkflowRunDiagram = {
nodes: Array<WorkflowRunDiagramNode>; nodes: Array<WorkflowRunDiagramNode>;
@ -67,6 +68,10 @@ export type WorkflowRunDiagramNodeData = Exclude<
'runStatus' 'runStatus'
> & { runStatus: WorkflowDiagramRunStatus }; > & { runStatus: WorkflowDiagramRunStatus };
export type EdgeData = {
shouldDisplayEdgeOptions?: boolean;
};
export type WorkflowDiagramNodeType = export type WorkflowDiagramNodeType =
| 'default' | 'default'
| 'empty-trigger' | 'empty-trigger'

View File

@ -30,6 +30,7 @@ describe('addCreateStepNodes', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -48,6 +49,7 @@ describe('addCreateStepNodes', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];

View File

@ -52,6 +52,7 @@ describe('generateWorkflowDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -70,6 +71,7 @@ describe('generateWorkflowDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
@ -118,6 +120,7 @@ describe('generateWorkflowDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -136,15 +139,168 @@ describe('generateWorkflowDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const result = generateWorkflowDiagram({ trigger, steps }); const result = generateWorkflowDiagram({ trigger, steps });
expect(result.edges[0].source).toEqual(result.nodes[0].id); expect(result.edges.length).toEqual(2);
expect(result.edges[0].target).toEqual(result.nodes[1].id); expect(result.nodes.length).toEqual(3);
expect(result.edges[1].source).toEqual(result.nodes[1].id); expect(result.edges[0].source).toEqual('trigger');
expect(result.edges[1].target).toEqual(result.nodes[2].id); expect(result.edges[0].target).toEqual('step1');
expect(result.edges[1].source).toEqual('step1');
expect(result.edges[1].target).toEqual('step2');
});
it('should take nextStepIds into account', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const steps: WorkflowStep[] = [
{
id: 'step1',
name: 'Step 1',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: undefined,
},
{
id: 'step2',
name: 'Step 2',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: ['step1'],
},
];
const result = generateWorkflowDiagram({ trigger, steps });
expect(result.edges.length).toEqual(2);
expect(result.nodes.length).toEqual(3);
expect(result.edges[0].source).toEqual('trigger');
expect(result.edges[0].target).toEqual('step2');
expect(result.edges[1].source).toEqual('step2');
expect(result.edges[1].target).toEqual('step1');
});
it('should take nextStepIds into account for complex diagram', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const steps: WorkflowStep[] = [
{
id: 'step1',
name: 'Step 1',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: undefined,
},
{
id: 'step2',
name: 'Step 2',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: ['step1'],
},
{
id: 'step3',
name: 'Step 3',
type: 'CODE',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: {
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
},
nextStepIds: ['step1'],
},
];
const result = generateWorkflowDiagram({ trigger, steps });
expect(result.edges.length).toEqual(4);
expect(result.nodes.length).toEqual(4);
expect(result.edges[0].source).toEqual('trigger');
expect(result.edges[0].target).toEqual('step2');
expect(result.edges[1].source).toEqual('trigger');
expect(result.edges[1].target).toEqual('step3');
expect(result.edges[2].source).toEqual('step2');
expect(result.edges[2].target).toEqual('step1');
expect(result.edges[3].source).toEqual('step3');
expect(result.edges[3].target).toEqual('step1');
}); });
}); });

View File

@ -21,6 +21,7 @@ describe('generateWorkflowRunDiagram', () => {
outputSchema: {}, outputSchema: {},
}, },
}; };
const steps: WorkflowStep[] = [ const steps: WorkflowStep[] = [
{ {
id: 'step1', id: 'step1',
@ -39,6 +40,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -57,6 +59,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step3'],
}, },
{ {
id: 'step3', id: 'step3',
@ -75,8 +78,10 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const stepsOutput: WorkflowRunOutputStepsOutput = { const stepsOutput: WorkflowRunOutputStepsOutput = {
step1: { step1: {
result: undefined, result: undefined,
@ -144,7 +149,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step1", "id": "step1",
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 150,
}, },
}, },
{ {
@ -157,7 +162,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step2", "id": "step2",
"position": { "position": {
"x": 0, "x": 0,
"y": 150, "y": 300,
}, },
}, },
{ {
@ -170,7 +175,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step3", "id": "step3",
"position": { "position": {
"x": 0, "x": 0,
"y": 300, "y": 450,
}, },
}, },
], ],
@ -189,6 +194,7 @@ describe('generateWorkflowRunDiagram', () => {
outputSchema: {}, outputSchema: {},
}, },
}; };
const steps: WorkflowStep[] = [ const steps: WorkflowStep[] = [
{ {
id: 'step1', id: 'step1',
@ -207,6 +213,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -225,6 +232,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step3'],
}, },
{ {
id: 'step3', id: 'step3',
@ -243,8 +251,10 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const stepsOutput: WorkflowRunOutputStepsOutput = { const stepsOutput: WorkflowRunOutputStepsOutput = {
step1: { step1: {
result: {}, result: {},
@ -322,7 +332,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step1", "id": "step1",
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 150,
}, },
}, },
{ {
@ -335,7 +345,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step2", "id": "step2",
"position": { "position": {
"x": 0, "x": 0,
"y": 150, "y": 300,
}, },
}, },
{ {
@ -348,7 +358,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step3", "id": "step3",
"position": { "position": {
"x": 0, "x": 0,
"y": 300, "y": 450,
}, },
}, },
], ],
@ -367,6 +377,7 @@ describe('generateWorkflowRunDiagram', () => {
outputSchema: {}, outputSchema: {},
}, },
}; };
const steps: WorkflowStep[] = [ const steps: WorkflowStep[] = [
{ {
id: 'step1', id: 'step1',
@ -385,6 +396,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -403,6 +415,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step3'],
}, },
{ {
id: 'step3', id: 'step3',
@ -421,8 +434,10 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const stepsOutput = undefined; const stepsOutput = undefined;
const result = generateWorkflowRunDiagram({ trigger, steps, stepsOutput }); const result = generateWorkflowRunDiagram({ trigger, steps, stepsOutput });
@ -485,7 +500,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step1", "id": "step1",
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 150,
}, },
}, },
{ {
@ -498,7 +513,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step2", "id": "step2",
"position": { "position": {
"x": 0, "x": 0,
"y": 150, "y": 300,
}, },
}, },
{ {
@ -511,7 +526,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step3", "id": "step3",
"position": { "position": {
"x": 0, "x": 0,
"y": 300, "y": 450,
}, },
}, },
], ],
@ -530,6 +545,7 @@ describe('generateWorkflowRunDiagram', () => {
outputSchema: {}, outputSchema: {},
}, },
}; };
const steps: WorkflowStep[] = [ const steps: WorkflowStep[] = [
{ {
id: 'step1', id: 'step1',
@ -548,6 +564,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step2'],
}, },
{ {
id: 'step2', id: 'step2',
@ -566,6 +583,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step3'],
}, },
{ {
id: 'step3', id: 'step3',
@ -584,6 +602,7 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: ['step4'],
}, },
{ {
id: 'step4', id: 'step4',
@ -602,8 +621,10 @@ describe('generateWorkflowRunDiagram', () => {
}, },
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const stepsOutput: WorkflowRunOutputStepsOutput = { const stepsOutput: WorkflowRunOutputStepsOutput = {
step1: { step1: {
result: {}, result: {},
@ -681,7 +702,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step1", "id": "step1",
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 150,
}, },
}, },
{ {
@ -694,7 +715,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step2", "id": "step2",
"position": { "position": {
"x": 0, "x": 0,
"y": 150, "y": 300,
}, },
}, },
{ {
@ -707,7 +728,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step3", "id": "step3",
"position": { "position": {
"x": 0, "x": 0,
"y": 300, "y": 450,
}, },
}, },
{ {
@ -720,7 +741,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step4", "id": "step4",
"position": { "position": {
"x": 0, "x": 0,
"y": 450, "y": 600,
}, },
}, },
], ],
@ -762,6 +783,7 @@ describe('generateWorkflowRunDiagram', () => {
], ],
outputSchema: {}, outputSchema: {},
}, },
nextStepIds: undefined,
}, },
]; ];
const stepsOutput = { const stepsOutput = {
@ -814,7 +836,7 @@ describe('generateWorkflowRunDiagram', () => {
"id": "step1", "id": "step1",
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 150,
}, },
}, },
], ],

View File

@ -0,0 +1,14 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const addEdgeOptions = ({
nodes,
edges,
}: WorkflowDiagram): WorkflowDiagram => {
return {
nodes,
edges: edges.map((edge) => ({
...edge,
data: { shouldDisplayEdgeOptions: true },
})),
};
};

View File

@ -15,6 +15,58 @@ import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerSt
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
/**
* Groups workflow steps into levels based on their distance from root nodes.
*
* A root node is one that is not referenced as a `nextStepId` by any other step.
* The function performs a breadth-first traversal from all roots and assigns
* each step to a level indicating its depth in the graph.
*
* Returns an array where each sub-array contains all steps at the same level.
*/
const groupStepsByLevel = (steps: WorkflowStep[]): WorkflowStep[][] => {
const stepMap = new Map<string, WorkflowStep>();
const childIds = new Set<string>();
for (const step of steps) {
stepMap.set(step.id, step);
step.nextStepIds?.forEach((id) => childIds.add(id));
}
const rootSteps = steps.filter((step) => !childIds.has(step.id));
const stepsByLevel: WorkflowStep[][] = [];
const visited = new Set<string>();
const visit = ({ step, level }: { step: WorkflowStep; level: number }) => {
if (visited.has(step.id)) {
return;
}
visited.add(step.id);
if (!isDefined(stepsByLevel[level])) {
stepsByLevel[level] = [];
}
stepsByLevel[level].push(step);
step.nextStepIds?.forEach((childId) => {
const child = stepMap.get(childId);
if (isDefined(child)) {
visit({ step: child, level: level + 1 });
}
});
};
rootSteps.forEach((root) => visit({ step: root, level: 0 }));
return stepsByLevel;
};
export const generateWorkflowDiagram = ({ export const generateWorkflowDiagram = ({
trigger, trigger,
steps, steps,
@ -23,6 +75,7 @@ export const generateWorkflowDiagram = ({
steps: Array<WorkflowStep>; steps: Array<WorkflowStep>;
}): WorkflowDiagram => { }): WorkflowDiagram => {
const nodes: Array<WorkflowDiagramNode> = []; const nodes: Array<WorkflowDiagramNode> = [];
const edges: Array<WorkflowDiagramEdge> = []; const edges: Array<WorkflowDiagramEdge> = [];
if (isDefined(trigger)) { if (isDefined(trigger)) {
@ -31,58 +84,50 @@ export const generateWorkflowDiagram = ({
nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION); nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION);
} }
const processNode = ({ const stepsGroupedByLevel = groupStepsByLevel(steps);
stepIndex,
parentNodeId, let levelYPos = FIRST_NODE_POSITION.y;
xPos,
yPos, const xPos = FIRST_NODE_POSITION.x;
}: {
stepIndex: number; for (const stepsByLevel of stepsGroupedByLevel) {
parentNodeId: string; levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES;
xPos: number;
yPos: number; for (const step of stepsByLevel) {
}) => { nodes.push({
const step = steps.at(stepIndex); id: step.id,
if (!isDefined(step)) { data: {
return; nodeType: 'action',
actionType: step.type,
name: step.name,
} satisfies WorkflowDiagramStepNodeData,
position: {
x: xPos,
y: levelYPos,
},
});
} }
}
const nodeId = step.id; for (const firstLevelStep of stepsGroupedByLevel[0] || []) {
nodes.push({
id: nodeId,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
} satisfies WorkflowDiagramStepNodeData,
position: {
x: xPos,
y: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
},
});
edges.push({ edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
id: v4(), id: v4(),
source: parentNodeId, source: TRIGGER_STEP_ID,
target: nodeId, target: firstLevelStep.id,
}); });
}
processNode({ for (const step of steps) {
stepIndex: stepIndex + 1, step.nextStepIds?.forEach((child) => {
parentNodeId: nodeId, edges.push({
xPos, ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES, id: v4(),
source: step.id,
target: child,
});
}); });
}; }
processNode({
stepIndex: 0,
parentNodeId: TRIGGER_STEP_ID,
xPos: FIRST_NODE_POSITION.x,
yPos: FIRST_NODE_POSITION.y,
});
return { return {
nodes, nodes,

View File

@ -3,22 +3,16 @@ import {
WorkflowStep, WorkflowStep,
WorkflowTrigger, WorkflowTrigger,
} from '@/workflow/types/Workflow'; } 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_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeSuccessConfiguration'; import { WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeSuccessConfiguration';
import { import {
WorkflowDiagramRunStatus, WorkflowDiagramRunStatus,
WorkflowRunDiagram, WorkflowRunDiagram,
WorkflowRunDiagramEdge,
WorkflowRunDiagramNode, WorkflowRunDiagramNode,
WorkflowRunDiagramNodeData,
WorkflowRunDiagramStepNodeData, WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid'; import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode';
export const generateWorkflowRunDiagram = ({ export const generateWorkflowRunDiagram = ({
trigger, trigger,
@ -44,124 +38,79 @@ export const generateWorkflowRunDiagram = ({
} }
| undefined = undefined; | undefined = undefined;
const triggerBase = getWorkflowDiagramTriggerNode({ trigger }); const workflowDiagram = generateWorkflowDiagram({ trigger, steps });
const nodes: Array<WorkflowRunDiagramNode> = [ let skippedExecution = false;
{
...triggerBase,
data: {
...triggerBase.data,
runStatus: 'success',
},
},
];
const edges: Array<WorkflowRunDiagramEdge> = [];
const processNode = ({ const workflowRunDiagramNodes: WorkflowRunDiagramNode[] =
stepIndex, workflowDiagram.nodes.filter(isStepNode).map((node) => {
parentNodeId, if (node.data.nodeType === 'trigger') {
parentRunStatus, return {
xPos, ...node,
yPos, data: {
skippedExecution, ...node.data,
}: { runStatus: 'success',
stepIndex: number; },
parentNodeId: string; };
parentRunStatus: WorkflowDiagramRunStatus;
xPos: number;
yPos: number;
skippedExecution: boolean;
}) => {
const step = steps.at(stepIndex);
if (!isDefined(step)) {
return;
}
const nodeId = step.id;
if (parentRunStatus === 'success') {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION,
id: v4(),
source: parentNodeId,
target: nodeId,
});
} else {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
id: v4(),
source: parentNodeId,
target: nodeId,
});
}
const runResult = stepsOutput?.[nodeId];
const isPendingFormAction =
step.type === 'FORM' &&
isDefined(runResult?.pendingEvent) &&
runResult.pendingEvent;
let runStatus: WorkflowDiagramRunStatus;
if (skippedExecution) {
runStatus = 'not-executed';
} else if (!isDefined(runResult) || isPendingFormAction) {
runStatus = 'running';
} else {
if (isDefined(runResult.error)) {
runStatus = 'failure';
} else {
runStatus = 'success';
} }
}
const nodeData: WorkflowRunDiagramNodeData = { const nodeId = node.id;
nodeType: 'action',
actionType: step.type,
name: step.name,
runStatus,
};
nodes.push({ const runResult = stepsOutput?.[nodeId];
id: nodeId,
data: nodeData, const isPendingFormAction =
position: { node.data.nodeType === 'action' &&
x: xPos, node.data.actionType === 'FORM' &&
y: yPos, isDefined(runResult?.pendingEvent) &&
}, runResult.pendingEvent;
let runStatus: WorkflowDiagramRunStatus = 'success';
if (skippedExecution) {
runStatus = 'not-executed';
} else if (!isDefined(runResult) || isPendingFormAction) {
runStatus = 'running';
} else if (isDefined(runResult.error)) {
runStatus = 'failure';
}
skippedExecution =
skippedExecution || runStatus === 'failure' || runStatus === 'running';
const nodeData = { ...node.data, runStatus };
if (isPendingFormAction) {
stepToOpenByDefault = {
id: nodeId,
data: nodeData,
};
}
return {
...node,
data: nodeData,
};
}); });
if (isPendingFormAction) { const workflowRunDiagramEdges = workflowDiagram.edges.map((edge) => {
stepToOpenByDefault = { const parentNode = workflowRunDiagramNodes.find(
id: nodeId, (node) => node.id === edge.source,
data: nodeData, );
if (isDefined(parentNode) && parentNode.data.runStatus === 'success') {
return {
...edge,
...WORKFLOW_VISUALIZER_EDGE_SUCCESS_CONFIGURATION,
}; };
} }
processNode({ return edge;
stepIndex: stepIndex + 1,
parentNodeId: nodeId,
parentRunStatus: runStatus,
xPos,
yPos: yPos + VERTICAL_DISTANCE_BETWEEN_TWO_NODES,
skippedExecution: skippedExecution
? true
: runStatus === 'failure' || runStatus === 'running',
});
};
processNode({
stepIndex: 0,
parentNodeId: TRIGGER_STEP_ID,
parentRunStatus: 'success',
xPos: FIRST_NODE_POSITION.x,
yPos: FIRST_NODE_POSITION.y,
skippedExecution: false,
}); });
return { return {
diagram: { diagram: {
nodes, nodes: workflowRunDiagramNodes,
edges, edges: workflowRunDiagramEdges,
}, },
stepToOpenByDefault, stepToOpenByDefault,
}; };

View File

@ -0,0 +1,10 @@
import {
WorkflowDiagramNode,
WorkflowDiagramStepNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const isStepNode = (
node: WorkflowDiagramNode,
): node is WorkflowDiagramStepNode => {
return node.data.nodeType === 'trigger' || node.data.nodeType === 'action';
};

View File

@ -1,5 +1,5 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
@ -33,10 +33,10 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
<RecoilRoot <RecoilRoot
initializeState={({ set }) => { initializeState={({ set }) => {
set( set(
workflowCreateStepFromParentStepIdComponentState.atomFamily({ workflowInsertStepIdsComponentState.atomFamily({
instanceId: workflowVisualizerComponentInstanceId, instanceId: workflowVisualizerComponentInstanceId,
}), }),
'parent-step-id', { parentStepId: 'parent-step-id', nextStepId: undefined },
); );
}} }}
> >

View File

@ -8,7 +8,7 @@ import {
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import { workflowCreateStepFromParentStepIdComponentState } from '@/workflow/workflow-steps/states/workflowCreateStepFromParentStepIdComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
export const useCreateStep = ({ export const useCreateStep = ({
@ -24,15 +24,17 @@ export const useCreateStep = ({
workflowLastCreatedStepIdComponentState, workflowLastCreatedStepIdComponentState,
); );
const workflowCreateStepFromParentStepId = useRecoilComponentValueV2( const workflowInsertStepIds = useRecoilComponentValueV2(
workflowCreateStepFromParentStepIdComponentState, workflowInsertStepIdsComponentState,
); );
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const createStep = async (newStepType: WorkflowStepType) => { const createStep = async (newStepType: WorkflowStepType) => {
if (!isDefined(workflowCreateStepFromParentStepId)) { if (!isDefined(workflowInsertStepIds.parentStepId)) {
throw new Error('Select a step to create a new step from first.'); throw new Error(
'No parentStepId. Please select a parent step to create from.',
);
} }
const workflowVersionId = await getUpdatableWorkflowVersion(workflow); const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
@ -41,8 +43,8 @@ export const useCreateStep = ({
await createWorkflowVersionStep({ await createWorkflowVersionStep({
workflowVersionId, workflowVersionId,
stepType: newStepType, stepType: newStepType,
parentStepId: workflowCreateStepFromParentStepId, parentStepId: workflowInsertStepIds.parentStepId,
nextStepId: undefined, nextStepId: workflowInsertStepIds.nextStepId,
}) })
)?.data?.createWorkflowVersionStep; )?.data?.createWorkflowVersionStep;

View File

@ -37,8 +37,9 @@ export const useCreateWorkflowVersionStep = () => {
variables: { input }, variables: { input },
}); });
const createdStep = result?.data?.createWorkflowVersionStep; const insertedStep = result?.data?.createWorkflowVersionStep;
if (!isDefined(createdStep)) {
if (!isDefined(insertedStep)) {
return; return;
} }
@ -50,20 +51,29 @@ export const useCreateWorkflowVersionStep = () => {
return; return;
} }
const { parentStepId, nextStepId } = input;
const updatedExistingSteps = const updatedExistingSteps =
cachedRecord.steps?.map((step) => { cachedRecord.steps?.map((existingStep) => {
if (step.id === input.parentStepId) { if (existingStep.id === parentStepId) {
return { return {
...step, ...existingStep,
nextStepIds: [...(step.nextStepIds || []), createdStep.id], nextStepIds: [
...new Set([
...(existingStep.nextStepIds?.filter(
(id) => id !== nextStepId,
) || []),
insertedStep.id,
]),
],
}; };
} }
return step; return existingStep;
}) ?? []; }) ?? [];
const newCachedRecord = { const newCachedRecord = {
...cachedRecord, ...cachedRecord,
steps: [...updatedExistingSteps, createdStep], steps: [...updatedExistingSteps, insertedStep],
}; };
const recordGqlFields = { const recordGqlFields = {

View File

@ -1,9 +1,14 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
export const workflowCreateStepFromParentStepIdComponentState = type WorkflowInsertStepIdsState = {
createComponentStateV2<string | undefined>({ parentStepId: string | undefined;
key: 'workflowCreateStepFromParentStepIdComponentState', nextStepId: string | undefined;
defaultValue: undefined, };
export const workflowInsertStepIdsComponentState =
createComponentStateV2<WorkflowInsertStepIdsState>({
key: 'workflowInsertStepIdsComponentState',
defaultValue: { parentStepId: undefined, nextStepId: undefined },
componentInstanceContext: WorkflowVisualizerComponentInstanceContext, componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
}); });

View File

@ -38,7 +38,8 @@ describe('insertStep', () => {
insertedStep: newStep, insertedStep: newStep,
}); });
expect(result).toEqual([step1, step2, newStep]); expect(result.updatedSteps).toEqual([step1, step2, newStep]);
expect(result.updatedInsertedStep).toEqual(newStep);
}); });
it('should update parent step nextStepIds when inserting a step between two steps', () => { it('should update parent step nextStepIds when inserting a step between two steps', () => {
@ -53,7 +54,7 @@ describe('insertStep', () => {
nextStepId: '2', nextStepId: '2',
}); });
expect(result).toEqual([ expect(result.updatedSteps).toEqual([
{ ...step1, nextStepIds: ['new'] }, { ...step1, nextStepIds: ['new'] },
step2, step2,
{ ...newStep, nextStepIds: ['2'] }, { ...newStep, nextStepIds: ['2'] },
@ -71,7 +72,10 @@ describe('insertStep', () => {
nextStepId: '1', nextStepId: '1',
}); });
expect(result).toEqual([step1, { ...newStep, nextStepIds: ['1'] }]); expect(result.updatedSteps).toEqual([
step1,
{ ...newStep, nextStepIds: ['1'] },
]);
}); });
it('should handle inserting a step at the end of the workflow', () => { it('should handle inserting a step at the end of the workflow', () => {
@ -85,7 +89,10 @@ describe('insertStep', () => {
nextStepId: undefined, nextStepId: undefined,
}); });
expect(result).toEqual([{ ...step1, nextStepIds: ['new'] }, newStep]); expect(result.updatedSteps).toEqual([
{ ...step1, nextStepIds: ['new'] },
newStep,
]);
}); });
it('should handle inserting a step between two steps with multiple nextStepIds', () => { it('should handle inserting a step between two steps with multiple nextStepIds', () => {
@ -101,7 +108,7 @@ describe('insertStep', () => {
nextStepId: '2', nextStepId: '2',
}); });
expect(result).toEqual([ expect(result.updatedSteps).toEqual([
{ ...step1, nextStepIds: ['3', 'new'] }, { ...step1, nextStepIds: ['3', 'new'] },
step2, step2,
step3, step3,

View File

@ -10,7 +10,7 @@ export const insertStep = ({
insertedStep: WorkflowAction; insertedStep: WorkflowAction;
parentStepId?: string; parentStepId?: string;
nextStepId?: string; nextStepId?: string;
}): WorkflowAction[] => { }): { updatedSteps: WorkflowAction[]; updatedInsertedStep: WorkflowAction } => {
const updatedExistingSteps = existingSteps.map((existingStep) => { const updatedExistingSteps = existingSteps.map((existingStep) => {
if (existingStep.id === parentStepId) { if (existingStep.id === parentStepId) {
return { return {
@ -28,11 +28,13 @@ export const insertStep = ({
return existingStep; return existingStep;
}); });
return [ const updatedInsertedStep = {
...updatedExistingSteps, ...insertedStep,
{ nextStepIds: nextStepId ? [nextStepId] : undefined,
...insertedStep, };
nextStepIds: nextStepId ? [nextStepId] : undefined,
}, return {
]; updatedSteps: [...updatedExistingSteps, updatedInsertedStep],
updatedInsertedStep,
};
}; };

View File

@ -96,7 +96,8 @@ export class WorkflowVersionStepWorkspaceService {
assertWorkflowVersionIsDraft(workflowVersion); assertWorkflowVersionIsDraft(workflowVersion);
const existingSteps = workflowVersion.steps || []; const existingSteps = workflowVersion.steps || [];
const updatedSteps = insertStep({
const { updatedSteps, updatedInsertedStep } = insertStep({
existingSteps, existingSteps,
insertedStep: enrichedNewStep, insertedStep: enrichedNewStep,
parentStepId, parentStepId,
@ -107,7 +108,7 @@ export class WorkflowVersionStepWorkspaceService {
steps: updatedSteps, steps: updatedSteps,
}); });
return enrichedNewStep; return updatedInsertedStep;
} }
async updateWorkflowVersionStep({ async updateWorkflowVersionStep({

View File

@ -8,6 +8,7 @@ export class WorkflowRunException extends CustomException {
export enum WorkflowRunExceptionCode { export enum WorkflowRunExceptionCode {
WORKFLOW_RUN_NOT_FOUND = 'WORKFLOW_RUN_NOT_FOUND', WORKFLOW_RUN_NOT_FOUND = 'WORKFLOW_RUN_NOT_FOUND',
WORKFLOW_ROOT_STEP_NOT_FOUND = 'WORKFLOW_ROOT_STEP_NOT_FOUND',
INVALID_OPERATION = 'INVALID_OPERATION', INVALID_OPERATION = 'INVALID_OPERATION',
INVALID_INPUT = 'INVALID_INPUT', INVALID_INPUT = 'INVALID_INPUT',
WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED', WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED',

View File

@ -14,6 +14,7 @@ import {
WorkflowRunExceptionCode, WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception'; } from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/getRootSteps.utils';
export type RunWorkflowJobData = { export type RunWorkflowJobData = {
workspaceId: string; workspaceId: string;
@ -114,9 +115,11 @@ export class RunWorkflowJob {
await this.throttleExecution(workflowVersion.workflowId); await this.throttleExecution(workflowVersion.workflowId);
const rootSteps = getRootSteps(workflowVersion.steps);
await this.executeWorkflow({ await this.executeWorkflow({
workflowRunId, workflowRunId,
currentStepId: workflowVersion.steps[0].id, currentStepId: rootSteps[0].id,
steps: workflowVersion.steps, steps: workflowVersion.steps,
context, context,
workspaceId, workspaceId,

View File

@ -0,0 +1,85 @@
import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/getRootSteps.utils';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
describe('getRootSteps', () => {
it('should return the root steps', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
{ id: 'step2', nextStepIds: undefined },
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should not consider step order', () => {
const steps = [
{ id: 'step2', nextStepIds: undefined },
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should handle multiple root steps', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step3'],
},
{
id: 'step2',
nextStepIds: ['step3'],
},
{ id: 'step3', nextStepIds: ['step4'] },
{ id: 'step4', nextStepIds: undefined },
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step3'],
},
{
id: 'step2',
nextStepIds: ['step3'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should throw if buggy steps provided', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
{
id: 'step2',
nextStepIds: ['step1'],
},
] as WorkflowAction[];
expect(() => getRootSteps(steps)).toThrow('No root step found');
});
});

View File

@ -0,0 +1,24 @@
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
WorkflowRunException,
WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
export const getRootSteps = (steps: WorkflowAction[]): WorkflowAction[] => {
const childIds = new Set<string>();
for (const step of steps) {
step.nextStepIds?.forEach((id) => childIds.add(id));
}
const rootSteps = steps.filter((step) => !childIds.has(step.id));
if (rootSteps.length === 0) {
throw new WorkflowRunException(
'No root step found',
WorkflowRunExceptionCode.WORKFLOW_ROOT_STEP_NOT_FOUND,
);
}
return rootSteps;
};

View File

@ -2,17 +2,10 @@ import styled from '@emotion/styled';
import { IconComponent } from '@ui/display'; import { IconComponent } from '@ui/display';
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { IconButton, IconButtonPosition, IconButtonProps } from './IconButton'; import { InsideButton } from '@ui/input/button/components/InsideButton';
const StyledIconButtonGroupContainer = styled.div` export type IconButtonGroupProps = {
border-radius: ${({ theme }) => theme.border.radius.md}; disabled?: boolean;
display: flex;
`;
export type IconButtonGroupProps = Pick<
IconButtonProps,
'accent' | 'size' | 'variant'
> & {
iconButtons: { iconButtons: {
Icon: IconComponent; Icon: IconComponent;
onClick?: (event: MouseEvent<any>) => void; onClick?: (event: MouseEvent<any>) => void;
@ -20,31 +13,36 @@ export type IconButtonGroupProps = Pick<
className?: string; className?: string;
}; };
const StyledIconButtonGroupContainer = styled.div<
Pick<IconButtonGroupProps, 'disabled'>
>`
display: inline-flex;
align-items: flex-start;
background-color: ${({ disabled, theme }) =>
disabled ? 'inherit' : theme.background.transparent.lighter};
border-radius: ${({ theme }) => theme.border.radius.sm};
gap: 2px;
padding: 2px;
backdrop-filter: blur(20px);
&:hover {
box-shadow: ${({ theme }) => theme.boxShadow.light};
}
`;
export const IconButtonGroup = ({ export const IconButtonGroup = ({
accent,
iconButtons, iconButtons,
size, disabled,
variant,
className, className,
}: IconButtonGroupProps) => ( }: IconButtonGroupProps) => (
<StyledIconButtonGroupContainer className={className}> <StyledIconButtonGroupContainer className={className} disabled={disabled}>
{iconButtons.map(({ Icon, onClick }, index) => { {iconButtons.map(({ Icon, onClick }, index) => {
const position: IconButtonPosition =
index === 0
? 'left'
: index === iconButtons.length - 1
? 'right'
: 'middle';
return ( return (
<IconButton <InsideButton
key={index} key={index}
accent={accent}
Icon={Icon} Icon={Icon}
onClick={onClick} onClick={onClick}
position={position} disabled={disabled}
size={size}
variant={variant}
/> />
); );
})} })}

View File

@ -0,0 +1,47 @@
import { IconComponent } from '@ui/display';
import styled from '@emotion/styled';
import React from 'react';
import { useTheme } from '@emotion/react';
export type InsideButtonProps = {
className?: string;
Icon?: IconComponent;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
};
const StyledButton = styled.button`
align-items: center;
border: none;
background-color: transparent;
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
height: 20px;
justify-content: center;
padding: 0;
white-space: nowrap;
min-width: 20px;
transition: background-color 0.1s ease;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
export const InsideButton = ({
className,
Icon,
onClick,
disabled = false,
}: InsideButtonProps) => {
const theme = useTheme();
return (
<StyledButton className={className} onClick={onClick} disabled={disabled}>
{Icon && <Icon size={theme.icon.size.sm} />}
</StyledButton>
);
};

View File

@ -5,11 +5,7 @@ import {
CatalogStory, CatalogStory,
ComponentDecorator, ComponentDecorator,
} from '@ui/testing'; } from '@ui/testing';
import {
IconButtonAccent,
IconButtonSize,
IconButtonVariant,
} from '../IconButton';
import { IconButtonGroup } from '../IconButtonGroup'; import { IconButtonGroup } from '../IconButtonGroup';
const meta: Meta<typeof IconButtonGroup> = { const meta: Meta<typeof IconButtonGroup> = {
@ -32,40 +28,22 @@ type Story = StoryObj<typeof IconButtonGroup>;
export const Default: Story = { export const Default: Story = {
args: { args: {
size: 'small', disabled: false,
variant: 'primary',
accent: 'danger',
}, },
decorators: [ComponentDecorator], decorators: [ComponentDecorator],
}; };
export const Catalog: CatalogStory<Story, typeof IconButtonGroup> = { export const Catalog: CatalogStory<Story, typeof IconButtonGroup> = {
argTypes: { argTypes: {
size: { control: false }, disabled: { control: false },
variant: { control: false },
accent: { control: false },
}, },
parameters: { parameters: {
catalog: { catalog: {
dimensions: [ dimensions: [
{ {
name: 'sizes', name: 'disabled',
values: ['small', 'medium'] satisfies IconButtonSize[], values: [true, false],
props: (size: IconButtonSize) => ({ size }), props: (disabled: boolean) => ({ disabled }),
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[],
props: (accent: IconButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
}, },
], ],
}, },

View File

@ -49,6 +49,8 @@ export type {
export { IconButton } from './button/components/IconButton'; export { IconButton } from './button/components/IconButton';
export type { IconButtonGroupProps } from './button/components/IconButtonGroup'; export type { IconButtonGroupProps } from './button/components/IconButtonGroup';
export { IconButtonGroup } from './button/components/IconButtonGroup'; export { IconButtonGroup } from './button/components/IconButtonGroup';
export type { InsideButtonProps } from './button/components/InsideButton';
export { InsideButton } from './button/components/InsideButton';
export type { export type {
LightButtonAccent, LightButtonAccent,
LightButtonProps, LightButtonProps,