fix(editor): Improve configurable nodes design on new canvas (#12317)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run

This commit is contained in:
Alex Grozav 2025-01-08 13:11:20 +02:00 committed by GitHub
parent 8cce588209
commit 0ecce10faf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 182 additions and 42 deletions

View file

@ -84,6 +84,33 @@ describe('CanvasNode', () => {
expect(inputHandles.length).toBe(3);
expect(outputHandles.length).toBe(2);
});
it('should insert spacers after required non-main input handle', () => {
const { getAllByTestId } = renderComponent({
props: {
...createCanvasNodeProps({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0, required: true },
{ type: NodeConnectionType.AiTool, index: 0 },
],
outputs: [],
},
}),
},
global: {
stubs: {
Handle: true,
},
},
});
const inputHandles = getAllByTestId('canvas-node-input-handle');
expect(inputHandles[1]).toHaveStyle('left: 20%');
expect(inputHandles[2]).toHaveStyle('left: 80%');
});
});
describe('toolbar', () => {

View file

@ -28,7 +28,10 @@ import { useContextMenu } from '@/composables/useContextMenu';
import type { NodeProps, XYPosition } from '@vue-flow/core';
import { Position } from '@vue-flow/core';
import { useCanvas } from '@/composables/useCanvas';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import {
createCanvasConnectionHandleString,
insertSpacersBetweenEndpoints,
} from '@/utils/canvasUtilsV2';
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
import { isEqual } from 'lodash-es';
@ -77,8 +80,14 @@ const nodeClasses = ref<string[]>([]);
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections);
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs, isValidConnection } =
useNodeConnections({
const {
mainInputs,
nonMainInputs,
requiredNonMainInputs,
mainOutputs,
nonMainOutputs,
isValidConnection,
} = useNodeConnections({
inputs,
outputs,
connections,
@ -114,23 +123,15 @@ function emitCanvasNodeEvent(event: CanvasEventBusEvents['nodes:action']) {
* Inputs
*/
const nonMainInputsWithSpacer = computed(() =>
insertSpacersBetweenEndpoints(nonMainInputs.value, requiredNonMainInputs.value.length),
);
const mappedInputs = computed(() => {
return [
...mainInputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Left,
offsetAxis: 'top',
}),
),
...nonMainInputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Bottom,
offsetAxis: 'left',
}),
),
];
...mainInputs.value.map(mainInputsMappingFn),
...nonMainInputsWithSpacer.value.map(nonMainInputsMappingFn),
].filter((endpoint) => !!endpoint);
});
/**
@ -139,21 +140,9 @@ const mappedInputs = computed(() => {
const mappedOutputs = computed(() => {
return [
...mainOutputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Right,
offsetAxis: 'top',
}),
),
...nonMainOutputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Top,
offsetAxis: 'left',
}),
),
];
...mainOutputs.value.map(mainOutputsMappingFn),
...nonMainOutputs.value.map(nonMainOutputsMappingFn),
].filter((endpoint) => !!endpoint);
});
/**
@ -179,10 +168,14 @@ const createEndpointMappingFn =
offsetAxis: 'top' | 'left';
}) =>
(
endpoint: CanvasConnectionPort,
endpoint: CanvasConnectionPort | null,
index: number,
endpoints: CanvasConnectionPort[],
): CanvasElementPortWithRenderData => {
endpoints: Array<CanvasConnectionPort | null>,
): CanvasElementPortWithRenderData | undefined => {
if (!endpoint) {
return;
}
const handleId = createCanvasConnectionHandleString({
mode,
type: endpoint.type,
@ -207,6 +200,30 @@ const createEndpointMappingFn =
};
};
const mainInputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Left,
offsetAxis: 'top',
});
const nonMainInputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Bottom,
offsetAxis: 'left',
});
const mainOutputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Right,
offsetAxis: 'top',
});
const nonMainOutputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Top,
offsetAxis: 'left',
});
/**
* Events
*/

View file

@ -201,6 +201,12 @@ function openContextMenu(event: MouseEvent) {
var(--configurable-node--input-width)
);
justify-content: flex-start;
:global(.n8n-node-icon) {
margin-left: var(--configurable-node--icon-offset);
}
.description {
top: unset;
position: relative;

View file

@ -1,14 +1,15 @@
import {
checkOverlap,
createCanvasConnectionHandleString,
createCanvasConnectionId,
insertSpacersBetweenEndpoints,
mapCanvasConnectionToLegacyConnection,
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
parseCanvasConnectionHandleString,
checkOverlap,
} from '@/utils/canvasUtilsV2';
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { IConnections, INodeTypeDescription, IConnection } from 'n8n-workflow';
import type { CanvasConnection } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { INodeUi } from '@/Interface';
@ -976,3 +977,72 @@ describe('checkOverlap', () => {
expect(checkOverlap(node1, node2)).toBe(false);
});
});
describe('insertSpacersBetweenEndpoints', () => {
it('should insert spacers when there are less than min endpoints count', () => {
const endpoints = [{ index: 0, required: true }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
expect(result).toEqual([{ index: 0, required: true }, null, null, null]);
});
it('should not insert spacers when there are at least min endpoints count', () => {
const endpoints = [{ index: 0, required: true }, { index: 1 }, { index: 2 }, { index: 3 }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
expect(result).toEqual(endpoints);
});
it('should handle zero required endpoints', () => {
const endpoints = [{ index: 0, required: false }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
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, 4);
expect(result).toEqual([null, null, null, null]);
});
it('should handle required endpoints greater than min endpoints count', () => {
const endpoints = [
{ index: 0, required: true },
{ index: 1, required: true },
{ index: 2, required: true },
{ index: 3, required: true },
{ index: 4, required: true },
];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
expect(result).toEqual(endpoints);
});
it('should insert spacers between required and optional endpoints', () => {
const endpoints = [{ index: 0, required: true }, { index: 1, required: true }, { index: 2 }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
expect(result).toEqual([
{ index: 0, required: true },
{ index: 1, required: true },
null,
{ index: 2 },
]);
});
it('should handle required endpoints count greater than endpoints length', () => {
const endpoints = [{ index: 0, required: true }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 4);
expect(result).toEqual([{ index: 0, required: true }, null, null, null]);
});
it('should handle min endpoints count less than required endpoints count', () => {
const endpoints = [{ index: 0, required: false }];
const requiredEndpointsCount = endpoints.filter((endpoint) => endpoint.required).length;
const result = insertSpacersBetweenEndpoints(endpoints, requiredEndpointsCount, 0);
expect(result).toEqual([{ index: 0, required: false }]);
});
});

View file

@ -210,3 +210,23 @@ export function checkOverlap(node1: BoundingBox, node2: BoundingBox) {
)
);
}
export function insertSpacersBetweenEndpoints<T>(
endpoints: T[],
requiredEndpointsCount = 0,
minEndpointsCount = 4,
) {
const endpointsWithSpacers: Array<T | null> = [...endpoints];
const optionalNonMainInputsCount = endpointsWithSpacers.length - requiredEndpointsCount;
const spacerCount = minEndpointsCount - requiredEndpointsCount - optionalNonMainInputsCount;
// Insert `null` in between required non-main inputs and non-required non-main inputs
// to separate them visually if there are less than 4 inputs in total
if (endpointsWithSpacers.length < minEndpointsCount) {
for (let i = 0; i < spacerCount; i++) {
endpointsWithSpacers.splice(requiredEndpointsCount + i, 0, null);
}
}
return endpointsWithSpacers;
}