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:
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -30,6 +30,7 @@ describe('addCreateStepNodes', () => {
|
|||||||
},
|
},
|
||||||
outputSchema: {},
|
outputSchema: {},
|
||||||
},
|
},
|
||||||
|
nextStepIds: ['step2'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'step2',
|
id: 'step2',
|
||||||
@ -48,6 +49,7 @@ describe('addCreateStepNodes', () => {
|
|||||||
},
|
},
|
||||||
outputSchema: {},
|
outputSchema: {},
|
||||||
},
|
},
|
||||||
|
nextStepIds: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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 },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
|
};
|
||||||
@ -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 },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 }),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user