mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Update grid size to 16px for better alignment (#16869)
This commit is contained in:
@@ -74,8 +74,8 @@ export function createCanvasGraphNode({
|
||||
id = '1',
|
||||
type = 'default',
|
||||
label = 'Node',
|
||||
position = { x: 100, y: 100 },
|
||||
dimensions = { width: 100, height: 100 },
|
||||
position = { x: 96, y: 96 },
|
||||
dimensions = { width: 96, height: 96 },
|
||||
data,
|
||||
...rest
|
||||
}: Partial<
|
||||
|
||||
@@ -3,7 +3,7 @@ import { reactive, computed, toRefs } from 'vue';
|
||||
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
|
||||
import { WEBHOOK_NODE_TYPE, DRAG_EVENT_DATA_KEY } from '@/constants';
|
||||
|
||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||
import { DEFAULT_NODE_SIZE, getNewNodePosition } from '@/utils/nodeViewUtils';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
|
||||
import { useViewStacks } from '../composables/useViewStacks';
|
||||
@@ -76,7 +76,10 @@ function onDragOver(event: DragEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
|
||||
const [x, y] = getNewNodePosition(
|
||||
[],
|
||||
[event.pageX - DEFAULT_NODE_SIZE[0] / 2, event.pageY - DEFAULT_NODE_SIZE[1] / 2],
|
||||
);
|
||||
|
||||
state.draggablePosition = { x, y };
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('Canvas', () => {
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
position: { x: 120, y: 120 },
|
||||
position: { x: 112, y: 112 },
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`CanvasBackground > should render the background with the correct gap 1`] = `
|
||||
"<svg class="vue-flow__background vue-flow__container" style="height: 100%; width: 100%;" data-test-id="canvas-background">
|
||||
<pattern id="pattern-vue-flow-0" x="0" y="0" width="20" height="20" patternTransform="translate(-11,-11)" patternUnits="userSpaceOnUse">
|
||||
<pattern id="pattern-vue-flow-0" x="0" y="0" width="16" height="16" patternTransform="translate(-9,-9)" patternUnits="userSpaceOnUse">
|
||||
<circle cx="0.5" cy="0.5" r="0.5" fill="#aaa"></circle>
|
||||
<!---->
|
||||
</pattern>
|
||||
|
||||
@@ -111,8 +111,8 @@ describe('CanvasNode', () => {
|
||||
const inputHandles = getAllByTestId('canvas-node-input-handle');
|
||||
|
||||
expect(inputHandles[1]).toHaveStyle('left: 40px');
|
||||
expect(inputHandles[2]).toHaveStyle('left: 160px');
|
||||
expect(inputHandles[3]).toHaveStyle('left: 200px');
|
||||
expect(inputHandles[2]).toHaveStyle('left: 168px');
|
||||
expect(inputHandles[3]).toHaveStyle('left: 232px');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
|
||||
import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||
import { CONFIGURATION_NODE_OFFSET, GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||
|
||||
type Props = NodeProps<CanvasNodeData> & {
|
||||
readOnly?: boolean;
|
||||
@@ -186,7 +186,7 @@ const createEndpointMappingFn =
|
||||
connectingHandle.value?.handleId === handleId;
|
||||
const offsetValue =
|
||||
position === Position.Bottom
|
||||
? `${GRID_SIZE * (2 + index * 2)}px`
|
||||
? `${GRID_SIZE * 2 * (1 + index * 2) + CONFIGURATION_NODE_OFFSET}px`
|
||||
: `${(100 / (endpoints.length + 1)) * (index + 1)}%`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -35,13 +35,12 @@ describe('CanvasNodeDefault', () => {
|
||||
|
||||
describe('inputs and outputs', () => {
|
||||
it.each([
|
||||
[1, 1, '100px'],
|
||||
[3, 1, '100px'],
|
||||
[4, 1, '140px'],
|
||||
[1, 1, '100px'],
|
||||
[1, 3, '100px'],
|
||||
[1, 4, '140px'],
|
||||
[4, 4, '140px'],
|
||||
[1, 1, '96px'],
|
||||
[1, 3, '128px'],
|
||||
[1, 4, '160px'],
|
||||
[3, 1, '128px'],
|
||||
[4, 1, '160px'],
|
||||
[4, 4, '160px'],
|
||||
])(
|
||||
'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)',
|
||||
(inputCount, outputCount, expected) => {
|
||||
@@ -205,7 +204,7 @@ describe('CanvasNodeDefault', () => {
|
||||
[
|
||||
'1 required',
|
||||
[{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true }],
|
||||
'240px',
|
||||
'272px',
|
||||
],
|
||||
[
|
||||
'2 required, 1 optional',
|
||||
@@ -214,7 +213,7 @@ describe('CanvasNodeDefault', () => {
|
||||
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true },
|
||||
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true },
|
||||
],
|
||||
'240px',
|
||||
'272px',
|
||||
],
|
||||
[
|
||||
'2 required, 2 optional',
|
||||
@@ -224,7 +223,7 @@ describe('CanvasNodeDefault', () => {
|
||||
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true },
|
||||
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true },
|
||||
],
|
||||
'240px',
|
||||
'272px',
|
||||
],
|
||||
[
|
||||
'1 required, 4 optional',
|
||||
@@ -235,7 +234,7 @@ describe('CanvasNodeDefault', () => {
|
||||
{ type: NodeConnectionTypes.AiMemory, index: 0 },
|
||||
{ type: NodeConnectionTypes.AiMemory, index: 0 },
|
||||
],
|
||||
'280px',
|
||||
'336px',
|
||||
],
|
||||
])(
|
||||
'should adjust width css variable based on the number of non-main inputs (%s)',
|
||||
|
||||
@@ -234,7 +234,8 @@ function onActivate(event: MouseEvent) {
|
||||
|
||||
&.configuration {
|
||||
.icon {
|
||||
margin-left: calc((var(--canvas-node--height) - var(--node-icon-size)) / 2);
|
||||
// 4px represents calc(var(--handle--indicator--width) - configuration node offset) / 2)
|
||||
margin-left: calc((var(--canvas-node--height) - var(--node-icon-size) - 4px) / 2);
|
||||
}
|
||||
|
||||
&:not(.running) {
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
|
||||
<div
|
||||
class="node configurable"
|
||||
data-test-id="canvas-configurable-node"
|
||||
style="--canvas-node--width: 240px; --canvas-node--height: 100px; --node-icon-size: 40px;"
|
||||
style="--canvas-node--width: 272px; --canvas-node--height: 96px; --node-icon-size: 40px;"
|
||||
>
|
||||
<!--v-if-->
|
||||
<div
|
||||
@@ -49,7 +49,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
|
||||
<div
|
||||
class="node configurable configuration"
|
||||
data-test-id="canvas-configurable-node"
|
||||
style="--canvas-node--width: 240px; --canvas-node--height: 75px; --node-icon-size: 30px;"
|
||||
style="--canvas-node--width: 272px; --canvas-node--height: 80px; --node-icon-size: 30px;"
|
||||
>
|
||||
<!--v-if-->
|
||||
<div
|
||||
@@ -139,7 +139,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
|
||||
<div
|
||||
class="node"
|
||||
data-test-id="canvas-default-node"
|
||||
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
|
||||
style="--canvas-node--width: 96px; --canvas-node--height: 96px; --node-icon-size: 40px;"
|
||||
>
|
||||
<!--v-if-->
|
||||
<div
|
||||
@@ -184,7 +184,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
|
||||
<div
|
||||
class="node trigger"
|
||||
data-test-id="canvas-trigger-node"
|
||||
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
|
||||
style="--canvas-node--width: 96px; --canvas-node--height: 96px; --node-icon-size: 40px;"
|
||||
>
|
||||
<!--v-if-->
|
||||
<div
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
exports[`useCanvasLayout > should layout a basic workflow 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 540,
|
||||
"height": 96,
|
||||
"width": 544,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"x": 96,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 320,
|
||||
"y": 100,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 540,
|
||||
"y": 100,
|
||||
"x": 544,
|
||||
"y": 96,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -31,26 +31,26 @@ exports[`useCanvasLayout > should layout a basic workflow 1`] = `
|
||||
exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 540,
|
||||
"height": 96,
|
||||
"width": 544,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"x": 96,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 320,
|
||||
"y": 100,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 540,
|
||||
"y": 100,
|
||||
"x": 544,
|
||||
"y": 96,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -59,16 +59,16 @@ exports[`useCanvasLayout > should layout a basic workflow with selected nodes 1`
|
||||
exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 540,
|
||||
"width": 820,
|
||||
"height": 544,
|
||||
"width": 832,
|
||||
"x": 0,
|
||||
"y": 220,
|
||||
"y": 224,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"x": 96,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "aiTool1",
|
||||
@@ -77,28 +77,28 @@ exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
|
||||
},
|
||||
{
|
||||
"id": "aiTool2",
|
||||
"x": 460,
|
||||
"x": 464,
|
||||
"y": 320,
|
||||
},
|
||||
{
|
||||
"id": "aiTool3",
|
||||
"x": 600,
|
||||
"y": 540,
|
||||
"x": 608,
|
||||
"y": 544,
|
||||
},
|
||||
{
|
||||
"id": "aiAgent",
|
||||
"x": 460,
|
||||
"y": 100,
|
||||
"x": 464,
|
||||
"y": 96,
|
||||
},
|
||||
{
|
||||
"id": "configurableAiTool",
|
||||
"x": 600,
|
||||
"x": 608,
|
||||
"y": 320,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 820,
|
||||
"y": 100,
|
||||
"x": 832,
|
||||
"y": 96,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -107,8 +107,8 @@ exports[`useCanvasLayout > should layout a workflow with AI nodes 1`] = `
|
||||
exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 100,
|
||||
"width": 760,
|
||||
"height": 96,
|
||||
"width": 768,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
@@ -120,22 +120,22 @@ exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 220,
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 440,
|
||||
"x": 448,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "node4",
|
||||
"x": 660,
|
||||
"x": 672,
|
||||
"y": 0,
|
||||
},
|
||||
{
|
||||
"id": "sticky",
|
||||
"x": 130,
|
||||
"x": 134,
|
||||
"y": -240,
|
||||
},
|
||||
],
|
||||
@@ -145,7 +145,7 @@ exports[`useCanvasLayout > should layout a workflow with sticky notes 1`] = `
|
||||
exports[`useCanvasLayout > should not reorder nodes vertically as it affects execution order 1`] = `
|
||||
{
|
||||
"boundingBox": {
|
||||
"height": 300,
|
||||
"height": 288,
|
||||
"width": 320,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -154,17 +154,17 @@ exports[`useCanvasLayout > should not reorder nodes vertically as it affects exe
|
||||
{
|
||||
"id": "node1",
|
||||
"x": 0,
|
||||
"y": -100,
|
||||
"y": -112,
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"x": 220,
|
||||
"y": -200,
|
||||
"x": 224,
|
||||
"y": -208,
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"x": 220,
|
||||
"y": 0,
|
||||
"x": 224,
|
||||
"y": -16,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('useCanvasLayout', () => {
|
||||
|
||||
const { layout } = createTestSetup(nodes, connections);
|
||||
const result = layout('all');
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(matchesGrid(result)).toBe(true);
|
||||
});
|
||||
@@ -153,8 +154,8 @@ describe('useCanvasLayout', () => {
|
||||
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 } }),
|
||||
createCanvasGraphNode({ id: 'node2', position: { x: 400, y: 208 } }),
|
||||
createCanvasGraphNode({ id: 'node3', position: { x: 400, y: -208 } }),
|
||||
];
|
||||
|
||||
const connections: Array<[string, string]> = [
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type CanvasNodeData,
|
||||
} from '../types';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
import { GRID_SIZE, NODE_SIZE } from '../utils/nodeViewUtils';
|
||||
import { DEFAULT_NODE_SIZE, GRID_SIZE } from '../utils/nodeViewUtils';
|
||||
|
||||
export type CanvasLayoutOptions = { id?: string };
|
||||
export type CanvasLayoutTarget = 'selection' | 'all';
|
||||
@@ -40,12 +40,12 @@ export type CanvasLayoutEvent = {
|
||||
|
||||
export type CanvasNodeDictionary = Record<string, GraphNode<CanvasNodeData>>;
|
||||
|
||||
const NODE_X_SPACING = GRID_SIZE * 6;
|
||||
const NODE_Y_SPACING = GRID_SIZE * 5;
|
||||
const NODE_X_SPACING = GRID_SIZE * 8;
|
||||
const NODE_Y_SPACING = GRID_SIZE * 6;
|
||||
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;
|
||||
const AI_X_SPACING = GRID_SIZE * 3;
|
||||
const AI_Y_SPACING = GRID_SIZE * 8;
|
||||
const STICKY_BOTTOM_PADDING = GRID_SIZE * 4;
|
||||
|
||||
export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
||||
const {
|
||||
@@ -113,7 +113,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
||||
function createDagreSubGraph({
|
||||
nodeIds,
|
||||
parent,
|
||||
}: { nodeIds: string[]; parent: dagre.graphlib.Graph }) {
|
||||
}: {
|
||||
nodeIds: string[];
|
||||
parent: dagre.graphlib.Graph;
|
||||
}) {
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setGraph({
|
||||
rankdir: 'LR',
|
||||
@@ -165,7 +168,10 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
||||
function createAiSubGraph({
|
||||
parent,
|
||||
nodeIds,
|
||||
}: { parent: dagre.graphlib.Graph; nodeIds: string[] }) {
|
||||
}: {
|
||||
parent: dagre.graphlib.Graph;
|
||||
nodeIds: string[];
|
||||
}) {
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setGraph({
|
||||
rankdir: 'TB',
|
||||
@@ -449,7 +455,7 @@ export function useCanvasLayout({ id: canvasId }: CanvasLayoutOptions = {}) {
|
||||
const aiGraphBoundingBox = compositeBoundingBox(
|
||||
aiNodes.map((nodeId) => boundingBoxByNodeId[nodeId]).filter(isPresent),
|
||||
);
|
||||
const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - NODE_SIZE / 2;
|
||||
const aiNodeVerticalCorrection = aiGraphBoundingBox.height / 2 - DEFAULT_NODE_SIZE[0] / 2;
|
||||
aiGraphBoundingBox.y += aiNodeVerticalCorrection;
|
||||
|
||||
const hasConflictingNodes = Object.entries(boundingBoxByNodeId)
|
||||
|
||||
@@ -200,12 +200,12 @@ describe('useCanvasOperations', () => {
|
||||
{
|
||||
type: 'type',
|
||||
typeVersion: 1,
|
||||
position: [20, 20],
|
||||
position: [32, 32],
|
||||
},
|
||||
mockNodeTypeDescription({ name: 'type' }),
|
||||
);
|
||||
|
||||
expect(result.position).toEqual([20, 20]);
|
||||
expect(result.position).toEqual([32, 32]);
|
||||
});
|
||||
|
||||
it('should not assign credentials when multiple credentials are available', () => {
|
||||
@@ -274,13 +274,13 @@ describe('useCanvasOperations', () => {
|
||||
|
||||
describe('resolveNodePosition', () => {
|
||||
it('should return the node position if it is already set', () => {
|
||||
const node = createTestNode({ position: [100, 100] });
|
||||
const node = createTestNode({ position: [112, 112] });
|
||||
const nodeTypeDescription = mockNodeTypeDescription();
|
||||
|
||||
const { resolveNodePosition } = useCanvasOperations();
|
||||
const position = resolveNodePosition(node, nodeTypeDescription);
|
||||
|
||||
expect(position).toEqual([100, 100]);
|
||||
expect(position).toEqual([112, 112]);
|
||||
});
|
||||
|
||||
it('should place the node at the last cancelled connection position', () => {
|
||||
@@ -302,7 +302,7 @@ describe('useCanvasOperations', () => {
|
||||
const { resolveNodePosition } = useCanvasOperations();
|
||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||
|
||||
expect(position).toEqual([200, 160]);
|
||||
expect(position).toEqual([208, 160]);
|
||||
expect(uiStore.lastCancelledConnectionPosition).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -316,7 +316,7 @@ describe('useCanvasOperations', () => {
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
|
||||
uiStore.lastInteractedWithNode = createTestNode({
|
||||
position: [100, 100],
|
||||
position: [112, 112],
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
});
|
||||
@@ -327,7 +327,7 @@ describe('useCanvasOperations', () => {
|
||||
const { resolveNodePosition } = useCanvasOperations();
|
||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||
|
||||
expect(position).toEqual([320, 100]);
|
||||
expect(position).toEqual([320, 112]);
|
||||
});
|
||||
|
||||
it('should place the node below the last interacted with node if it has non-main outputs', () => {
|
||||
@@ -340,7 +340,7 @@ describe('useCanvasOperations', () => {
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
|
||||
uiStore.lastInteractedWithNode = createTestNode({
|
||||
position: [100, 100],
|
||||
position: [96, 96],
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
});
|
||||
@@ -358,7 +358,7 @@ describe('useCanvasOperations', () => {
|
||||
const { resolveNodePosition } = useCanvasOperations();
|
||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||
|
||||
expect(position).toEqual([460, 100]);
|
||||
expect(position).toEqual([448, 96]);
|
||||
});
|
||||
|
||||
it('should place the node at the last clicked position if no other position is set', () => {
|
||||
@@ -367,16 +367,14 @@ describe('useCanvasOperations', () => {
|
||||
const node = createTestNode({ id: '0' });
|
||||
const nodeTypeDescription = mockNodeTypeDescription();
|
||||
|
||||
workflowsStore.workflowTriggerNodes = [
|
||||
createTestNode({ id: 'trigger', position: [100, 100] }),
|
||||
];
|
||||
workflowsStore.workflowTriggerNodes = [createTestNode({ id: 'trigger', position: [96, 96] })];
|
||||
|
||||
const { resolveNodePosition, lastClickPosition } = useCanvasOperations();
|
||||
lastClickPosition.value = [300, 300];
|
||||
|
||||
const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription);
|
||||
|
||||
expect(position).toEqual([300, 300]);
|
||||
expect(position).toEqual([304, 304]); // Snapped to grid
|
||||
});
|
||||
|
||||
it('should place the trigger node at the root if it is the first trigger node', () => {
|
||||
@@ -532,8 +530,8 @@ describe('useCanvasOperations', () => {
|
||||
it('records history for multiple node position updates when tracking is enabled', () => {
|
||||
const historyStore = useHistoryStore();
|
||||
const events = [
|
||||
{ id: 'node1', position: { x: 100, y: 100 } },
|
||||
{ id: 'node2', position: { x: 200, y: 200 } },
|
||||
{ id: 'node1', position: { x: 96, y: 96 } },
|
||||
{ id: 'node2', position: { x: 208, y: 208 } },
|
||||
];
|
||||
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
|
||||
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
|
||||
@@ -548,8 +546,8 @@ describe('useCanvasOperations', () => {
|
||||
it('updates positions for multiple nodes', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const events = [
|
||||
{ id: 'node1', position: { x: 100, y: 100 } },
|
||||
{ id: 'node2', position: { x: 200, y: 200 } },
|
||||
{ id: 'node1', position: { x: 96, y: 96 } },
|
||||
{ id: 'node2', position: { x: 208, y: 208 } },
|
||||
];
|
||||
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
|
||||
workflowsStore.getNodeById
|
||||
@@ -570,13 +568,13 @@ describe('useCanvasOperations', () => {
|
||||
updateNodesPosition(events);
|
||||
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]);
|
||||
});
|
||||
|
||||
it('does not record history when trackHistory is false', () => {
|
||||
const historyStore = useHistoryStore();
|
||||
const events = [{ id: 'node1', position: { x: 100, y: 100 } }];
|
||||
const events = [{ id: 'node1', position: { x: 96, y: 96 } }];
|
||||
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
|
||||
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
|
||||
|
||||
@@ -596,10 +594,10 @@ describe('useCanvasOperations', () => {
|
||||
target: 'all',
|
||||
result: {
|
||||
nodes: [
|
||||
{ id: 'node1', x: 100, y: 100 },
|
||||
{ id: 'node2', x: 200, y: 200 },
|
||||
{ id: 'node1', x: 96, y: 96 },
|
||||
{ id: 'node2', x: 208, y: 208 },
|
||||
],
|
||||
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||
boundingBox: { height: 96, width: 96, x: 0, y: 0 },
|
||||
},
|
||||
};
|
||||
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
|
||||
@@ -619,10 +617,10 @@ describe('useCanvasOperations', () => {
|
||||
target: 'all',
|
||||
result: {
|
||||
nodes: [
|
||||
{ id: 'node1', x: 100, y: 100 },
|
||||
{ id: 'node2', x: 200, y: 200 },
|
||||
{ id: 'node1', x: 96, y: 96 },
|
||||
{ id: 'node2', x: 208, y: 208 },
|
||||
],
|
||||
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||
boundingBox: { height: 96, width: 96, x: 0, y: 0 },
|
||||
},
|
||||
};
|
||||
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
|
||||
@@ -644,8 +642,8 @@ describe('useCanvasOperations', () => {
|
||||
tidyUp(event);
|
||||
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [96, 96]);
|
||||
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [208, 208]);
|
||||
});
|
||||
|
||||
it('should send a "User tidied up workflow" telemetry event', () => {
|
||||
@@ -654,10 +652,10 @@ describe('useCanvasOperations', () => {
|
||||
target: 'all',
|
||||
result: {
|
||||
nodes: [
|
||||
{ id: 'node1', x: 100, y: 100 },
|
||||
{ id: 'node2', x: 200, y: 200 },
|
||||
{ id: 'node1', x: 96, y: 96 },
|
||||
{ id: 'node2', x: 208, y: 208 },
|
||||
],
|
||||
boundingBox: { height: 100, width: 100, x: 0, y: 0 },
|
||||
boundingBox: { height: 96, width: 96, x: 0, y: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -738,8 +736,8 @@ describe('useCanvasOperations', () => {
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeTypeName = 'type';
|
||||
const nodes = [
|
||||
mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }),
|
||||
mockNode({ name: 'Node 1', type: nodeTypeName, position: [32, 32] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [96, 256] }),
|
||||
];
|
||||
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
@@ -758,14 +756,14 @@ describe('useCanvasOperations', () => {
|
||||
name: nodes[0].name,
|
||||
type: nodeTypeName,
|
||||
typeVersion: 1,
|
||||
position: [40, 40],
|
||||
position: [32, 32],
|
||||
parameters: {},
|
||||
});
|
||||
expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({
|
||||
name: nodes[1].name,
|
||||
type: nodeTypeName,
|
||||
typeVersion: 1,
|
||||
position: [100, 240],
|
||||
position: [96, 256],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
@@ -775,8 +773,8 @@ describe('useCanvasOperations', () => {
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const nodeTypeName = 'type';
|
||||
const nodes = [
|
||||
mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }),
|
||||
mockNode({ name: 'Node 1', type: nodeTypeName, position: [128, 128] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [192, 320] }),
|
||||
];
|
||||
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
@@ -804,9 +802,9 @@ describe('useCanvasOperations', () => {
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeTypeName = 'type';
|
||||
const nodes = [
|
||||
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }),
|
||||
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }),
|
||||
mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [100, 240] }),
|
||||
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [32, 32] }),
|
||||
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [32, 32] }),
|
||||
mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [96, 256] }),
|
||||
];
|
||||
|
||||
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
|
||||
@@ -842,7 +840,7 @@ describe('useCanvasOperations', () => {
|
||||
const nodeTypeName = 'type';
|
||||
const nodes = [
|
||||
mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }),
|
||||
mockNode({ name: 'Node 2', type: nodeTypeName, position: [96, 240] }),
|
||||
];
|
||||
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
@@ -3007,8 +3005,8 @@ describe('useCanvasOperations', () => {
|
||||
|
||||
// Create three nodes in a sequence: A -> B -> C
|
||||
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: nodeA.type,
|
||||
@@ -3077,8 +3075,8 @@ describe('useCanvasOperations', () => {
|
||||
|
||||
// Create three nodes in a sequence: A -> B -> C
|
||||
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
|
||||
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: nodeA.type,
|
||||
@@ -3144,8 +3142,8 @@ describe('useCanvasOperations', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
|
||||
// Create nodes: B -> C (no incoming to B)
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [200, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] });
|
||||
const nodeC = createTestNode({ id: 'C', name: 'Node C', position: [208, 0] });
|
||||
|
||||
workflowsStore.workflow.nodes = [nodeB, nodeC];
|
||||
workflowsStore.workflow.connections = {
|
||||
@@ -3173,7 +3171,7 @@ describe('useCanvasOperations', () => {
|
||||
|
||||
// Create nodes: A -> B (no outgoing from B)
|
||||
const nodeA = createTestNode({ id: 'A', name: 'Node A', position: [0, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [100, 0] });
|
||||
const nodeB = createTestNode({ id: 'B', name: 'Node B', position: [96, 0] });
|
||||
|
||||
workflowsStore.workflow.nodes = [nodeA, nodeB];
|
||||
workflowsStore.workflow.connections = {
|
||||
@@ -3220,7 +3218,7 @@ describe('useCanvasOperations', () => {
|
||||
const nodeB: IWorkflowTemplateNode = createTestNode({
|
||||
id: 'Y',
|
||||
name: 'Node Y',
|
||||
position: [180, 80],
|
||||
position: [192, 80],
|
||||
});
|
||||
|
||||
const workflow: IWorkflowTemplate['workflow'] = {
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from '@/utils/canvasUtils';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import {
|
||||
GRID_SIZE,
|
||||
CONFIGURABLE_NODE_SIZE,
|
||||
CONFIGURATION_NODE_SIZE,
|
||||
DEFAULT_NODE_SIZE,
|
||||
@@ -692,7 +693,7 @@ export function useCanvasOperations() {
|
||||
|
||||
// When we're adding multiple nodes, increment the X position for the next one
|
||||
insertPosition = [
|
||||
lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE,
|
||||
lastAddedNode.position[0] + DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE,
|
||||
lastAddedNode.position[1],
|
||||
];
|
||||
}
|
||||
@@ -1108,8 +1109,8 @@ export function useCanvasOperations() {
|
||||
if (lastInteractedWithNodeMainOutputs.length > 1) {
|
||||
const yOffsetValues = generateOffsets(
|
||||
lastInteractedWithNodeMainOutputs.length,
|
||||
NodeViewUtils.NODE_SIZE,
|
||||
NodeViewUtils.GRID_SIZE,
|
||||
DEFAULT_NODE_SIZE[1],
|
||||
GRID_SIZE,
|
||||
);
|
||||
|
||||
yOffset = yOffsetValues[connectionIndex];
|
||||
|
||||
@@ -116,7 +116,7 @@ export const EXPRESSIONS_DOCS_URL = `https://${DOCS_DOMAIN}/code-examples/expres
|
||||
export const N8N_PRICING_PAGE_URL = 'https://n8n.io/pricing';
|
||||
export const N8N_MAIN_GITHUB_REPO_URL = 'https://github.com/n8n-io/n8n';
|
||||
|
||||
export const NODE_MIN_INPUT_ITEMS_COUNT = 5;
|
||||
export const NODE_MIN_INPUT_ITEMS_COUNT = 4;
|
||||
|
||||
// node types
|
||||
export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr';
|
||||
|
||||
@@ -102,6 +102,7 @@ import { updateCurrentUserSettings } from '@/api/users';
|
||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||
import { isChatNode } from '@/utils/aiUtils';
|
||||
import { snapPositionToGrid } from '@/utils/nodeViewUtils';
|
||||
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
@@ -1290,6 +1291,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
node.type = getCredentialOnlyNodeTypeName(node.extendsCredential);
|
||||
}
|
||||
|
||||
if (node.position) {
|
||||
node.position = snapPositionToGrid(node.position);
|
||||
}
|
||||
|
||||
if (!nodeMetadata.value[node.name]) {
|
||||
nodeMetadata.value[node.name] = { pristine: true };
|
||||
}
|
||||
|
||||
@@ -992,7 +992,7 @@ describe('insertSpacersBetweenEndpoints', () => {
|
||||
const endpoints = [{ index: 0, required: true }];
|
||||
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
|
||||
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount);
|
||||
expect(result).toEqual([{ index: 0, required: true }, null, null, null, null]);
|
||||
expect(result).toEqual([{ index: 0, required: true }, null, null, null]);
|
||||
});
|
||||
|
||||
it('should not insert spacers when there are at least min endpoints count', () => {
|
||||
@@ -1012,14 +1012,14 @@ describe('insertSpacersBetweenEndpoints', () => {
|
||||
const endpoints = [{ index: 0, required: false }];
|
||||
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
|
||||
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount);
|
||||
expect(result).toEqual([null, null, null, null, { index: 0, required: false }]);
|
||||
expect(result).toEqual([null, null, null, { index: 0, required: false }]);
|
||||
});
|
||||
|
||||
it('should handle no endpoints', () => {
|
||||
const endpoints: Array<{ index: number; required: boolean }> = [];
|
||||
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
|
||||
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount);
|
||||
expect(result).toEqual([null, null, null, null, null]);
|
||||
expect(result).toEqual([null, null, null, null]);
|
||||
});
|
||||
|
||||
it('should handle required endpoints greater than NODE_MIN_INPUT_ITEMS_COUNT', () => {
|
||||
@@ -1040,7 +1040,6 @@ describe('insertSpacersBetweenEndpoints', () => {
|
||||
{ index: 0, required: true },
|
||||
{ index: 1, required: true },
|
||||
null,
|
||||
null,
|
||||
{ index: 2 },
|
||||
]);
|
||||
});
|
||||
@@ -1049,6 +1048,6 @@ describe('insertSpacersBetweenEndpoints', () => {
|
||||
const endpoints = [{ index: 0, required: true }];
|
||||
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
|
||||
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount);
|
||||
expect(result).toEqual([{ index: 0, required: true }, null, null, null, null]);
|
||||
expect(result).toEqual([{ index: 0, required: true }, null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
generateOffsets,
|
||||
getGenericHints,
|
||||
getNewNodePosition,
|
||||
NODE_SIZE,
|
||||
updateViewportToContainNodes,
|
||||
DEFAULT_NODE_SIZE,
|
||||
snapPositionToGrid,
|
||||
calculateNodeSize,
|
||||
GRID_SIZE,
|
||||
} from './nodeViewUtils';
|
||||
import type { INode, INodeTypeDescription, INodeExecutionData, Workflow } from 'n8n-workflow';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
@@ -203,96 +206,112 @@ describe('getGenericHints', () => {
|
||||
|
||||
describe('generateOffsets', () => {
|
||||
it('should return correct offsets for 0 nodes', () => {
|
||||
const result = generateOffsets(0, 100, 20);
|
||||
const result = generateOffsets(0, 96, GRID_SIZE);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return correct offsets for 1 node', () => {
|
||||
const result = generateOffsets(1, 100, 20);
|
||||
const result = generateOffsets(1, 96, GRID_SIZE);
|
||||
expect(result).toEqual([0]);
|
||||
});
|
||||
|
||||
it('should return correct offsets for 2 nodes', () => {
|
||||
const result = generateOffsets(2, 100, 20);
|
||||
expect(result).toEqual([-100, 100]);
|
||||
const result = generateOffsets(2, 96, GRID_SIZE);
|
||||
expect(result).toEqual([-96, 96]);
|
||||
});
|
||||
|
||||
it('should return correct offsets for 3 nodes', () => {
|
||||
const result = generateOffsets(3, 100, 20);
|
||||
expect(result).toEqual([-120, 0, 120]);
|
||||
const result = generateOffsets(3, 96, GRID_SIZE);
|
||||
expect(result).toEqual([-112, 0, 112]);
|
||||
});
|
||||
|
||||
it('should return correct offsets for 4 nodes', () => {
|
||||
const result = generateOffsets(4, 100, 20);
|
||||
expect(result).toEqual([-220, -100, 100, 220]);
|
||||
const result = generateOffsets(4, 96, GRID_SIZE);
|
||||
expect(result).toEqual([-208, -96, 96, 208]);
|
||||
});
|
||||
|
||||
it('should return correct offsets for large node count', () => {
|
||||
const result = generateOffsets(10, 100, 20);
|
||||
expect(result).toEqual([-580, -460, -340, -220, -100, 100, 220, 340, 460, 580]);
|
||||
const result = generateOffsets(10, 96, GRID_SIZE);
|
||||
expect(result).toEqual([-544, -432, -320, -208, -96, 96, 208, 320, 432, 544]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapPositionToGrid', () => {
|
||||
it('should snap position to grid', () => {
|
||||
const position: XYPosition = [105, 115];
|
||||
const snappedPosition = snapPositionToGrid(position);
|
||||
expect(snappedPosition).toEqual([112, 128]);
|
||||
});
|
||||
|
||||
it('should not change position if already on grid', () => {
|
||||
const position: XYPosition = [96, 96];
|
||||
const snappedPosition = snapPositionToGrid(position);
|
||||
expect(snappedPosition).toEqual([96, 96]);
|
||||
});
|
||||
|
||||
it('should handle negative positions', () => {
|
||||
const position: XYPosition = [-15, -25];
|
||||
const snappedPosition = snapPositionToGrid(position);
|
||||
expect(snappedPosition).toEqual([-16, -32]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewNodePosition', () => {
|
||||
it('should return the new position when there are no conflicts', () => {
|
||||
const nodes: INodeUi[] = [];
|
||||
const newPosition: XYPosition = [100, 100];
|
||||
const newPosition: XYPosition = [96, 96];
|
||||
const result = getNewNodePosition(nodes, newPosition);
|
||||
expect(result).toEqual([100, 100]);
|
||||
expect(result).toEqual([96, 96]);
|
||||
});
|
||||
|
||||
it('should adjust the position to the closest grid size', () => {
|
||||
const nodes: INodeUi[] = [];
|
||||
const newPosition: XYPosition = [105, 115];
|
||||
const result = getNewNodePosition(nodes, newPosition);
|
||||
expect(result).toEqual([120, 120]);
|
||||
expect(result).toEqual([112, 128]);
|
||||
});
|
||||
|
||||
it('should move the position to avoid conflicts', () => {
|
||||
const nodes: INodeUi[] = [
|
||||
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }),
|
||||
];
|
||||
const newPosition: XYPosition = [100, 100];
|
||||
const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })];
|
||||
const newPosition: XYPosition = [96, 96];
|
||||
const result = getNewNodePosition(nodes, newPosition);
|
||||
expect(result).toEqual([220, 220]);
|
||||
expect(result).toEqual([240, 240]);
|
||||
});
|
||||
|
||||
it('should skip nodes in the conflict allowlist', () => {
|
||||
const nodes: INodeUi[] = [
|
||||
createTestNode({ id: '1', position: [100, 100], type: STICKY_NODE_TYPE }),
|
||||
createTestNode({ id: '1', position: [96, 96], type: STICKY_NODE_TYPE }),
|
||||
];
|
||||
const newPosition: XYPosition = [100, 100];
|
||||
const newPosition: XYPosition = [96, 96];
|
||||
const result = getNewNodePosition(nodes, newPosition);
|
||||
expect(result).toEqual([100, 100]);
|
||||
expect(result).toEqual([96, 96]);
|
||||
});
|
||||
|
||||
it('should use the provided move position to resolve conflicts', () => {
|
||||
const nodes: INodeUi[] = [
|
||||
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }),
|
||||
];
|
||||
const newPosition: XYPosition = [100, 100];
|
||||
const movePosition: XYPosition = [50, 50];
|
||||
const nodes: INodeUi[] = [createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE })];
|
||||
const newPosition: XYPosition = [96, 96];
|
||||
const movePosition: XYPosition = [48, 48];
|
||||
const result = getNewNodePosition(nodes, newPosition, {
|
||||
offset: movePosition,
|
||||
});
|
||||
expect(result).toEqual([220, 220]);
|
||||
expect(result).toEqual([240, 240]);
|
||||
});
|
||||
|
||||
it('should handle multiple conflicts correctly', () => {
|
||||
const nodes: INodeUi[] = [
|
||||
createTestNode({ id: '1', position: [100, 100], type: SET_NODE_TYPE }),
|
||||
createTestNode({ id: '2', position: [140, 140], type: SET_NODE_TYPE }),
|
||||
createTestNode({ id: '1', position: [96, 96], type: SET_NODE_TYPE }),
|
||||
createTestNode({ id: '2', position: [144, 144], type: SET_NODE_TYPE }),
|
||||
];
|
||||
const newPosition: XYPosition = [100, 100];
|
||||
const newPosition: XYPosition = [96, 96];
|
||||
const result = getNewNodePosition(nodes, newPosition);
|
||||
expect(result).toEqual([280, 280]);
|
||||
expect(result).toEqual([288, 288]);
|
||||
});
|
||||
});
|
||||
|
||||
const testNodes: INode[] = [
|
||||
createTestNode({ id: 'a', position: [0, 0] }),
|
||||
createTestNode({ id: 'b', position: [100, 50] }),
|
||||
createTestNode({ id: 'c', position: [50, 100] }),
|
||||
createTestNode({ id: 'b', position: [96, 50] }),
|
||||
createTestNode({ id: 'c', position: [50, 96] }),
|
||||
createTestNode({ id: 'd', position: [-20, -10] }),
|
||||
];
|
||||
|
||||
@@ -379,15 +398,15 @@ describe('getBottomMostNode', () => {
|
||||
describe('getNodesGroupSize', () => {
|
||||
it('calculates the group size correctly', () => {
|
||||
const [width, height] = getNodesGroupSize(testNodes);
|
||||
expect(width).toBe(Math.abs(100 - -20) + NODE_SIZE);
|
||||
expect(height).toBe(Math.abs(-10 - 100) + NODE_SIZE);
|
||||
expect(width).toBe(Math.abs(96 - -20) + DEFAULT_NODE_SIZE[0]);
|
||||
expect(height).toBe(Math.abs(-10 - 96) + DEFAULT_NODE_SIZE[1]);
|
||||
});
|
||||
|
||||
it('should handle a single node', () => {
|
||||
const single = [testNodes[0]];
|
||||
const [w, h] = getNodesGroupSize(single);
|
||||
expect(w).toBe(NODE_SIZE);
|
||||
expect(h).toBe(NODE_SIZE);
|
||||
expect(w).toBe(DEFAULT_NODE_SIZE[0]);
|
||||
expect(h).toBe(DEFAULT_NODE_SIZE[1]);
|
||||
});
|
||||
|
||||
it('should handle nodes with equal positions', () => {
|
||||
@@ -396,12 +415,12 @@ describe('getNodesGroupSize', () => {
|
||||
createTestNode({ id: 'y', position: [10, 20] }),
|
||||
];
|
||||
const [we, he] = getNodesGroupSize(equalNodes);
|
||||
expect(we).toBe(NODE_SIZE);
|
||||
expect(he).toBe(NODE_SIZE);
|
||||
expect(we).toBe(DEFAULT_NODE_SIZE[0]);
|
||||
expect(he).toBe(DEFAULT_NODE_SIZE[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(updateViewportToContainNodes, () => {
|
||||
describe('updateViewportToContainNodes', () => {
|
||||
it('should return the same viewport if given node is already in the viewport', () => {
|
||||
const result = updateViewportToContainNodes(
|
||||
{ x: 0, y: 0, zoom: 2 },
|
||||
@@ -446,6 +465,82 @@ describe(updateViewportToContainNodes, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateNodeSize', () => {
|
||||
it('should return configuration node size when isConfiguration is true and isConfigurable is false', () => {
|
||||
const result = calculateNodeSize(
|
||||
true, // isConfiguration
|
||||
false, // isConfigurable
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
// width = GRID_SIZE * 5 = 16 * 5 = 80
|
||||
// height = GRID_SIZE * 5 = 16 * 5 = 80
|
||||
expect(result).toEqual({ width: 80, height: 80 });
|
||||
});
|
||||
|
||||
it('should return configurable node size when isConfigurable is true and isConfiguration is false', () => {
|
||||
const nonMainInputCount = 5;
|
||||
const mainInputCount = 3;
|
||||
const mainOutputCount = 2;
|
||||
// width = max(4, 5) * 2 * 16 * 2 = 5 * 2 * 16 * 2 + offset = 336
|
||||
// height = DEFAULT_NODE_SIZE[1] + max(0, max(3,2,1) - 2) * 16 * 2
|
||||
// maxVerticalHandles = 3
|
||||
// height = 96 + (3 - 2) * 32 = 96 + 32 = 128
|
||||
expect(
|
||||
calculateNodeSize(false, true, mainInputCount, mainOutputCount, nonMainInputCount),
|
||||
).toEqual({ width: 336, height: 128 });
|
||||
});
|
||||
|
||||
it('should return configurable configuration node size when both isConfigurable and isConfiguration are true', () => {
|
||||
const nonMainInputCount = 2;
|
||||
// width = max(4, 2) * 2 * 16 * 2 = 4 * 2 * 16 * 2 + offset = 272
|
||||
// height = CONFIGURATION_NODE_SIZE[1] = 16 * 5 = 80
|
||||
expect(calculateNodeSize(true, true, 1, 1, nonMainInputCount)).toEqual({
|
||||
width: 272,
|
||||
height: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default node size when neither isConfigurable nor isConfiguration are true', () => {
|
||||
const mainInputCount = 3;
|
||||
const mainOutputCount = 2;
|
||||
// width = 96
|
||||
// maxVerticalHandles = 3
|
||||
// height = 96 + (3 - 2) * 32 = 128
|
||||
expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0)).toEqual({
|
||||
width: 96,
|
||||
height: 128,
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate height based on the max of mainInputCount and mainOutputCount', () => {
|
||||
const mainInputCount = 6;
|
||||
const mainOutputCount = 4;
|
||||
// maxVerticalHandles = 6
|
||||
// height = 96 + (6 - 2) * 32 = 96 + 128 = 224
|
||||
expect(calculateNodeSize(false, false, mainInputCount, mainOutputCount, 0).height).toBe(224);
|
||||
});
|
||||
|
||||
it('should respect the minimum width for configurable nodes', () => {
|
||||
const nonMainInputCount = 2; // less than NODE_MIN_INPUT_ITEMS_COUNT
|
||||
// width = 4 * 2 * 16 * 2 + offset = 272
|
||||
// height = default path, mainInputCount = 1, mainOutputCount = 1
|
||||
// maxVerticalHandles = 1
|
||||
// height = 96 + (1 - 2) * 32 = 96 + 0 = 96
|
||||
expect(calculateNodeSize(false, true, 1, 1, nonMainInputCount)).toEqual({
|
||||
width: 272,
|
||||
height: 96,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle edge case when mainInputCount and mainOutputCount are 0', () => {
|
||||
// maxVerticalHandles = max(0,0,1) = 1
|
||||
// height = 96 + (1 - 2) * 32 = 96 + 0 = 96
|
||||
expect(calculateNodeSize(false, false, 0, 0, 0).height).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
function createTestGraphNode(data: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
computedPosition: { z: 0, ...(data.position ?? { x: 0, y: 0 }) },
|
||||
|
||||
@@ -32,17 +32,15 @@ import {
|
||||
* Canvas constants and functions
|
||||
*/
|
||||
|
||||
export const GRID_SIZE = 20;
|
||||
export const GRID_SIZE = 16;
|
||||
|
||||
export const NODE_SIZE = GRID_SIZE * 5;
|
||||
export const DEFAULT_NODE_SIZE: [number, number] = [GRID_SIZE * 5, GRID_SIZE * 5];
|
||||
export const CONFIGURATION_NODE_SIZE: [number, number] = [GRID_SIZE * 4, GRID_SIZE * 4];
|
||||
export const CONFIGURABLE_NODE_SIZE: [number, number] = [GRID_SIZE * 12, GRID_SIZE * 5];
|
||||
export const DEFAULT_START_POSITION_X = GRID_SIZE * 9;
|
||||
export const DEFAULT_START_POSITION_Y = GRID_SIZE * 12;
|
||||
export const DEFAULT_NODE_SIZE: [number, number] = [GRID_SIZE * 6, GRID_SIZE * 6];
|
||||
export const CONFIGURATION_NODE_SIZE: [number, number] = [GRID_SIZE * 5, GRID_SIZE * 5];
|
||||
export const CONFIGURABLE_NODE_SIZE: [number, number] = [GRID_SIZE * 16, GRID_SIZE * 6];
|
||||
export const DEFAULT_START_POSITION_X = GRID_SIZE * 11;
|
||||
export const DEFAULT_START_POSITION_Y = GRID_SIZE * 15;
|
||||
export const HEADER_HEIGHT = 65;
|
||||
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = GRID_SIZE * 15;
|
||||
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
|
||||
export const PUSH_NODES_OFFSET = DEFAULT_NODE_SIZE[0] * 2 + GRID_SIZE;
|
||||
export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = {
|
||||
xMin: -Infinity,
|
||||
yMin: -Infinity,
|
||||
@@ -50,6 +48,10 @@ export const DEFAULT_VIEWPORT_BOUNDARIES: ViewportBoundaries = {
|
||||
yMax: Infinity,
|
||||
};
|
||||
|
||||
// The top-center of the configuration node is not a multiple of GRID_SIZE,
|
||||
// therefore we need to offset non-main inputs to align with the nodes top-center
|
||||
export const CONFIGURATION_NODE_OFFSET = (CONFIGURATION_NODE_SIZE[0] / 2) % GRID_SIZE;
|
||||
|
||||
/**
|
||||
* Utility functions for returning nodes found at the edges of a group
|
||||
*/
|
||||
@@ -110,8 +112,10 @@ export const getNodesGroupSize = (nodes: INodeUi[]): [number, number] => {
|
||||
const rightMostNode = getRightMostNode(nodes);
|
||||
const bottomMostNode = getBottomMostNode(nodes);
|
||||
|
||||
const width = Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + NODE_SIZE;
|
||||
const height = Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + NODE_SIZE;
|
||||
const width =
|
||||
Math.abs(rightMostNode.position[0] - leftMostNode.position[0]) + DEFAULT_NODE_SIZE[0];
|
||||
const height =
|
||||
Math.abs(bottomMostNode.position[1] - topMostNode.position[1]) + DEFAULT_NODE_SIZE[1];
|
||||
|
||||
return [width, height];
|
||||
};
|
||||
@@ -155,6 +159,13 @@ const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): num
|
||||
return inputNumber2;
|
||||
};
|
||||
|
||||
export function snapPositionToGrid(position: XYPosition): XYPosition {
|
||||
return [
|
||||
closestNumberDivisibleBy(position[0], GRID_SIZE),
|
||||
closestNumberDivisibleBy(position[1], GRID_SIZE),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new position for a node based on the given position and the nodes in the workflow
|
||||
*/
|
||||
@@ -173,13 +184,8 @@ export const getNewNodePosition = (
|
||||
normalize?: boolean;
|
||||
} = {},
|
||||
): XYPosition => {
|
||||
const resolvedOffset = [...offset];
|
||||
resolvedOffset[0] = closestNumberDivisibleBy(resolvedOffset[0], GRID_SIZE);
|
||||
resolvedOffset[1] = closestNumberDivisibleBy(resolvedOffset[1], GRID_SIZE);
|
||||
|
||||
const resolvedPosition: XYPosition = [...initialPosition];
|
||||
resolvedPosition[0] = closestNumberDivisibleBy(resolvedPosition[0], GRID_SIZE);
|
||||
resolvedPosition[1] = closestNumberDivisibleBy(resolvedPosition[1], GRID_SIZE);
|
||||
const resolvedOffset = snapPositionToGrid(offset);
|
||||
const resolvedPosition: XYPosition = snapPositionToGrid(initialPosition);
|
||||
|
||||
if (normalize) {
|
||||
let conflictFound = false;
|
||||
@@ -290,7 +296,7 @@ export const getNodesWithNormalizedPosition = <T extends { position: XYPosition
|
||||
const diffY = DEFAULT_START_POSITION_Y - leftmostTop.position[1];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.position[0] += diffX + NODE_SIZE * 2;
|
||||
node.position[0] += diffX + DEFAULT_NODE_SIZE[0] * 2;
|
||||
node.position[1] += diffY;
|
||||
});
|
||||
}
|
||||
@@ -610,18 +616,20 @@ export function calculateNodeSize(
|
||||
nonMainInputCount: number,
|
||||
): { width: number; height: number } {
|
||||
const maxVerticalHandles = Math.max(mainInputCount, mainOutputCount, 1);
|
||||
const height = 100 + Math.max(0, maxVerticalHandles - 3) * GRID_SIZE * 2;
|
||||
const height = DEFAULT_NODE_SIZE[1] + Math.max(0, maxVerticalHandles - 2) * GRID_SIZE * 2;
|
||||
|
||||
if (isConfigurable) {
|
||||
return {
|
||||
width: (Math.max(NODE_MIN_INPUT_ITEMS_COUNT - 1, nonMainInputCount) * 2 + 4) * GRID_SIZE,
|
||||
height: isConfiguration ? 75 : height,
|
||||
width:
|
||||
Math.max(NODE_MIN_INPUT_ITEMS_COUNT, nonMainInputCount) * GRID_SIZE * 4 +
|
||||
CONFIGURATION_NODE_OFFSET * 2,
|
||||
height: isConfiguration ? CONFIGURATION_NODE_SIZE[1] : height,
|
||||
};
|
||||
}
|
||||
|
||||
if (isConfiguration) {
|
||||
return { width: GRID_SIZE * 4, height: GRID_SIZE * 4 };
|
||||
return { width: CONFIGURATION_NODE_SIZE[0], height: CONFIGURATION_NODE_SIZE[1] };
|
||||
}
|
||||
|
||||
return { width: 100, height };
|
||||
return { width: DEFAULT_NODE_SIZE[0], height };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user