feat(editor): Automatically tidy up workflows (#13471)

This commit is contained in:
Elias Meire 2025-02-28 15:28:55 +01:00 committed by GitHub
parent aea2e79bf2
commit f381a24145
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1139 additions and 42 deletions

View file

@ -356,5 +356,5 @@ export function openContextMenu(
}
export function clickContextMenuAction(action: string) {
getContextMenuAction(action).click();
getContextMenuAction(action).click({ force: true });
}

View file

@ -14,16 +14,16 @@ const keys = computed(() => {
allKeys.unshift('⌘');
}
if (props.shiftKey) {
allKeys.unshift('');
if (props.metaKey && !isMacOs) {
allKeys.unshift('Ctrl');
}
if (props.altKey) {
allKeys.unshift(isMacOs ? '⌥' : 'Alt');
}
if (props.metaKey && !isMacOs) {
allKeys.unshift('Ctrl');
if (props.shiftKey) {
allKeys.unshift('');
}
return allKeys;

View file

@ -29,6 +29,7 @@
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
"@dagrejs/dagre": "^1.1.4",
"@fontsource/open-sans": "^4.5.0",
"@lezer/common": "1.1.0",
"@n8n/api-types": "workspace:*",

View file

@ -12,9 +12,9 @@ import type {
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
import type { GraphEdge, GraphNode, ViewportTransform } from '@vue-flow/core';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type { ViewportTransform } from '@vue-flow/core';
export function createCanvasNodeData({
id = 'node',
@ -69,6 +69,35 @@ export function createCanvasNodeElement({
};
}
export function createCanvasGraphNode({
id = '1',
type = 'default',
label = 'Node',
position = { x: 100, y: 100 },
dimensions = { width: 100, height: 100 },
data,
...rest
}: Partial<
Omit<GraphNode<CanvasNodeData>, 'data'> & { data: Partial<CanvasNodeData> }
> = {}): GraphNode<CanvasNodeData> {
return {
id,
type,
label,
position,
computedPosition: { ...position, z: 0 },
dimensions,
dragging: false,
isParent: false,
selected: false,
resizing: false,
handleBounds: {},
events: {},
data: createCanvasNodeData({ id, type, ...data }),
...rest,
};
}
export function createCanvasNodeProps({
id = 'node',
label = 'Test Node',
@ -196,3 +225,30 @@ export function createCanvasConnection(
...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}),
};
}
export function createCanvasGraphEdge(
nodeA: GraphNode,
nodeB: GraphNode,
{ sourceIndex = 0, targetIndex = 0 } = {},
): GraphEdge {
const nodeAOutput = nodeA.data?.outputs[sourceIndex];
const nodeBInput = nodeA.data?.inputs[targetIndex];
return {
id: `${nodeA.id}-${nodeB.id}`,
source: nodeA.id,
target: nodeB.id,
sourceX: nodeA.position.x,
sourceY: nodeA.position.y,
targetX: nodeB.position.x,
targetY: nodeB.position.y,
type: 'default',
selected: false,
sourceNode: nodeA,
targetNode: nodeB,
data: {},
events: {},
...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}),
...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}),
};
}

View file

@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path
fill="currentColor"
d="M1.6.13c-.18-.17-.47-.18-.62 0L.56.57.14.98c-.2.15-.18.44 0 .62l3.63 3.6c.1.1.1.27 0 .37-.2.2-.53.52-.93.94-.56.57-.12 1.62.22 2.11.05.07.12.1.2.1.05-.01.1-.04.15-.08l5.23-5.22c.1-.1.1-.26-.02-.34-.5-.34-1.55-.78-2.12-.22-.42.4-.75.73-.94.93-.1.1-.27.1-.37 0L1.6.13ZM9.5 3.9c.07-.09.2-.1.3-.04l6.07 3.44c.15.08.18.29.05.4l-1.21 1.22a.26.26 0 0 1-.26.07l-2.18-.64a.26.26 0 0 0-.32.33l.76 2.02c.04.1.01.2-.06.27L7.7 15.92a.26.26 0 0 1-.41-.05L3.83 9.8a.26.26 0 0 1 .04-.3l5.62-5.6Z"
/>
</svg>
</template>

View file

@ -18,7 +18,17 @@ import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, watch } from 'vue';
import {
computed,
nextTick,
onMounted,
onUnmounted,
provide,
ref,
toRef,
useCssModule,
watch,
} from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
@ -37,6 +47,7 @@ import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
import { useCanvasLayout } from '@/composables/useCanvasLayout';
const $style = useCssModule();
@ -139,6 +150,7 @@ const {
getDownstreamNodes,
getUpstreamNodes,
} = useCanvasTraversal(vueFlow);
const { layout } = useCanvasLayout({ id: props.id });
const isPaneReady = ref(false);
@ -245,38 +257,43 @@ function selectUpstreamNodes(id: string) {
onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] });
}
const keyMap = computed(() => ({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
// Support both key and code for zooming in and out
'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(),
'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(),
0: async () => await onResetZoom(),
1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode),
ArrowLeft: emitWithLastSelectedNode(selectLeftNode),
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
const keyMap = computed(() => {
const readOnlyKeymap = {
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
// Support both key and code for zooming in and out
'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(),
'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(),
0: async () => await onResetZoom(),
1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode),
ArrowLeft: emitWithLastSelectedNode(selectLeftNode),
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
};
...(props.readOnly
? {}
: {
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
}),
}));
if (props.readOnly) return readOnlyKeymap;
const fullKeymap = {
...readOnlyKeymap,
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
shift_alt_t: onTidyUp,
};
return fullKeymap;
});
useKeybindings(keyMap, { disabled: disableKeyBindings });
@ -589,7 +606,7 @@ function onOpenNodeContextMenu(
contextMenu.open(event, { source, nodeId: id });
}
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
switch (action) {
case 'add_node':
return emit('create:node', 'context_menu');
@ -617,6 +634,20 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
return emit('update:node:name', nodeIds[0]);
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up':
return await onTidyUp();
}
}
async function onTidyUp() {
const applyOnSelection = selectedNodes.value.length > 1;
const { nodes } = layout(applyOnSelection ? 'selection' : 'all');
onUpdateNodesPosition(nodes.map((node) => ({ id: node.id, position: { x: node.x, y: node.y } })));
if (!applyOnSelection) {
await nextTick();
await onFitView();
}
}
@ -840,6 +871,7 @@ provide(CanvasKey, {
@zoom-in="onZoomIn"
@zoom-out="onZoomOut"
@reset-zoom="onResetZoom"
@tidy-up="onTidyUp"
/>
<Suspense>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { Controls } from '@vue-flow/controls';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import TidyUpIcon from '@/components/TidyUpIcon.vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
@ -18,6 +19,7 @@ const emit = defineEmits<{
'zoom-in': [];
'zoom-out': [];
'zoom-to-fit': [];
'tidy-up': [];
}>();
const i18n = useI18n();
@ -39,6 +41,10 @@ function onZoomOut() {
function onZoomToFit() {
emit('zoom-to-fit');
}
function onTidyUp() {
emit('tidy-up');
}
</script>
<template>
<Controls :show-zoom="false" :show-fit-view="false">
@ -85,9 +91,37 @@ function onZoomToFit() {
@click="onResetZoom"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
:label="i18n.baseText('nodeView.tidyUp')"
:shortcut="{ shiftKey: true, altKey: true, keys: ['T'] }"
>
<N8nButton
square
type="tertiary"
size="large"
data-test-id="tidy-up-button"
:class="$style.iconButton"
@click="onTidyUp"
>
<TidyUpIcon />
</N8nButton>
</KeyboardShortcutTooltip>
</Controls>
</template>
<style module lang="scss">
.iconButton {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
}
}
</style>
<style lang="scss">
.vue-flow__controls {
display: flex;

View file

@ -20,6 +20,10 @@ exports[`CanvasControlButtons > should render correctly 1`] = `
</button>
<!--teleport start-->
<!--teleport end-->
<!--v-if-->
<!--v-if--><button class="button button tertiary large square iconButton el-tooltip__trigger el-tooltip__trigger iconButton el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="tidy-up-button">
<!--v-if--><span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M1.6.13c-.18-.17-.47-.18-.62 0L.56.57.14.98c-.2.15-.18.44 0 .62l3.63 3.6c.1.1.1.27 0 .37-.2.2-.53.52-.93.94-.56.57-.12 1.62.22 2.11.05.07.12.1.2.1.05-.01.1-.04.15-.08l5.23-5.22c.1-.1.1-.26-.02-.34-.5-.34-1.55-.78-2.12-.22-.42.4-.75.73-.94.93-.1.1-.27.1-.37 0L1.6.13ZM9.5 3.9c.07-.09.2-.1.3-.04l6.07 3.44c.15.08.18.29.05.4l-1.21 1.22a.26.26 0 0 1-.26.07l-2.18-.64a.26.26 0 0 0-.32.33l.76 2.02c.04.1.01.2-.06.27L7.7 15.92a.26.26 0 0 1-.41-.05L3.83 9.8a.26.26 0 0 1 .04-.3l5.62-5.6Z"></path></svg></span>
</button>
<!--teleport start-->
<!--teleport end-->
</div>"
`;

View file

@ -0,0 +1,171 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`useCanvasLayout > should layout a basic workflow 1`] = `
{
"boundingBox": {
"height": 100,
"width": 540,
"x": 0,
"y": 0,
},
"nodes": [
{
"id": "node1",
"x": 100,
"y": 100,
},
{
"id": "node2",
"x": 320,
"y": 100,
},
{
"id": "node3",
"x": 540,
"y": 100,
},
],
}
`;
exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`] = `
{
"boundingBox": {
"height": 100,
"width": 540,
"x": 0,
"y": 0,
},
"nodes": [
{
"id": "node1",
"x": 100,
"y": 100,
},
{
"id": "node2",
"x": 320,
"y": 100,
},
{
"id": "node3",
"x": 540,
"y": 100,
},
],
}
`;
exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
{
"boundingBox": {
"height": 540,
"width": 820,
"x": 0,
"y": 220,
},
"nodes": [
{
"id": "node1",
"x": 100,
"y": 100,
},
{
"id": "aiTool1",
"x": 320,
"y": 320,
},
{
"id": "aiTool2",
"x": 460,
"y": 320,
},
{
"id": "aiTool3",
"x": 600,
"y": 540,
},
{
"id": "aiAgent",
"x": 460,
"y": 100,
},
{
"id": "configurableAiTool",
"x": 600,
"y": 320,
},
{
"id": "node2",
"x": 820,
"y": 100,
},
],
}
`;
exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
{
"boundingBox": {
"height": 100,
"width": 760,
"x": 0,
"y": 0,
},
"nodes": [
{
"id": "node1",
"x": 0,
"y": 0,
},
{
"id": "node2",
"x": 220,
"y": 0,
},
{
"id": "node3",
"x": 440,
"y": 0,
},
{
"id": "node4",
"x": 660,
"y": 0,
},
{
"id": "sticky",
"x": 130,
"y": -240,
},
],
}
`;
exports[`useCanvasLayout > should not reorder nodes vertically as it affects execution order 1`] = `
{
"boundingBox": {
"height": 300,
"width": 320,
"x": 0,
"y": 0,
},
"nodes": [
{
"id": "node1",
"x": 0,
"y": -100,
},
{
"id": "node3",
"x": 220,
"y": -200,
},
{
"id": "node2",
"x": 220,
"y": 0,
},
],
}
`;

View file

@ -67,6 +67,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up workflow",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,
@ -136,6 +148,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up workflow",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,
@ -234,6 +258,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up workflow",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,
@ -332,6 +368,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up workflow",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,
@ -401,6 +449,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up workflow",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,
@ -475,6 +535,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl
"metaKey": true,
},
},
{
"divided": true,
"id": "tidy_up",
"label": "Tidy up selection",
"shortcut": {
"altKey": true,
"keys": [
"T",
],
"shiftKey": true,
},
},
{
"disabled": false,
"divided": true,

View file

@ -0,0 +1,170 @@
import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
import { ref } from 'vue';
import { createCanvasGraphEdge, createCanvasGraphNode } from '../__tests__/data';
import { CanvasNodeRenderType, type CanvasNodeData } from '../types';
import { useCanvasLayout, type LayoutResult } from './useCanvasLayout';
import { STICKY_NODE_TYPE } from '../constants';
import { GRID_SIZE } from '../utils/nodeViewUtils';
vi.mock('@vue-flow/core');
function matchesGrid(result: LayoutResult) {
return result.nodes.every((node) => node.x % GRID_SIZE === 0 && node.y % GRID_SIZE === 0);
}
describe('useCanvasLayout', () => {
function createTestSetup(
nodes: Array<GraphNode<CanvasNodeData>>,
connections: Array<[string, string]>,
selectedNodeIds?: string[],
) {
const nodesById = Object.fromEntries(nodes.map((node) => [node.id, node]));
const edges = connections.map(([sourceId, targetId]) =>
createCanvasGraphEdge(nodesById[sourceId], nodesById[targetId]),
);
const edgesById = Object.fromEntries(edges.map((edge) => [edge.id, edge]));
const selectedNodes = selectedNodeIds?.map((id) => nodesById[id]) ?? nodes;
const vueFlowStoreMock = {
nodes: ref(nodes),
edges: ref(edges),
getSelectedNodes: ref(selectedNodes),
findNode: (nodeId: string) => nodesById[nodeId],
findEdge: (edgeId: string) => edgesById[edgeId],
} as unknown as VueFlowStore;
vi.mocked(useVueFlow).mockReturnValue(vueFlowStoreMock);
const { layout } = useCanvasLayout();
return { layout };
}
test('should layout a basic workflow', () => {
const nodes = [
createCanvasGraphNode({ id: 'node1' }),
createCanvasGraphNode({ id: 'node2' }),
createCanvasGraphNode({ id: 'node3' }),
];
const connections: Array<[string, string]> = [
['node1', 'node2'],
['node2', 'node3'],
];
const { layout } = createTestSetup(nodes, connections);
const result = layout('all');
expect(result).toMatchSnapshot();
expect(matchesGrid(result)).toBe(true);
});
test('should layout a basic workflow with selected nodes', () => {
const nodes = [
createCanvasGraphNode({ id: 'node1' }),
createCanvasGraphNode({ id: 'node2' }),
createCanvasGraphNode({ id: 'node3' }),
createCanvasGraphNode({ id: 'node4' }),
];
const connections: Array<[string, string]> = [
['node1', 'node2'],
['node2', 'node3'],
['node3', 'node4'],
];
const { layout } = createTestSetup(nodes, connections, ['node1', 'node2', 'node3']);
const result = layout('selection');
expect(result).toMatchSnapshot();
expect(matchesGrid(result)).toBe(true);
});
test('should layout a workflow with AI nodes', () => {
const nodes = [
createCanvasGraphNode({ id: 'node1' }),
createCanvasGraphNode({
id: 'aiAgent',
data: { render: { type: CanvasNodeRenderType.Default, options: { configurable: true } } },
}),
createCanvasGraphNode({
id: 'aiTool1',
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
}),
createCanvasGraphNode({
id: 'aiTool2',
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
}),
createCanvasGraphNode({
id: 'configurableAiTool',
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configurable: true, configuration: true },
},
},
}),
createCanvasGraphNode({
id: 'aiTool3',
data: { render: { type: CanvasNodeRenderType.Default, options: { configuration: true } } },
}),
createCanvasGraphNode({ id: 'node2' }),
];
const connections: Array<[string, string]> = [
['node1', 'aiAgent'],
['aiTool1', 'aiAgent'],
['aiTool2', 'aiAgent'],
['configurableAiTool', 'aiAgent'],
['aiTool3', 'configurableAiTool'],
['aiAgent', 'node2'],
];
const { layout } = createTestSetup(nodes, connections);
const result = layout('all');
expect(result).toMatchSnapshot();
expect(matchesGrid(result)).toBe(true);
});
test('should layout a workflow with sticky notes', () => {
const nodes = [
createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }),
createCanvasGraphNode({ id: 'node2', position: { x: 500, y: 0 } }),
createCanvasGraphNode({ id: 'node3', position: { x: 700, y: 0 } }),
createCanvasGraphNode({ id: 'node4', position: { x: 1000, y: 0 } }),
createCanvasGraphNode({
id: 'sticky',
data: { type: STICKY_NODE_TYPE },
dimensions: { width: 500, height: 400 },
position: { x: 400, y: -100 },
}),
];
const connections: Array<[string, string]> = [
['node1', 'node2'],
['node2', 'node3'],
['node3', 'node4'],
];
const { layout } = createTestSetup(nodes, connections);
const result = layout('all');
expect(result).toMatchSnapshot();
});
test('should not reorder nodes vertically as it affects execution order', () => {
const nodes = [
createCanvasGraphNode({ id: 'node1', position: { x: 0, y: 0 } }),
createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 200 } }),
createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -200 } }),
];
const connections: Array<[string, string]> = [
['node1', 'node3'],
['node1', 'node2'],
];
const { layout } = createTestSetup(nodes, connections);
const result = layout('all');
expect(result).toMatchSnapshot();
expect(matchesGrid(result)).toBe(true);
});
});

View file

@ -0,0 +1,516 @@
import dagre from '@dagrejs/dagre';
import { useVueFlow, type GraphEdge, type GraphNode, type XYPosition } from '@vue-flow/core';
import { STICKY_NODE_TYPE } from '../constants';
import {
CanvasNodeRenderType,
type BoundingBox,
type CanvasConnection,
type CanvasNodeData,
} from '../types';
import { isPresent } from '../utils/typesUtils';
import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
export type CanvasLayoutOptions = { id?: string };
export type CanvasLayoutTarget = 'selection' | 'all';
export type CanvasLayoutTargetData = {
nodes: Array<GraphNode<CanvasNodeData>>;
edges: CanvasConnection[];
};
export type NodeLayoutResult = {
id: string;
x: number;
y: number;
width?: number;
height?: number;
};
export type LayoutResult = { boundingBox: BoundingBox; nodes: NodeLayoutResult[] };
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
const NODE_X_SPACING = GRID_SIZE * 6;
const NODE_Y_SPACING = GRID_SIZE * 5;
const SUBGRAPH_SPACING = GRID_SIZE * 8;
const AI_X_SPACING = GRID_SIZE * 2;
const AI_Y_SPACING = GRID_SIZE * 6;
const STICKY_BOTTOM_PADDING = GRID_SIZE * 3;
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
const {
findNode,
findEdge,
getSelectedNodes,
edges: allEdges,
nodes: allNodes,
} = useVueFlow({ id: canvasId });
function getTargetData(target: CanvasLayoutTarget): CanvasLayoutTargetData {
if (target === 'selection') {
return { nodes: getSelectedNodes.value, edges: allEdges.value };
}
return { nodes: allNodes.value, edges: allEdges.value };
}
function sortByPosition(posA: XYPosition, posB: XYPosition): number {
const yDiff = posA.y - posB.y;
return yDiff === 0 ? posA.x - posB.x : yDiff;
}
function sortNodesByPosition(nodeA: GraphNode, nodeB: GraphNode): number {
const hasEdgesA = allEdges.value.some((edge) => edge.target === nodeA.id);
const hasEdgesB = allEdges.value.some((edge) => edge.target === nodeB.id);
if (!hasEdgesA && hasEdgesB) return -1;
if (hasEdgesA && !hasEdgesB) return 1;
return sortByPosition(nodeA.position, nodeB.position);
}
function sortEdgesByPosition(edgeA: GraphEdge, edgeB: GraphEdge): number {
return sortByPosition(positionFromEdge(edgeA), positionFromEdge(edgeB));
}
function positionFromEdge(edge: GraphEdge): XYPosition {
return { x: edge.targetX, y: edge.targetY };
}
function createDagreGraph({ nodes, edges }: CanvasLayoutTargetData) {
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
const graphNodes = nodes
.map((node) => findNode<CanvasNodeData>(node.id))
.filter(isPresent)
.sort(sortNodesByPosition);
const nodeIdSet = new Set(nodes.map((node) => node.id));
graphNodes.forEach(({ id: nodeId, position: { x, y }, dimensions: { width, height } }) => {
graph.setNode(nodeId, { width, height, x, y });
});
edges
.map((node) => findEdge<CanvasNodeData>(node.id))
.filter(isPresent)
.filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target))
.sort(sortEdgesByPosition)
.forEach((edge) => graph.setEdge(edge.source, edge.target));
return graph;
}
function createDagreSubGraph({
nodeIds,
parent,
}: { nodeIds: string[]; parent: dagre.graphlib.Graph }) {
const subGraph = new dagre.graphlib.Graph();
subGraph.setGraph({
rankdir: 'LR',
edgesep: NODE_Y_SPACING,
nodesep: NODE_Y_SPACING,
ranksep: NODE_X_SPACING,
});
subGraph.setDefaultEdgeLabel(() => ({}));
const nodeIdSet = new Set(nodeIds);
parent
.nodes()
.filter((nodeId) => nodeIdSet.has(nodeId))
.forEach((nodeId) => {
subGraph.setNode(nodeId, parent.node(nodeId));
});
parent
.edges()
.filter((edge) => nodeIdSet.has(edge.v) && nodeIdSet.has(edge.w))
.forEach((edge) => subGraph.setEdge(edge.v, edge.w, parent.edge(edge)));
return subGraph;
}
function createDagreVerticalGraph({ nodes }: { nodes: Array<{ id: string; box: BoundingBox }> }) {
const subGraph = new dagre.graphlib.Graph();
subGraph.setGraph({
rankdir: 'TB',
align: 'UL',
edgesep: SUBGRAPH_SPACING,
nodesep: SUBGRAPH_SPACING,
ranksep: SUBGRAPH_SPACING,
});
subGraph.setDefaultEdgeLabel(() => ({}));
nodes.forEach(({ id, box: { x, y, width, height } }) =>
subGraph.setNode(id, { x, y, width, height }),
);
nodes.forEach((node, index) => {
if (!nodes[index + 1]) return;
subGraph.setEdge(node.id, nodes[index + 1].id);
});
return subGraph;
}
function createAiSubGraph({
parent,
nodeIds,
}: { parent: dagre.graphlib.Graph; nodeIds: string[] }) {
const subGraph = new dagre.graphlib.Graph();
subGraph.setGraph({
rankdir: 'TB',
edgesep: AI_X_SPACING,
nodesep: AI_X_SPACING,
ranksep: AI_Y_SPACING,
});
subGraph.setDefaultEdgeLabel(() => ({}));
const nodeIdSet = new Set(nodeIds);
parent
.nodes()
.filter((nodeId) => nodeIdSet.has(nodeId))
.forEach((nodeId) => {
subGraph.setNode(nodeId, parent.node(nodeId));
});
parent
.edges()
.filter((edge) => nodeIdSet.has(edge.v) && nodeIdSet.has(edge.w))
.forEach((edge) => subGraph.setEdge(edge.w, edge.v));
return subGraph;
}
// For a list of bounding boxes, return the bounding box that contains them all
function compositeBoundingBox(boxes: BoundingBox[]): BoundingBox {
const { minX, minY, maxX, maxY } = boxes.reduce(
(bbox, node) => {
const { x, y, width, height } = node;
return {
minX: Math.min(bbox.minX, x),
maxX: Math.max(bbox.maxX, x + width),
minY: Math.min(bbox.minY, y),
maxY: Math.max(bbox.maxY, y + height),
};
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function boundingBoxFromCanvasNode(node: GraphNode<CanvasNodeData>): BoundingBox {
return {
x: node.position.x,
y: node.position.y,
width: node.dimensions.width,
height: node.dimensions.height,
};
}
function boundingBoxFromDagreNode(node: dagre.Node): BoundingBox {
return {
x: node.x - node.width / 2,
y: node.y - node.height / 2,
width: node.width,
height: node.height,
};
}
function boundingBoxFromGraph(graph: dagre.graphlib.Graph): BoundingBox {
return compositeBoundingBox(
graph.nodes().map((nodeId) => boundingBoxFromDagreNode(graph.node(nodeId))),
);
}
function boundingBoxFromCanvasNodes(nodes: Array<GraphNode<CanvasNodeData>>): BoundingBox {
return compositeBoundingBox(nodes.map(boundingBoxFromCanvasNode));
}
// Is the `child` bounding box completely contained in the `parent` bounding box
function isCoveredBy(parent: BoundingBox, child: BoundingBox) {
const childRight = child.x + child.width;
const childBottom = child.y + child.height;
const parentRight = parent.x + parent.width;
const parentBottom = parent.y + parent.height;
return (
child.x >= parent.x &&
child.y >= parent.y &&
childRight <= parentRight &&
childBottom <= parentBottom
);
}
function centerHorizontally(container: BoundingBox, target: BoundingBox) {
const containerCenter = container.x + container.width / 2;
const newX = containerCenter - target.width / 2;
return newX;
}
function intersects(container: BoundingBox, target: BoundingBox, padding = 0): boolean {
// Add padding to target box dimensions
const targetWithPadding = {
x: target.x - padding,
y: target.y - padding,
width: target.width + padding * 2,
height: target.height + padding * 2,
};
const noIntersection =
targetWithPadding.x + targetWithPadding.width < container.x ||
targetWithPadding.x > container.x + container.width ||
targetWithPadding.y + targetWithPadding.height < container.y ||
targetWithPadding.y > container.y + container.height;
return !noIntersection;
}
function isAiParentNode(node: CanvasNodeData) {
return (
node.render.type === CanvasNodeRenderType.Default &&
node.render.options.configurable &&
!node.render.options.configuration
);
}
function isAiConfigNode(node: CanvasNodeData) {
return node.render.type === CanvasNodeRenderType.Default && node.render.options.configuration;
}
function getAllConnectedAiConfigNodes({
graph,
root,
nodeById,
}: {
graph: dagre.graphlib.Graph;
root: CanvasNodeData;
nodeById: CanvasNodeDictionary;
}): string[] {
return (graph.predecessors(root.id) as unknown as string[])
.map((successor) => nodeById[successor])
.filter((node) => isAiConfigNode(node.data))
.flatMap((node) => [
node.id,
...getAllConnectedAiConfigNodes({ graph, root: node.data, nodeById }),
]);
}
function layout(target: CanvasLayoutTarget): LayoutResult {
const { nodes, edges } = getTargetData(target);
const nonStickyNodes = nodes
.filter((node) => node.data.type !== STICKY_NODE_TYPE)
.map((node) => findNode(node.id))
.filter(isPresent);
const boundingBoxBefore = boundingBoxFromCanvasNodes(nonStickyNodes);
const parentGraph = createDagreGraph({ nodes: nonStickyNodes, edges });
const nodeById = nonStickyNodes.reduce((acc, node) => {
acc[node.id] = node;
return acc;
}, {} as CanvasNodeDictionary);
// Divide workflow in to subgraphs
// A subgraph contains a group of connected nodes that is not connected to any node outside of this group
const subgraphs = dagre.graphlib.alg.components(parentGraph).map((nodeIds) => {
const subgraph = createDagreSubGraph({ nodeIds, parent: parentGraph });
const aiParentNodes = subgraph
.nodes()
.map((nodeId) => nodeById[nodeId].data)
.filter(isAiParentNode);
// Create a subgraph for each AI (configurable) node and apply a top-bottom layout
// Then add the bounding box of this layout back into the parent graph before doing layout
const aiGraphs = aiParentNodes.map((aiParentNode) => {
const configNodeIds = getAllConnectedAiConfigNodes({
graph: subgraph,
nodeById,
root: aiParentNode,
});
const allAiNodeIds = configNodeIds.concat(aiParentNode.id);
const aiGraph = createAiSubGraph({
parent: subgraph,
nodeIds: allAiNodeIds,
});
configNodeIds.forEach((nodeId) => subgraph.removeNode(nodeId));
const rootEdges = subgraph
.edges()
.filter((edge) => edge.v === aiParentNode.id || edge.w === aiParentNode.id);
dagre.layout(aiGraph, { disableOptimalOrderHeuristic: true });
const aiBoundingBox = boundingBoxFromGraph(aiGraph);
subgraph.setNode(aiParentNode.id, {
width: aiBoundingBox.width,
height: aiBoundingBox.height,
});
rootEdges.forEach((edge) => subgraph.setEdge(edge));
return { graph: aiGraph, boundingBox: aiBoundingBox, aiParentNode };
});
dagre.layout(subgraph, { disableOptimalOrderHeuristic: true });
return { graph: subgraph, aiGraphs, boundingBox: boundingBoxFromGraph(subgraph) };
});
const compositeGraph = createDagreVerticalGraph({
nodes: subgraphs.map(({ boundingBox }, index) => ({
box: boundingBox,
id: index.toString(),
})),
});
dagre.layout(compositeGraph, { disableOptimalOrderHeuristic: true });
const boundingBoxByNodeId = subgraphs
.flatMap(({ graph, aiGraphs }, index) => {
const subgraphPosition = compositeGraph.node(index.toString());
const aiParentNodes = new Set(aiGraphs.map(({ aiParentNode }) => aiParentNode.id));
const offset = {
x: 0,
y: subgraphPosition.y - subgraphPosition.height / 2,
};
return graph.nodes().flatMap((nodeId) => {
const { x, y, width, height } = graph.node(nodeId);
const positionedNode = {
id: nodeId,
boundingBox: {
x: x + offset.x - width / 2,
y: y + offset.y - height / 2,
width,
height,
},
};
if (aiParentNodes.has(nodeId)) {
const aiGraph = aiGraphs.find(({ aiParentNode }) => aiParentNode.id === nodeId);
if (!aiGraph) return [];
const aiParentNodeBox = positionedNode.boundingBox;
const parentOffset = {
x: aiParentNodeBox.x,
y: aiParentNodeBox.y,
};
return aiGraph.graph.nodes().map((aiNodeId) => {
const aiNode = aiGraph.graph.node(aiNodeId);
const aiBoundingBox = {
x: aiNode.x + parentOffset.x - aiNode.width / 2,
y: aiNode.y + parentOffset.y - aiNode.height / 2,
width: aiNode.width,
height: aiNode.height,
};
return {
id: aiNodeId,
boundingBox: aiBoundingBox,
};
});
}
return positionedNode;
});
})
.reduce(
(acc, node) => {
acc[node.id] = node.boundingBox;
return acc;
},
{} as Record<string, BoundingBox>,
);
// Post process AI node vertical position
// The bounding box of the AI node sublayout is vertically centered with the other nodes, but we want it to be top-aligned when possible
// We need to be careful to only do this when it would not overlap with other nodes
subgraphs
.flatMap(({ aiGraphs }) => aiGraphs)
.forEach(({ graph }) => {
const aiNodes = graph.nodes();
const aiGraphBoundingBox = compositeBoundingBox(
aiNodes.map((nodeId) => boundingBoxByNodeId[nodeId]).filter(isPresent),
);
const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - NODE_SIZE / 2;
aiGraphBoundingBox.y += aiNodeVerticalCorrection;
const hasConflictingNodes = Object.entries(boundingBoxByNodeId)
.filter(([id]) => !graph.hasNode(id))
.some(([, nodeBoundingBox]) =>
intersects(aiGraphBoundingBox, nodeBoundingBox, NODE_Y_SPACING),
);
if (!hasConflictingNodes) {
for (const aiNode of aiNodes) {
boundingBoxByNodeId[aiNode].y += aiNodeVerticalCorrection;
}
}
});
const positionedNodes = Object.entries(boundingBoxByNodeId).map(([id, boundingBox]) => ({
id,
boundingBox,
}));
const boundingBoxAfter = compositeBoundingBox(positionedNodes.map((node) => node.boundingBox));
const anchor = {
x: boundingBoxAfter.x - boundingBoxBefore.x,
y: boundingBoxAfter.y - boundingBoxBefore.y,
};
const stickies = nodes
.filter((node) => node.data.type === STICKY_NODE_TYPE)
.map((node) => findNode(node.id))
.filter(isPresent);
const positionedStickies = stickies
.map((sticky) => {
const stickyBox = boundingBoxFromCanvasNode(sticky);
const coveredNodes = nonStickyNodes.filter((node) =>
isCoveredBy(boundingBoxFromCanvasNode(sticky), boundingBoxFromCanvasNode(node)),
);
if (coveredNodes.length === 0) return null;
const coveredNodesBoxAfter = compositeBoundingBox(
positionedNodes
.filter((node) => coveredNodes.some((covered) => covered.id === node.id))
.map(({ boundingBox }) => boundingBox),
);
return {
id: sticky.id,
boundingBox: {
x: centerHorizontally(coveredNodesBoxAfter, stickyBox),
y:
coveredNodesBoxAfter.y +
coveredNodesBoxAfter.height -
stickyBox.height +
STICKY_BOTTOM_PADDING,
height: stickyBox.height,
width: stickyBox.width,
},
};
})
.filter(isPresent);
return {
boundingBox: boundingBoxAfter,
nodes: positionedNodes.concat(positionedStickies).map(({ id, boundingBox }) => {
return {
id,
x: boundingBox.x - anchor.x,
y: boundingBox.y - anchor.y,
};
}),
};
}
return { layout };
}

View file

@ -31,7 +31,8 @@ export type ContextMenuAction =
| 'deselect_all'
| 'add_node'
| 'add_sticky'
| 'change_color';
| 'change_color'
| 'tidy_up';
const position = ref<XYPosition>([0, 0]);
const isOpen = ref(false);
@ -120,7 +121,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
},
};
const selectionActions = [
const selectionActions: ActionDropdownItem[] = [
{
id: 'select_all',
divided: true,
@ -135,6 +136,17 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
},
];
const layoutActions: ActionDropdownItem[] = [
{
id: 'tidy_up',
divided: true,
label: i18n.baseText(
nodes.length < 2 ? 'contextMenu.tidyUpWorkflow' : 'contextMenu.tidyUpSelection',
),
shortcut: { shiftKey: true, altKey: true, keys: ['T'] },
},
];
if (nodes.length === 0) {
actions.value = [
{
@ -149,6 +161,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
label: i18n.baseText('contextMenu.addSticky'),
disabled: isReadOnly.value,
},
...layoutActions,
...selectionActions,
];
} else {
@ -180,6 +193,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
shortcut: { metaKey: true, keys: ['D'] },
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
},
...layoutActions,
...selectionActions,
{
id: 'delete',

View file

@ -1332,6 +1332,7 @@
"nodeView.redirecting": "Redirecting",
"nodeView.refresh": "Refresh",
"nodeView.resetZoom": "Reset Zoom",
"nodeView.tidyUp": "Tidy Up",
"nodeView.runButtonText.executeWorkflow": "Test workflow",
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
@ -1381,6 +1382,8 @@
"contextMenu.sticky": "sticky note | sticky notes",
"contextMenu.selectAll": "Select all",
"contextMenu.deselectAll": "Clear selection",
"contextMenu.tidyUpWorkflow": "Tidy up workflow",
"contextMenu.tidyUpSelection": "Tidy up selection",
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
"contextMenu.open": "Open...",
"contextMenu.test": "Test step",

View file

@ -1576,6 +1576,9 @@ importers:
'@codemirror/view':
specifier: ^6.26.3
version: 6.26.3
'@dagrejs/dagre':
specifier: ^1.1.4
version: 1.1.4
'@fontsource/open-sans':
specifier: ^4.5.0
version: 4.5.12
@ -3343,6 +3346,13 @@ packages:
'@dabh/diagnostics@2.0.3':
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
'@dagrejs/dagre@1.1.4':
resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==}
'@dagrejs/graphlib@2.2.4':
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
engines: {node: '>17.0.0'}
'@element-plus/icons-vue@2.3.1':
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
peerDependencies:
@ -15752,6 +15762,12 @@ snapshots:
enabled: 2.0.0
kuler: 2.0.0
'@dagrejs/dagre@1.1.4':
dependencies:
'@dagrejs/graphlib': 2.2.4
'@dagrejs/graphlib@2.2.4': {}
'@element-plus/icons-vue@2.3.1(vue@3.5.13(typescript@5.7.2))':
dependencies:
vue: 3.5.13(typescript@5.7.2)