Make filters a regular step (#14586)

- Remove special design logic for filters and edge with filters
- Remove backend logic to delete filters along with parent steps
- Add filters to the node type picker



https://github.com/user-attachments/assets/5b6ceda4-d86f-48a3-8c93-37a885398a8d
This commit is contained in:
Thomas Trompette
2025-09-18 13:07:23 +00:00
committed by GitHub
parent 235eef32f6
commit 3fbc257c06
32 changed files with 88 additions and 2184 deletions
@@ -4,8 +4,8 @@ import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/componen
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
import { AI_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/AiActions';
import { CORE_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/CoreActions';
import { FLOW_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/FlowActions';
import { HUMAN_INPUT_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/HumanInputActions';
import { OTHER_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/OtherActions';
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
@@ -45,6 +45,16 @@ export const CommandMenuWorkflowSelectAction = ({
</>
)}
<RightDrawerWorkflowSelectStepTitle>
{t`Flow`}
</RightDrawerWorkflowSelectStepTitle>
<WorkflowActionMenuItems
actions={FLOW_ACTIONS.filter(
(action) => action.type !== 'ITERATOR' || isIteratorEnabled,
)}
onClick={onActionSelected}
/>
<RightDrawerWorkflowSelectStepTitle>
{t`Core`}
</RightDrawerWorkflowSelectStepTitle>
@@ -60,18 +70,6 @@ export const CommandMenuWorkflowSelectAction = ({
actions={HUMAN_INPUT_ACTIONS}
onClick={onActionSelected}
/>
{isIteratorEnabled && (
<>
<RightDrawerWorkflowSelectStepTitle>
{t`Others`}
</RightDrawerWorkflowSelectStepTitle>
<WorkflowActionMenuItems
actions={OTHER_ACTIONS}
onClick={onActionSelected}
/>
</>
)}
</RightDrawerStepListContainer>
);
};
@@ -14,7 +14,6 @@ import {
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBlankEdge';
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable';
import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge';
@@ -128,8 +127,7 @@ export const WorkflowDiagramCanvasEditable = () => {
}}
edgeTypes={{
blank: WorkflowDiagramBlankEdge,
'empty-filter--editable': WorkflowDiagramDefaultEdgeEditable,
'filter--editable': WorkflowDiagramFilterEdgeEditable,
editable: WorkflowDiagramDefaultEdgeEditable,
}}
tagContainerTestId="workflow-visualizer-status"
tagColor={tagProps.color}
@@ -4,7 +4,6 @@ import { WorkflowDiagramDefaultEdgeReadonly } from '@/workflow/workflow-diagram/
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { WorkflowDiagramEmptyTriggerReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerReadonly';
import { WorkflowDiagramFilterEdgeReadonly } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramFilterEdgeReadonly';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeReadonly';
import { ReactFlowProvider } from '@xyflow/react';
@@ -25,8 +24,7 @@ export const WorkflowDiagramCanvasReadonly = ({
'empty-trigger': WorkflowDiagramEmptyTriggerReadonly,
}}
edgeTypes={{
'empty-filter--readonly': WorkflowDiagramDefaultEdgeReadonly,
'filter--readonly': WorkflowDiagramFilterEdgeReadonly,
readonly: WorkflowDiagramDefaultEdgeReadonly,
}}
tagContainerTestId="workflow-visualizer-status"
tagColor={tagProps.color}
@@ -1,7 +1,6 @@
import { type WorkflowRunStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramDefaultEdgeRun } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeRun';
import { WorkflowDiagramFilterEdgeRun } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramFilterEdgeRun';
import { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
import { WorkflowRunDiagramStepNode } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode';
@@ -23,8 +22,7 @@ export const WorkflowRunDiagramCanvas = ({
default: WorkflowRunDiagramStepNode,
}}
edgeTypes={{
'empty-filter--run': WorkflowDiagramDefaultEdgeRun,
'filter--run': WorkflowDiagramFilterEdgeRun,
run: WorkflowDiagramDefaultEdgeRun,
}}
tagContainerTestId="workflow-run-status"
tagColor={tagProps.color}
@@ -1,65 +0,0 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useOpenWorkflowEditFilterInCommandMenu = () => {
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setWorkflowDiagram = useSetRecoilComponentState(
workflowDiagramComponentState,
);
const openWorkflowEditFilterInCommandMenu = ({
stepId,
stepName,
}: {
stepId: string;
stepName: string;
}) => {
if (!isDefined(workflowVisualizerWorkflowId)) {
throw new Error(
'Workflow ID must be configured for the edge when opening a filter in command menu',
);
}
setWorkflowSelectedNode(stepId);
setWorkflowDiagram((diagram) => {
if (!isDefined(diagram)) {
throw new Error('Workflow diagram must be defined');
}
return {
...diagram,
nodes: diagram.nodes.map((node) => ({
...node,
selected: node.id === stepId,
})),
};
});
openWorkflowEditStepInCommandMenu(
workflowVisualizerWorkflowId,
stepName,
getIcon(getActionIcon('FILTER')),
);
};
return {
openWorkflowEditFilterInCommandMenu,
};
};
@@ -1,78 +0,0 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowRunIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowRunIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { isDefined } from 'twenty-shared/utils';
import { type StepStatus } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
export const useOpenWorkflowRunFilterInCommandMenu = () => {
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowRunId = useRecoilComponentValue(
workflowVisualizerWorkflowRunIdComponentState,
);
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setWorkflowDiagram = useSetRecoilComponentState(
workflowDiagramComponentState,
);
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const openWorkflowRunFilterInCommandMenu = ({
stepId,
stepName,
stepExecutionStatus,
}: {
stepId: string;
stepName: string;
stepExecutionStatus: StepStatus;
}) => {
if (!isDefined(workflowVisualizerWorkflowId)) {
throw new Error('Workflow ID is required');
}
if (!isDefined(workflowVisualizerWorkflowRunId)) {
throw new Error('Workflow run ID is required');
}
setWorkflowSelectedNode(stepId);
setWorkflowDiagram((diagram) => {
if (!isDefined(diagram)) {
throw new Error('Workflow diagram must be defined');
}
return {
...diagram,
nodes: diagram.nodes.map((node) => ({
...node,
selected: false,
})),
};
});
openWorkflowRunViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowRunId: workflowVisualizerWorkflowRunId,
title: stepName,
icon: getIcon(getActionIcon('FILTER')),
workflowSelectedNode: stepId,
stepExecutionStatus,
});
};
return {
openWorkflowRunFilterInCommandMenu,
};
};
@@ -1,72 +0,0 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useOpenWorkflowViewFilterInCommandMenu = () => {
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowVersionId = useRecoilComponentValue(
workflowVisualizerWorkflowVersionIdComponentState,
);
const { openWorkflowViewStepInCommandMenu } = useWorkflowCommandMenu();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setWorkflowDiagram = useSetRecoilComponentState(
workflowDiagramComponentState,
);
const openWorkflowViewFilterInCommandMenu = ({
stepId,
stepName,
}: {
stepId: string;
stepName: string;
}) => {
if (!workflowVisualizerWorkflowId) {
throw new Error('Workflow ID is required');
}
if (!workflowVisualizerWorkflowVersionId) {
throw new Error('Workflow version ID is required');
}
setWorkflowSelectedNode(stepId);
setWorkflowDiagram((diagram) => {
if (!isDefined(diagram)) {
throw new Error('Workflow diagram must be defined');
}
return {
...diagram,
nodes: diagram.nodes.map((node) => ({
...node,
selected: false,
})),
};
});
openWorkflowViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: stepName,
icon: getIcon(getActionIcon('FILTER')),
});
};
return {
openWorkflowViewFilterInCommandMenu,
};
};
@@ -3,7 +3,6 @@ import {
type WorkflowRunStepStatus,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { type MessageDescriptor } from '@lingui/core';
import {
type Connection,
@@ -105,33 +104,14 @@ export type WorkflowDiagramEdgeLabelOptions = {
label: MessageDescriptor;
};
export type WorkflowDiagramFilterEdgeData = {
edgeType: 'filter';
stepId: string;
filterSettings: FilterSettings;
name: string;
runStatus?: WorkflowRunStepStatus;
edgeExecutionStatus?: StepStatus;
labelOptions?: WorkflowDiagramEdgeLabelOptions;
};
export type WorkflowDiagramDefaultEdgeData = {
edgeType: 'default';
edgeExecutionStatus?: StepStatus;
labelOptions?: WorkflowDiagramEdgeLabelOptions;
};
export type WorkflowDiagramEdgeData =
| WorkflowDiagramFilterEdgeData
| WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramEdgeData = WorkflowDiagramDefaultEdgeData;
export type WorkflowDiagramNodeType = 'default' | 'empty-trigger';
export type WorkflowDiagramEdgeType =
| 'blank'
| 'empty-filter--editable'
| 'empty-filter--readonly'
| 'empty-filter--run'
| 'filter--editable'
| 'filter--readonly'
| 'filter--run';
export type WorkflowDiagramEdgeType = 'blank' | 'editable' | 'readonly' | 'run';
@@ -124,7 +124,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step1",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -141,7 +141,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step2",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -158,7 +158,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step3",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
],
@@ -361,7 +361,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step1",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -378,7 +378,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step2",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -395,7 +395,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step3",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
],
@@ -598,7 +598,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step1",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -615,7 +615,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step2",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -632,7 +632,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step3",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
],
@@ -854,7 +854,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step1",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -871,7 +871,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step2",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -888,7 +888,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step3",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
{
@@ -905,7 +905,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step4",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
],
@@ -1093,7 +1093,7 @@ describe('generateWorkflowRunDiagram', () => {
"sourceHandle": "default",
"target": "step1",
"targetHandle": "default",
"type": "empty-filter--run",
"type": "run",
"zIndex": -2,
},
],
@@ -164,7 +164,7 @@ describe('getWorkflowVersionDiagram', () => {
"sourceHandle": "default",
"target": "step-1",
"targetHandle": "default",
"type": "empty-filter--editable",
"type": "editable",
"zIndex": -2,
},
],
@@ -1,511 +0,0 @@
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { transformFilterNodesAsEdges } from '../transformFilterNodesAsEdges';
describe('transformFilterNodesAsEdges', () => {
it('should return the original diagram when there are no filter nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 0 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'A-C',
source: 'A',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
data: {
edgeType: 'default',
},
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext: 'workflow',
});
expect(result.nodes).toEqual(diagram.nodes);
expect(result.edges).toEqual(diagram.edges);
});
it('should transform A->B->C where B is a FILTER step', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'A-B',
source: 'A',
sourceHandle: 'default',
target: 'B',
targetHandle: 'default',
data: { edgeType: 'default' },
},
{
id: 'B-C',
source: 'B',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext: 'workflow',
});
// Should only have nodes A and C
expect(result.nodes).toEqual([
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
]);
// Should have one edge with filter data
expect(result.edges).toHaveLength(1);
expect(result.edges[0]).toEqual({
id: 'A-C-filter-B',
type: 'filter--editable',
source: 'A',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
stepId: 'B',
name: 'Filter B',
runStatus: undefined,
filterSettings: {},
},
});
});
it('should handle multiple filter nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B1',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B1',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B1',
},
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
{
id: 'B2',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B2',
hasNextStepIds: true,
position: { x: 0, y: 450 },
stepId: 'B2',
},
position: { x: 0, y: 450 },
},
{
id: 'D',
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Step D',
hasNextStepIds: true,
position: { x: 0, y: 600 },
stepId: 'D',
},
position: { x: 0, y: 600 },
},
],
edges: [
{
id: 'A-B1',
source: 'A',
sourceHandle: 'default',
target: 'B1',
targetHandle: 'default',
data: { edgeType: 'default' },
},
{
id: 'B1-C',
source: 'B1',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
data: { edgeType: 'default' },
},
{
id: 'C-B2',
source: 'C',
sourceHandle: 'default',
target: 'B2',
targetHandle: 'default',
data: { edgeType: 'default' },
},
{
id: 'B2-D',
source: 'B2',
sourceHandle: 'default',
target: 'D',
targetHandle: 'default',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext: 'workflow',
});
// Should only have nodes A, C, and D
expect(result.nodes).toHaveLength(3);
expect(result.nodes.map((n) => n.id)).toEqual(
expect.arrayContaining(['A', 'C', 'D']),
);
// Should have two edges with filter data
expect(result.edges).toHaveLength(2);
const edgeAC = result.edges.find(
(e) => e.source === 'A' && e.target === 'C',
);
expect(edgeAC).toEqual({
id: 'A-C-filter-B1',
type: 'filter--editable',
source: 'A',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B1',
runStatus: undefined,
stepId: 'B1',
filterSettings: {},
},
});
const edgeCD = result.edges.find(
(e) => e.source === 'C' && e.target === 'D',
);
expect(edgeCD).toEqual({
id: 'C-D-filter-B2',
type: 'filter--editable',
source: 'C',
sourceHandle: 'default',
target: 'D',
targetHandle: 'default',
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B2',
runStatus: undefined,
stepId: 'B2',
filterSettings: {},
},
});
});
it('should handle filter nodes that are not part of a chain', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
],
edges: [
{
id: 'A-B',
source: 'A',
sourceHandle: 'default',
target: 'B',
targetHandle: 'default',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext: 'workflow',
});
// Should only have node A (filter node B is removed)
expect(result.nodes).toEqual([
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
]);
// Should have no edges (original edge A-B is removed, no new edges created)
expect(result.edges).toEqual([]);
});
it('should preserve trigger nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'trigger',
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'trigger-B',
source: 'trigger',
sourceHandle: 'default',
target: 'B',
targetHandle: 'default',
data: { edgeType: 'default' },
},
{
id: 'B-C',
source: 'B',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext: 'workflow',
});
// Should have trigger and C nodes
expect(result.nodes).toEqual([
{
id: 'trigger',
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: true,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
]);
// Should have one edge with filter data
expect(result.edges).toEqual([
{
id: 'trigger-C-filter-B',
type: 'filter--editable',
source: 'trigger',
sourceHandle: 'default',
target: 'C',
targetHandle: 'default',
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B',
runStatus: undefined,
stepId: 'B',
filterSettings: {},
},
},
]);
});
});
@@ -4,14 +4,12 @@ import {
} from '@/workflow/types/Workflow';
import {
type WorkflowDiagramEdgeData,
type WorkflowDiagramEdgeType,
type WorkflowRunDiagram,
type WorkflowRunDiagramNode,
type WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
@@ -87,11 +85,9 @@ export const generateWorkflowRunDiagram = ({
const stepInfo = stepInfos?.[parentNode.id];
const edgeType: WorkflowDiagramEdgeType = 'empty-filter--run';
return {
...edge,
type: edgeType,
type: 'run',
data: {
...edge.data,
edgeType: 'default',
@@ -101,11 +97,10 @@ export const generateWorkflowRunDiagram = ({
});
return {
diagram: transformFilterNodesAsEdges({
diagram: {
nodes: workflowRunDiagramNodes,
edges: workflowRunDiagramEdges,
workflowContext: 'workflow-run',
}),
},
stepToOpenByDefault,
};
};
@@ -8,11 +8,11 @@ export const getEdgeTypeBetweenTwoNodes = ({
}): WorkflowDiagramEdgeType => {
switch (workflowContext) {
case 'workflow': {
return 'empty-filter--editable';
return 'editable';
}
case 'workflow-version':
case 'workflow-run': {
return 'empty-filter--readonly';
return 'readonly';
}
}
};
@@ -1,20 +0,0 @@
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import { type WorkflowDiagramEdgeType } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const getFilterEdgeType = ({
workflowContext,
}: {
workflowContext: WorkflowContext;
}): WorkflowDiagramEdgeType => {
switch (workflowContext) {
case 'workflow': {
return 'filter--editable';
}
case 'workflow-version': {
return 'filter--readonly';
}
case 'workflow-run': {
return 'filter--run';
}
}
};
@@ -41,8 +41,7 @@ export const getWorkflowDiagramColors = ({
},
};
}
case 'FAILED':
case 'STOPPED': {
case 'FAILED': {
return {
selected: {
background: theme.adaptiveColors.red1,
@@ -60,6 +59,7 @@ export const getWorkflowDiagramColors = ({
},
};
}
case 'STOPPED':
case 'SUCCESS': {
return {
selected: {
@@ -2,7 +2,6 @@ import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
const EMPTY_DIAGRAM: WorkflowDiagram = {
@@ -21,15 +20,9 @@ export const getWorkflowVersionDiagram = ({
return EMPTY_DIAGRAM;
}
const diagram = generateWorkflowDiagram({
return generateWorkflowDiagram({
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
workflowContext,
});
return transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
workflowContext,
});
};
@@ -1,95 +0,0 @@
import { type WorkflowContext } from '@/workflow/workflow-diagram/types/WorkflowContext';
import {
type WorkflowDiagramEdge,
type WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getFilterEdgeType } from '@/workflow/workflow-diagram/utils/getFilterEdgeType';
import { isDefined } from 'twenty-shared/utils';
export const transformFilterNodesAsEdges = <
T extends WorkflowDiagramNode,
U extends WorkflowDiagramEdge,
>({
nodes,
edges,
workflowContext,
}: {
nodes: T[];
edges: U[];
workflowContext: WorkflowContext;
}): { nodes: T[]; edges: U[] } => {
const filterNodes = nodes.filter(
(node) =>
node.data.nodeType === 'action' &&
'actionType' in node.data &&
node.data.actionType === 'FILTER',
);
if (filterNodes.length === 0) {
return { nodes, edges };
}
const resultNodes = nodes.filter(
(node) => !filterNodes.some((filterNode) => filterNode.id === node.id),
);
const resultEdges = [...edges];
const edgesToRemove = new Set<string>();
const edgesToAdd: typeof edges = [];
for (const filterNode of filterNodes) {
const incomingEdge = edges.find((edge) => edge.target === filterNode.id);
const outgoingEdge = edges.find((edge) => edge.source === filterNode.id);
if (isDefined(incomingEdge) && isDefined(outgoingEdge)) {
if (
filterNode.data.nodeType !== 'action' ||
filterNode.data.actionType !== 'FILTER'
) {
throw new Error('Expected the filter node to be of action type');
}
const filterEdgeType = getFilterEdgeType({ workflowContext });
const newEdge: U = {
...incomingEdge,
type: filterEdgeType,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target,
selectable: true,
deletable: true,
data: {
...incomingEdge.data,
edgeType: 'filter',
stepId: filterNode.id,
// TODO: Get the filter settings from the filter node
filterSettings: {},
name: filterNode.data.name,
runStatus: filterNode.data.runStatus,
},
};
edgesToAdd.push(newEdge);
edgesToRemove.add(incomingEdge.id);
edgesToRemove.add(outgoingEdge.id);
} else {
if (isDefined(incomingEdge)) {
edgesToRemove.add(incomingEdge.id);
}
if (isDefined(outgoingEdge)) {
edgesToRemove.add(outgoingEdge.id);
}
}
}
const finalEdges = [
...resultEdges.filter((edge) => !edgesToRemove.has(edge.id)),
...edgesToAdd,
];
return {
nodes: resultNodes,
edges: finalEdges,
};
};
@@ -1,6 +1,3 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { WorkflowDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBaseEdge';
import { WorkflowDiagramEdgeButtonGroup } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeButtonGroup';
@@ -12,14 +9,12 @@ import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workf
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps';
import { getConnectionOptionsForSourceHandle } from '@/workflow/workflow-diagram/workflow-edges/utils/getConnectionOptionsForSourceHandle';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useLingui } from '@lingui/react/macro';
import { EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { type MouseEvent, useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { type MouseEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter, IconPlus, IconTrash } from 'twenty-ui/display';
import { IconPlus, IconTrash } from 'twenty-ui/display';
type WorkflowDiagramDefaultEdgeEditableProps =
WorkflowDiagramEdgeComponentProps;
@@ -39,8 +34,6 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
}: WorkflowDiagramDefaultEdgeEditableProps) => {
const { i18n } = useLingui();
const { isInRightDrawer } = useContext(ActionMenuContext);
const { isEdgeHovered } = useEdgeState();
const [edgePath, labelX, labelY] = getBezierPath({
@@ -50,8 +43,6 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
targetY,
});
const { createStep } = useCreateStep();
const { deleteEdge } = useDeleteEdge();
const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation();
@@ -61,34 +52,6 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
nextStepId: target,
});
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const { openWorkflowEditFilterInCommandMenu } =
useOpenWorkflowEditFilterInCommandMenu();
const handleCreateFilter = async () => {
const createdStep = await createStep({
newStepType: 'FILTER',
parentStepId: source,
nextStepId: target,
});
if (!isDefined(createdStep)) {
return;
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
openWorkflowEditFilterInCommandMenu({
stepId: createdStep.id,
stepName: createdStep.name,
});
};
const handleNodeButtonClick = () => {
startNodeCreation({
parentStepId: source,
@@ -153,10 +116,6 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
>
<WorkflowDiagramEdgeButtonGroup
iconButtons={[
{
Icon: IconFilter,
onClick: handleCreateFilter,
},
{
Icon: IconPlus,
onClick: handleNodeButtonClick,
@@ -1,306 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramEdgeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
import { WorkflowDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBaseEdge';
import { WorkflowDiagramEdgeButtonGroup } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeButtonGroup';
import { WorkflowDiagramEdgeLabel } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabel';
import { WorkflowDiagramEdgeLabelContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeLabelContainer';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { WorkflowStepFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterCounter';
import { useFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useFilterCounter';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
IconDotsVertical,
IconFilter,
IconFilterX,
IconPlus,
IconTrash,
} from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
type WorkflowDiagramFilterEdgeEditableProps = WorkflowDiagramEdgeComponentProps;
const assertFilterEdgeDataOrThrow: (
data: WorkflowDiagramEdgeData | undefined,
) => asserts data is WorkflowDiagramEdgeData & { edgeType: 'filter' } = (
data: WorkflowDiagramEdgeData | undefined,
) => {
if (data?.edgeType !== 'filter') {
throw new Error('Edge data must be of type "filter"');
}
};
const StyledConfiguredFilterContainer = styled.div`
height: 26px;
width: 26px;
position: relative;
`;
const StyledIconButtonGroup = styled(IconButtonGroup)<{ selected?: boolean }>`
pointer-events: all;
${({ selected, theme }) => {
if (!selected) return '';
const colors = getWorkflowDiagramColors({ theme });
return css`
background-color: ${colors.selected.background};
border: 1px solid ${colors.selected.borderColor};
`;
}}
`;
export const WorkflowDiagramFilterEdgeEditable = ({
source,
sourceHandleId,
target,
targetHandleId,
sourceY,
sourceX,
targetY,
targetX,
markerStart,
markerEnd,
data,
}: WorkflowDiagramFilterEdgeEditableProps) => {
assertFilterEdgeDataOrThrow(data);
const { t, i18n } = useLingui();
const theme = useTheme();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
});
const { isInRightDrawer } = useContext(ActionMenuContext);
const { deleteStep } = useDeleteStep();
const { deleteEdge } = useDeleteEdge();
const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation();
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const { isEdgeHovered } = useEdgeState();
const setWorkflowDiagramPanOnDrag = useSetRecoilComponentState(
workflowDiagramPanOnDragComponentState,
);
const nodeCreationStarted = isNodeCreationStarted({
parentStepId: data.stepId,
nextStepId: target,
});
const workflowSelectedNode = useRecoilComponentValue(
workflowSelectedNodeComponentState,
);
const isFilterNodeSelected =
isNonEmptyString(data.stepId) && workflowSelectedNode === data.stepId;
const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${source}-${target}`;
const isDropdownOpen = useRecoilComponentValue(
isDropdownOpenComponentState,
dropdownId,
);
const { openWorkflowEditFilterInCommandMenu } =
useOpenWorkflowEditFilterInCommandMenu();
const handleFilterButtonClick = () => {
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
openWorkflowEditFilterInCommandMenu({
stepId: data.stepId,
stepName: data.name,
});
};
const handleRemoveFilterButtonClick = async () => {
closeDropdown(dropdownId);
await deleteStep(data.stepId);
};
const handleAddNodeButtonClick = () => {
closeDropdown(dropdownId);
startNodeCreation({
parentStepId: data.stepId,
nextStepId: target,
position: { x: labelX, y: labelY },
});
};
const handleDeleteBranchClick = async () => {
closeDropdown(dropdownId);
await deleteEdge({
source,
target,
});
};
const { filterCounter } = useFilterCounter({ stepId: data.stepId });
const { unselected } = getWorkflowDiagramColors({ theme });
return (
<>
<WorkflowDiagramBaseEdge
source={source}
sourceHandleId={sourceHandleId}
target={target}
targetHandleId={targetHandleId}
path={edgePath}
markerStart={markerStart}
markerEnd={markerEnd}
/>
<EdgeLabelRenderer>
{isDefined(data?.labelOptions) && (
<WorkflowDiagramEdgeLabelContainer
sourceX={sourceX}
sourceY={sourceY}
position={data.labelOptions.position}
>
<WorkflowDiagramEdgeLabel label={i18n._(data.labelOptions.label)} />
</WorkflowDiagramEdgeLabelContainer>
)}
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
>
<WorkflowDiagramEdgeV2VisibilityContainer shouldDisplay>
<StyledConfiguredFilterContainer>
<WorkflowStepFilterCounter
backgroundColor={unselected.tagBackground}
textColor={theme.font.color.inverted}
counter={filterCounter}
/>
{isEdgeHovered({
source,
target,
sourceHandle: sourceHandleId,
targetHandle: targetHandleId,
}) ||
isDropdownOpen ||
nodeCreationStarted ? (
<WorkflowDiagramEdgeButtonGroup
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
{
Icon: IconDotsVertical,
onClick: () => {
openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
},
]}
selected={isFilterNodeSelected}
/>
) : (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
]}
selected={isFilterNodeSelected}
/>
)}
</StyledConfiguredFilterContainer>
<Dropdown
dropdownId={dropdownId}
clickableComponent={<div></div>}
data-select-disable
dropdownPlacement="bottom-start"
dropdownStrategy="absolute"
dropdownOffset={{
x: 24,
y: 4,
}}
onOpen={() => {
setWorkflowDiagramPanOnDrag(false);
}}
onClose={() => {
setWorkflowDiagramPanOnDrag(true);
}}
dropdownComponents={
<DropdownContent
widthInPixels={GenericDropdownContentWidth.Narrow}
>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Remove filter`}
LeftIcon={IconFilterX}
onClick={handleRemoveFilterButtonClick}
/>
<MenuItem
text={t`Add Node`}
LeftIcon={IconPlus}
onClick={handleAddNodeButtonClick}
/>
<MenuItem
text={t`Delete branch`}
LeftIcon={IconTrash}
onClick={handleDeleteBranchClick}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</WorkflowDiagramEdgeV2VisibilityContainer>
</WorkflowDiagramEdgeV2Container>
</EdgeLabelRenderer>
</>
);
};
@@ -1,143 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useOpenWorkflowViewFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowViewFilterInCommandMenu';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramEdgeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps';
import { WorkflowStepFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterCounter';
import { useFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useFilterCounter';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconFilter } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
type WorkflowDiagramFilterEdgeReadonlyProps = WorkflowDiagramEdgeComponentProps;
const assertFilterEdgeDataOrThrow: (
data: WorkflowDiagramEdgeData | undefined,
) => asserts data is WorkflowDiagramEdgeData & { edgeType: 'filter' } = (
data: WorkflowDiagramEdgeData | undefined,
) => {
if (data?.edgeType !== 'filter') {
throw new Error('Edge data must be of type "filter"');
}
};
const StyledIconButtonGroup = styled(IconButtonGroup)<{ selected?: boolean }>`
pointer-events: all;
${({ selected, theme }) => {
if (!selected) return '';
const colors = getWorkflowDiagramColors({ theme });
return css`
background-color: ${colors.selected.background};
border: 1px solid ${colors.selected.borderColor};
`;
}}
`;
const StyledConfiguredFilterContainer = styled.div`
height: 26px;
width: 26px;
`;
export const WorkflowDiagramFilterEdgeReadonly = ({
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
data,
}: WorkflowDiagramFilterEdgeReadonlyProps) => {
assertFilterEdgeDataOrThrow(data);
const { isInRightDrawer } = useContext(ActionMenuContext);
const theme = useTheme();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
});
const workflowSelectedNode = useRecoilComponentValue(
workflowSelectedNodeComponentState,
);
const isFilterNodeSelected =
isNonEmptyString(data.stepId) && workflowSelectedNode === data.stepId;
const { openWorkflowViewFilterInCommandMenu } =
useOpenWorkflowViewFilterInCommandMenu();
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const handleFilterButtonClick = () => {
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
openWorkflowViewFilterInCommandMenu({
stepId: data.stepId,
stepName: data.name,
});
};
const { filterCounter } = useFilterCounter({
stepId: data.stepId,
});
const { unselected } = getWorkflowDiagramColors({ theme });
return (
<>
<BaseEdge
markerStart={markerStart}
markerEnd={markerEnd}
path={edgePath}
style={{ stroke: theme.border.color.strong }}
/>
<EdgeLabelRenderer>
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
>
<WorkflowDiagramEdgeV2VisibilityContainer shouldDisplay>
<StyledConfiguredFilterContainer>
<WorkflowStepFilterCounter
backgroundColor={unselected.tagBackground}
textColor={theme.font.color.inverted}
counter={filterCounter}
/>
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
]}
selected={isFilterNodeSelected}
/>
</StyledConfiguredFilterContainer>
</WorkflowDiagramEdgeV2VisibilityContainer>
</WorkflowDiagramEdgeV2Container>
</EdgeLabelRenderer>
</>
);
};
@@ -1,141 +0,0 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import type { WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { useOpenWorkflowRunFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowRunFilterInCommandMenu';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramEdgeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowRunDiagramBaseEdge';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { type WorkflowDiagramEdgeComponentProps } from '@/workflow/workflow-diagram/workflow-edges/types/WorkflowDiagramEdgeComponentProps';
import { WorkflowStepFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterCounter';
import { useFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useFilterCounter';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter } from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
type WorkflowDiagramFilterEdgeRunProps = WorkflowDiagramEdgeComponentProps;
const assertFilterEdgeDataOrThrow: (
data: WorkflowDiagramEdgeData | undefined,
) => asserts data is WorkflowDiagramEdgeData & { edgeType: 'filter' } = (
data: WorkflowDiagramEdgeData | undefined,
) => {
if (data?.edgeType !== 'filter') {
throw new Error('Edge data must be of type "filter"');
}
};
const StyledIconButtonGroup = styled(IconButtonGroup)<{
selected?: boolean;
runStatus?: WorkflowRunStepStatus;
}>`
pointer-events: all;
${({ selected, runStatus, theme }) => {
if (!selected) return '';
const colors = getWorkflowDiagramColors({ runStatus, theme });
return css`
background-color: ${colors.selected.background};
border: 1px solid ${colors.selected.borderColor};
`;
}}
`;
const StyledConfiguredFilterContainer = styled.div`
height: 26px;
width: 26px;
`;
export const WorkflowDiagramFilterEdgeRun = ({
sourceX,
sourceY,
targetX,
targetY,
data,
markerStart,
markerEnd,
}: WorkflowDiagramFilterEdgeRunProps) => {
assertFilterEdgeDataOrThrow(data);
const theme = useTheme();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
});
const workflowSelectedNode = useRecoilComponentValue(
workflowSelectedNodeComponentState,
);
const isFilterNodeSelected =
isNonEmptyString(data.stepId) && workflowSelectedNode === data.stepId;
const { openWorkflowRunFilterInCommandMenu } =
useOpenWorkflowRunFilterInCommandMenu();
const handleFilterButtonClick = () => {
if (!isDefined(data.runStatus)) {
throw new Error('Run status must be set on edge data for workflow runs');
}
openWorkflowRunFilterInCommandMenu({
stepId: data.stepId,
stepName: data.name,
stepExecutionStatus: data.runStatus,
});
};
const { filterCounter } = useFilterCounter({ stepId: data.stepId });
const { selected } = getWorkflowDiagramColors({
theme,
runStatus: data.runStatus,
});
return (
<>
<WorkflowRunDiagramBaseEdge
path={edgePath}
markerStart={markerStart}
markerEnd={markerEnd}
/>
<EdgeLabelRenderer>
<WorkflowDiagramEdgeV2Container
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
>
<WorkflowDiagramEdgeV2VisibilityContainer shouldDisplay>
<StyledConfiguredFilterContainer>
<WorkflowStepFilterCounter
backgroundColor={selected.tagBackground}
textColor={selected.color}
counter={filterCounter}
/>
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilter,
onClick: handleFilterButtonClick,
},
]}
selected={isFilterNodeSelected}
runStatus={data.runStatus}
/>
</StyledConfiguredFilterContainer>
</WorkflowDiagramEdgeV2VisibilityContainer>
</WorkflowDiagramEdgeV2Container>
</EdgeLabelRenderer>
</>
);
};
@@ -1,11 +1,11 @@
import { EDGE_BRANCH_ARROW_MARKER } from '@/workflow/workflow-diagram/workflow-edges/constants/EdgeBranchArrowMarker';
import {
type WorkflowDiagramEdge,
type WorkflowDiagramEdgeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { EDGE_BRANCH_ARROW_MARKER } from '@/workflow/workflow-diagram/workflow-edges/constants/EdgeBranchArrowMarker';
export const WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION = {
type: 'empty-filter--readonly' satisfies WorkflowDiagramEdgeType,
type: 'readonly' satisfies WorkflowDiagramEdgeType,
markerStart: undefined,
...EDGE_BRANCH_ARROW_MARKER.Default,
deletable: false,
@@ -122,7 +122,8 @@ export const WorkflowRunDiagramStepNode = ({
{capitalize(data.nodeType)}
</WorkflowNodeLabel>
{data.runStatus === StepStatus.SUCCESS && (
{(data.runStatus === StepStatus.SUCCESS ||
data.runStatus === StepStatus.STOPPED) && (
<StyledNodeCounter>
<StyledColorIcon color={theme.tag.background.turquoise}>
<IconCheck color={theme.tag.text.turquoise} size={14} />
@@ -1,8 +1,8 @@
import { type WorkflowActionType } from '@/workflow/types/Workflow';
export const OTHER_ACTIONS: Array<{
export const FLOW_ACTIONS: Array<{
label: string;
type: Extract<WorkflowActionType, 'ITERATOR'>;
type: Extract<WorkflowActionType, 'ITERATOR' | 'FILTER'>;
icon: string;
}> = [
{
@@ -10,4 +10,9 @@ export const OTHER_ACTIONS: Array<{
type: 'ITERATOR',
icon: 'IconRepeat',
},
{
label: 'Filter',
type: 'FILTER',
icon: 'IconFilter',
},
];
@@ -18,6 +18,7 @@ import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/Ste
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/getCurrentSubStepFromPath';
import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel';
import { getStepItemIcon } from '@/workflow/workflow-variables/utils/getStepItemIcon';
import { getVariableTemplateFromPath } from '@/workflow/workflow-variables/utils/getVariableTemplateFromPath';
import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2';
import { useLingui } from '@lingui/react/macro';
@@ -185,7 +186,7 @@ export const WorkflowDropdownStepOutputItems = ({
subStepObjectMetadataItem.labelSingular
.toLowerCase()
.includes(searchInputValue.toLowerCase())
: true;
: isDefined(displayedSubStepObject);
const objectLabel = subStepObjectMetadataItem?.labelSingular;
@@ -236,7 +237,15 @@ export const WorkflowDropdownStepOutputItems = ({
onClick={() => handleSelectField(key)}
text={subStep.label || key}
hasSubMenu={!subStep.isLeaf}
LeftIcon={subStep.icon ? getIcon(subStep.icon) : undefined}
LeftIcon={
subStep.icon
? getIcon(subStep.icon)
: getIcon(
getStepItemIcon({
itemType: subStep.type,
}),
)
}
contextualText={
subStep.isLeaf ? subStep?.value?.toString() : undefined
}
@@ -1,37 +0,0 @@
import styled from '@emotion/styled';
const StyledFilterCounter = styled.div<{
backgroundColor: string;
textColor: string;
}>`
align-items: center;
background: ${({ backgroundColor }) => backgroundColor};
border-radius: 50%;
color: ${({ textColor }) => textColor};
display: flex;
font-size: ${({ theme }) => theme.font.size.xxs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
height: 12px;
justify-content: center;
width: 12px;
left: -3px;
position: absolute;
top: -3px;
z-index: 1;
`;
type WorkflowStepFilterCounterProps = {
backgroundColor: string;
textColor: string;
counter: number;
};
export const WorkflowStepFilterCounter = ({
backgroundColor,
textColor,
counter,
}: WorkflowStepFilterCounterProps) => (
<StyledFilterCounter backgroundColor={backgroundColor} textColor={textColor}>
{counter}
</StyledFilterCounter>
);
@@ -1,28 +0,0 @@
import { WorkflowStepFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterCounter';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { THEME_LIGHT } from 'twenty-ui/theme';
const meta: Meta<typeof WorkflowStepFilterCounter> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowStepFilterCounter',
component: WorkflowStepFilterCounter,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterCounter>;
export const Default: Story = {
args: {
counter: 1,
backgroundColor: THEME_LIGHT.border.color.strong,
textColor: THEME_LIGHT.font.color.inverted,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('1')).toBeVisible();
},
};
@@ -93,11 +93,10 @@ export const WorkflowVariablesDropdownStepItems = ({
.includes(searchInputValue.toLowerCase())
: true;
const objectLabel = displayedSubStepObjectMetadata?.labelSingular;
const shouldDisplaySubStepObject =
shouldDisplayRecordObjects && isObjectFoundThroughSearch;
const objectLabel = displayedSubStepObjectMetadata?.labelSingular;
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
@@ -746,195 +746,5 @@ describe('WorkflowVersionEdgeWorkspaceService', () => {
});
});
});
describe('with filter steps', () => {
it('should delete the filter step when deleting edge from trigger to target through filter', async () => {
const mockStepsWithFilter = [
{
id: 'step-1',
type: WorkflowActionType.FORM,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'filter-step',
type: WorkflowActionType.FILTER,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2'],
},
] as WorkflowAction[];
const mockTriggerWithFilter = {
type: WorkflowTriggerType.MANUAL,
settings: {},
nextStepIds: ['step-1', 'filter-step'],
};
const mockWorkflowVersionWithFilter = {
id: mockWorkflowVersionId,
trigger: mockTriggerWithFilter,
steps: mockStepsWithFilter,
status: 'DRAFT',
} as WorkflowVersionWorkspaceEntity;
workflowCommonWorkspaceService.getWorkflowVersionOrFail.mockResolvedValue(
mockWorkflowVersionWithFilter,
);
const result = await service.deleteWorkflowVersionEdge({
source: TRIGGER_STEP_ID,
target: 'step-2',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
trigger: {
...mockTriggerWithFilter,
nextStepIds: ['step-1'],
},
steps: mockStepsWithFilter.filter(
(step) => step.id !== 'filter-step',
),
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
},
});
});
it('should delete the filter step when deleting edge from step to target through filter', async () => {
const mockStepsWithFilter = [
{
id: 'step-1',
type: WorkflowActionType.FORM,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-2', 'filter-step'],
},
{
id: 'step-2',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'step-3',
type: WorkflowActionType.SEND_EMAIL,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: [],
},
{
id: 'filter-step',
type: WorkflowActionType.FILTER,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
},
nextStepIds: ['step-3'],
},
] as WorkflowAction[];
const mockWorkflowVersionWithFilter = {
id: mockWorkflowVersionId,
trigger: mockTrigger,
steps: mockStepsWithFilter,
status: 'DRAFT',
} as WorkflowVersionWorkspaceEntity;
workflowCommonWorkspaceService.getWorkflowVersionOrFail.mockResolvedValue(
mockWorkflowVersionWithFilter,
);
const result = await service.deleteWorkflowVersionEdge({
source: 'step-1',
target: 'step-3',
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
workflowCommonWorkspaceService.getWorkflowVersionOrFail,
).toHaveBeenCalledWith({
workflowVersionId: mockWorkflowVersionId,
workspaceId: mockWorkspaceId,
});
expect(
mockWorkflowVersionWorkspaceRepository.update,
).toHaveBeenCalledWith(mockWorkflowVersionId, {
steps: mockStepsWithFilter
.map((step) => {
if (step.id === 'step-1') {
return {
...step,
nextStepIds: ['step-2'],
};
}
return step;
})
.filter((step) => step.id !== 'filter-step'),
});
expect(result).toEqual({
triggerNextStepIds: ['step-1'],
stepsNextStepIds: {
'step-1': ['step-2'],
'step-2': [],
'step-3': [],
},
});
});
});
});
});
@@ -222,6 +222,16 @@ export class WorkflowVersionEdgeWorkspaceService {
);
}
if (
sourceStep.nextStepIds?.includes(target) &&
!isDefined(sourceConnectionOptions)
) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const { updatedSourceStep, shouldPersist } = isDefined(
sourceConnectionOptions,
)
@@ -364,12 +374,9 @@ export class WorkflowVersionEdgeWorkspaceService {
}
if (!trigger.nextStepIds?.includes(target)) {
return this.handleFilterBetweenTriggerAndTarget({
return computeWorkflowVersionStepChanges({
trigger,
steps,
target,
workflowVersionId: workflowVersion.id,
workflowVersionRepository,
});
}
@@ -416,24 +423,13 @@ export class WorkflowVersionEdgeWorkspaceService {
);
}
// TODO: Remove this once we start using filters as regular steps
const isIteratorWithLoopTarget =
isDefined(sourceConnectionOptions) &&
sourceConnectionOptions.connectedStepType ===
WorkflowActionType.ITERATOR &&
sourceConnectionOptions.settings.isConnectedToLoop;
if (
!sourceStep.nextStepIds?.includes(target) &&
!isIteratorWithLoopTarget
!isDefined(sourceConnectionOptions)
) {
return await this.handleFilterBetweenSourceAndTarget({
return computeWorkflowVersionStepChanges({
trigger,
steps,
sourceStep,
target,
workflowVersionId: workflowVersion.id,
workflowVersionRepository,
});
}
@@ -480,109 +476,6 @@ export class WorkflowVersionEdgeWorkspaceService {
});
}
private async handleFilterBetweenTriggerAndTarget({
trigger,
steps,
target,
workflowVersionId,
workflowVersionRepository,
}: {
trigger: WorkflowTrigger;
steps: WorkflowAction[];
target: string;
workflowVersionId: string;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
}): Promise<WorkflowVersionStepChangesDTO> {
const filterBetweenTriggerAndTarget = this.findFilterBetweenNodes({
steps,
sourceNextStepIds: trigger.nextStepIds,
target,
});
if (!isDefined(filterBetweenTriggerAndTarget)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const updatedTrigger = {
...trigger,
nextStepIds: trigger.nextStepIds?.filter(
(nextStepId: string) => nextStepId !== filterBetweenTriggerAndTarget.id,
),
};
const updatedSteps = steps.filter(
(step) => step.id !== filterBetweenTriggerAndTarget.id,
);
await workflowVersionRepository.update(workflowVersionId, {
trigger: updatedTrigger,
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
trigger: updatedTrigger,
steps: updatedSteps,
});
}
private async handleFilterBetweenSourceAndTarget({
trigger,
steps,
sourceStep,
target,
workflowVersionRepository,
workflowVersionId,
}: {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[];
sourceStep: WorkflowAction;
target: string;
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>;
workflowVersionId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const filterBetweenSourceAndTarget = this.findFilterBetweenNodes({
steps,
sourceNextStepIds: sourceStep.nextStepIds,
target,
});
if (!isDefined(filterBetweenSourceAndTarget)) {
return computeWorkflowVersionStepChanges({
trigger,
steps,
});
}
const updatedSourceStep = {
...sourceStep,
nextStepIds: sourceStep.nextStepIds?.filter(
(nextStepId: string) => nextStepId !== filterBetweenSourceAndTarget.id,
),
};
const updatedSteps = steps
.map((step) => {
if (step.id === sourceStep.id) {
return updatedSourceStep;
}
return step;
})
.filter((step) => step.id !== filterBetweenSourceAndTarget.id);
await workflowVersionRepository.update(workflowVersionId, {
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
trigger,
steps: updatedSteps,
});
}
private buildUpdatedSourceStepWithOptions({
sourceStep,
target,
@@ -650,22 +543,4 @@ export class WorkflowVersionEdgeWorkspaceService {
};
}
}
private findFilterBetweenNodes({
steps,
sourceNextStepIds,
target,
}: {
steps: WorkflowAction[];
sourceNextStepIds: string[] | undefined;
target: string;
}) {
const nextStepFilters = steps.filter(
(step) =>
sourceNextStepIds?.includes(step.id) &&
step.type === WorkflowActionType.FILTER,
);
return nextStepFilters.find((step) => step.nextStepIds?.includes(target));
}
}
@@ -151,100 +151,6 @@ describe('removeStep', () => {
});
});
it('should remove step child that is a filter', () => {
const step1 = createMockAction('1', ['2']);
const step2 = createMockAction('2', ['3']);
const step3 = {
id: '3',
name: 'Step 3',
type: WorkflowActionType.FILTER,
nextStepIds: ['4'],
} as WorkflowAction;
const step4 = createMockAction('4');
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, step2, step3, step4],
stepIdToDelete: '2',
stepToDeleteChildrenIds: ['3'],
});
expect(result.updatedTrigger).toEqual(mockTrigger);
expect(result.updatedSteps).toEqual([
{ ...step1, nextStepIds: ['4'] },
step4,
]);
});
it('should remove trigger children that is a filter', () => {
const step1 = {
id: '1',
name: 'Step 1',
type: WorkflowActionType.FILTER,
nextStepIds: ['2'],
} as WorkflowAction;
const step2 = createMockAction('2', ['3']);
const step3 = createMockAction('3');
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, step2, step3],
stepIdToDelete: TRIGGER_STEP_ID,
stepToDeleteChildrenIds: ['1'],
});
expect(result.updatedTrigger).toEqual(null);
expect(result.updatedSteps).toEqual([step2, step3]);
});
it('should remove filter step if it has no children', () => {
const step1 = {
id: '1',
name: 'Step 1',
type: WorkflowActionType.FILTER,
nextStepIds: ['2'],
} as WorkflowAction;
const step2 = createMockAction('2', ['3']);
const step3 = {
id: '3',
name: 'Step 3',
type: WorkflowActionType.FILTER,
nextStepIds: ['4'],
} as WorkflowAction;
const step4 = createMockAction('4');
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, step2, step3, step4],
stepIdToDelete: '4',
});
expect(result.updatedTrigger).toEqual(mockTrigger);
expect(result.updatedSteps).toEqual([step1, { ...step2, nextStepIds: [] }]);
});
it('should remove filter step if it is the last step', () => {
const step1 = {
id: '1',
name: 'Step 1',
type: WorkflowActionType.FILTER,
nextStepIds: ['2'],
} as WorkflowAction;
const step2 = createMockAction('2');
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, step2],
stepIdToDelete: '2',
});
expect(result.updatedTrigger).toEqual({
...mockTrigger,
nextStepIds: [],
});
expect(result.updatedSteps).toEqual([]);
});
it('should remove trigger if steps are null', () => {
const result = removeStep({
existingTrigger: { ...mockTrigger, nextStepIds: [] },
@@ -253,7 +159,7 @@ describe('removeStep', () => {
});
expect(result.updatedTrigger).toEqual(null);
expect(result.updatedSteps).toEqual([]);
expect(result.updatedSteps).toEqual(null);
});
it('should handle removing a step that is part of iteratorLoopStepIds', () => {
@@ -29,7 +29,7 @@ const computeUpdatedNextStepIds = ({
];
};
const removeOneStep = ({
export const removeStep = ({
existingTrigger,
existingSteps,
stepIdToDelete,
@@ -39,11 +39,15 @@ const removeOneStep = ({
existingSteps: WorkflowAction[] | null;
stepIdToDelete: string;
stepToDeleteChildrenIds?: string[];
}): {
updatedSteps: WorkflowAction[];
updatedTrigger: WorkflowTrigger | null;
removedStepIds: string[];
} => {
}) => {
if (stepIdToDelete === TRIGGER_STEP_ID) {
return {
updatedSteps: existingSteps,
updatedTrigger: null,
removedStepIds: [TRIGGER_STEP_ID],
};
}
const updatedSteps =
existingSteps
?.filter((step) => step.id !== stepIdToDelete)
@@ -104,131 +108,3 @@ const removeOneStep = ({
removedStepIds: [stepIdToDelete],
};
};
const removeRegularStep = ({
existingTrigger,
existingSteps,
stepIdToDelete,
stepToDeleteChildrenIds,
}: {
existingTrigger: WorkflowTrigger | null;
existingSteps: WorkflowAction[] | null;
stepIdToDelete: string;
stepToDeleteChildrenIds?: string[];
}): {
updatedSteps: WorkflowAction[];
updatedTrigger: WorkflowTrigger | null;
removedStepIds: string[];
} => {
let { updatedSteps, updatedTrigger, removedStepIds } = removeOneStep({
existingTrigger,
existingSteps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
for (const stepId of stepToDeleteChildrenIds ?? []) {
const step = existingSteps?.find((step) => step.id === stepId);
if (step?.type === WorkflowActionType.FILTER) {
const {
updatedSteps: stepsAfterRemovingChildFilter,
updatedTrigger: triggerAfterRemovingChildFilter,
removedStepIds: removedStepIdsAfterRemovingChildFilter,
} = removeOneStep({
existingTrigger: updatedTrigger,
existingSteps: updatedSteps,
stepIdToDelete: stepId,
stepToDeleteChildrenIds: step.nextStepIds,
});
updatedSteps = stepsAfterRemovingChildFilter;
updatedTrigger = triggerAfterRemovingChildFilter;
removedStepIds = [
...removedStepIds,
...removedStepIdsAfterRemovingChildFilter,
];
}
}
for (const step of updatedSteps) {
if (
step?.type === WorkflowActionType.FILTER &&
(!isDefined(step?.nextStepIds) || step.nextStepIds?.length === 0)
) {
const {
updatedSteps: stepsAfterRemovingFilterWithoutChildren,
updatedTrigger: triggerAfterRemovingFilterWithoutChildren,
removedStepIds: removedStepIdsAfterRemovingFilterWithoutChildren,
} = removeOneStep({
existingTrigger: updatedTrigger,
existingSteps: updatedSteps,
stepIdToDelete: step.id,
stepToDeleteChildrenIds: step.nextStepIds,
});
updatedSteps = stepsAfterRemovingFilterWithoutChildren;
updatedTrigger = triggerAfterRemovingFilterWithoutChildren;
removedStepIds = [
...removedStepIds,
...removedStepIdsAfterRemovingFilterWithoutChildren,
];
}
}
return {
updatedSteps,
updatedTrigger,
removedStepIds,
};
};
const removeTrigger = ({
existingSteps,
triggerChildrenIds,
}: {
existingSteps: WorkflowAction[] | null;
triggerChildrenIds?: string[];
}) => {
const stepIdsToRemove =
triggerChildrenIds?.filter((id) => {
const step = existingSteps?.find((step) => step.id === id);
return step?.type === WorkflowActionType.FILTER;
}) ?? [];
const updatedSteps =
existingSteps?.filter((step) => !stepIdsToRemove.includes(step.id)) ?? [];
return {
updatedSteps,
updatedTrigger: null,
removedStepIds: [TRIGGER_STEP_ID, ...stepIdsToRemove],
};
};
export const removeStep = ({
existingTrigger,
existingSteps,
stepIdToDelete,
stepToDeleteChildrenIds,
}: {
existingTrigger: WorkflowTrigger | null;
existingSteps: WorkflowAction[] | null;
stepIdToDelete: string;
stepToDeleteChildrenIds?: string[];
}) => {
if (stepIdToDelete === TRIGGER_STEP_ID) {
return removeTrigger({
existingSteps,
triggerChildrenIds: stepToDeleteChildrenIds,
});
} else {
return removeRegularStep({
existingTrigger,
existingSteps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
}
};