Allow deleting any branch via keyboard (#13897)

The heart of the fixing is making edges selectable and deletable in
`packages/twenty-front/src/modules/workflow/workflow-diagram/utils/transformFilterNodesAsEdges.ts`.

## When Branches are enabled


https://github.com/user-attachments/assets/7477c556-d593-4235-8312-e830847192ca

## When Branches are disabled


https://github.com/user-attachments/assets/445c98b7-2370-4d60-b2e8-e47b05e8d8a2
This commit is contained in:
Baptiste Devessier
2025-08-13 15:06:42 +02:00
committed by GitHub
parent dafb43be6d
commit 36dc6cbcb8
8 changed files with 142 additions and 17 deletions
@@ -30,6 +30,9 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
const isWorkflowFilteringEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_FILTERING_ENABLED,
);
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const runWorkflowRunOpeningInCommandMenuSideEffects = useRecoilCallback(
({ snapshot, set }) =>
@@ -63,6 +66,7 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
stepInfos: workflowRunRecord.state.stepInfos,
trigger: workflowRunRecord.state.flow.trigger,
isWorkflowFilteringEnabled,
isWorkflowBranchEnabled,
});
if (!isDefined(stepToOpenByDefault)) {
@@ -133,6 +137,7 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
apolloCoreClient.cache,
objectPermissionsByObjectMetadataId,
isWorkflowFilteringEnabled,
isWorkflowBranchEnabled,
openWorkflowRunViewStepInCommandMenu,
getIcon,
],
@@ -38,6 +38,7 @@ import {
type NodeChange,
type NodeProps,
type OnBeforeDelete,
type OnDelete,
type OnNodeDrag,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
@@ -417,18 +418,30 @@ export const WorkflowDiagramCanvasBase = ({
const onBeforeDelete: OnBeforeDelete<
WorkflowDiagramNode,
WorkflowDiagramEdge
> = async (diagram) => {
if (
diagram.nodes.length === 0 // We don't call deleteEdge when node diagram deletion is called
) {
for (const edge of diagram.edges) {
onDeleteEdge?.(edge);
}
return diagram;
> = async ({ nodes, edges }) => {
if (!isWorkflowBranchEnabled) {
return false;
}
if (nodes.length === 0 && edges.length > 0) {
return true;
}
return false;
};
const onDelete: OnDelete<WorkflowDiagramNode, WorkflowDiagramEdge> = async ({
edges,
}) => {
if (!isWorkflowBranchEnabled || !isDefined(onDeleteEdge)) {
return;
}
for (const edge of edges) {
onDeleteEdge(edge);
}
};
const onPaneContextMenu = useCallback(
(event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => {
event.preventDefault();
@@ -474,9 +487,8 @@ export const WorkflowDiagramCanvasBase = ({
onEdgesChange={handleEdgesChange}
onConnect={isWorkflowBranchEnabled ? onConnect : undefined}
onNodeDragStop={isWorkflowBranchEnabled ? onNodeDragStop : undefined}
onBeforeDelete={
isWorkflowBranchEnabled ? onBeforeDelete : async () => false
}
onBeforeDelete={onBeforeDelete}
onDelete={onDelete}
selectNodesOnDrag={false}
proOptions={{ hideAttribution: true }}
multiSelectionKeyCode={null}
@@ -68,6 +68,9 @@ export const WorkflowRunVisualizerEffect = ({
const isWorkflowFilteringEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_FILTERING_ENABLED,
);
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
useEffect(() => {
setWorkflowRunId(workflowRunId);
@@ -120,6 +123,7 @@ export const WorkflowRunVisualizerEffect = ({
steps: workflowRunState.flow.steps,
stepInfos: workflowRunState.stepInfos,
isWorkflowFilteringEnabled,
isWorkflowBranchEnabled,
});
if (workflowDiagramStatus !== 'done') {
@@ -190,6 +194,7 @@ export const WorkflowRunVisualizerEffect = ({
[
flowState,
getIcon,
isWorkflowBranchEnabled,
isWorkflowFilteringEnabled,
openWorkflowRunViewStepInCommandMenu,
workflowDiagramState,
@@ -105,6 +105,7 @@ describe('generateWorkflowRunDiagram', () => {
steps,
stepInfos,
isWorkflowFilteringEnabled: true,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -334,6 +335,7 @@ describe('generateWorkflowRunDiagram', () => {
steps,
stepInfos,
isWorkflowFilteringEnabled: true,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -563,6 +565,7 @@ describe('generateWorkflowRunDiagram', () => {
steps,
stepInfos,
isWorkflowFilteringEnabled: true,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -811,6 +814,7 @@ describe('generateWorkflowRunDiagram', () => {
steps,
stepInfos,
isWorkflowFilteringEnabled: true,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -1039,6 +1043,7 @@ describe('generateWorkflowRunDiagram', () => {
steps,
stepInfos,
isWorkflowFilteringEnabled: true,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -46,6 +46,7 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
expect(result.nodes).toEqual(diagram.nodes);
@@ -112,6 +113,7 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have nodes A and C
@@ -149,7 +151,8 @@ describe('transformFilterNodesAsEdges', () => {
type: 'filter--editable',
source: 'A',
target: 'C',
deletable: false,
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
stepId: 'B',
@@ -256,6 +259,7 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have nodes A, C, and D
@@ -275,7 +279,8 @@ describe('transformFilterNodesAsEdges', () => {
type: 'filter--editable',
source: 'A',
target: 'C',
deletable: false,
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B1',
@@ -293,7 +298,8 @@ describe('transformFilterNodesAsEdges', () => {
type: 'filter--editable',
source: 'C',
target: 'D',
deletable: false,
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B2',
@@ -346,6 +352,7 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have node A (filter node B is removed)
@@ -428,6 +435,7 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should have trigger and C nodes
@@ -465,7 +473,8 @@ describe('transformFilterNodesAsEdges', () => {
type: 'filter--editable',
source: 'trigger',
target: 'C',
deletable: false,
selectable: true,
deletable: true,
data: {
edgeType: 'filter',
name: 'Filter B',
@@ -476,4 +485,86 @@ describe('transformFilterNodesAsEdges', () => {
},
]);
});
it('should set selectable and deletable to false when isWorkflowBranchEnabled is false', () => {
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',
target: 'B',
data: { edgeType: 'default' },
},
{
id: 'B-C',
source: 'B',
target: 'C',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: false,
});
// 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',
target: 'C',
selectable: false,
deletable: false,
data: {
edgeType: 'filter',
stepId: 'B',
name: 'Filter B',
runStatus: undefined,
filterSettings: {},
},
});
});
});
@@ -20,11 +20,13 @@ export const generateWorkflowRunDiagram = ({
steps,
stepInfos,
isWorkflowFilteringEnabled,
isWorkflowBranchEnabled,
}: {
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
stepInfos: WorkflowRunStepInfos | undefined;
isWorkflowFilteringEnabled: boolean;
isWorkflowBranchEnabled: boolean;
}): {
diagram: WorkflowRunDiagram;
stepToOpenByDefault:
@@ -119,6 +121,7 @@ export const generateWorkflowRunDiagram = ({
nodes: workflowRunDiagramNodes,
edges: workflowRunDiagramEdges,
defaultFilterEdgeType: 'filter--run',
isWorkflowBranchEnabled,
}),
stepToOpenByDefault,
};
@@ -4,8 +4,8 @@ import {
type WorkflowDiagramEdgeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { isDefined } from 'twenty-shared/utils';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
@@ -57,5 +57,6 @@ export const getWorkflowVersionDiagram = ({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: isEditable ? 'filter--editable' : 'filter--readonly',
isWorkflowBranchEnabled: isWorkflowBranchEnabled === true,
});
};
@@ -12,10 +12,12 @@ export const transformFilterNodesAsEdges = <
nodes,
edges,
defaultFilterEdgeType,
isWorkflowBranchEnabled,
}: {
nodes: T[];
edges: U[];
defaultFilterEdgeType: WorkflowDiagramEdgeType;
isWorkflowBranchEnabled: boolean;
}): { nodes: T[]; edges: U[] } => {
const filterNodes = nodes.filter(
(node) =>
@@ -53,7 +55,8 @@ export const transformFilterNodesAsEdges = <
type: defaultFilterEdgeType,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target,
deletable: false,
selectable: isWorkflowBranchEnabled === true,
deletable: isWorkflowBranchEnabled === true,
data: {
...incomingEdge.data,
edgeType: 'filter',