fix(editor): Make adjustments to status icon and connector port position in canvas (#16469)

This commit is contained in:
Suguru Inoue
2025-06-20 11:52:53 +02:00
committed by GitHub
parent 67852b826f
commit 3ea51c11cb
9 changed files with 234 additions and 235 deletions

View File

@@ -94,6 +94,7 @@ describe('CanvasNode', () => {
inputs: [
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.AiAgent, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0 },
{ type: NodeConnectionTypes.AiTool, index: 0 },
],
outputs: [],
@@ -109,8 +110,9 @@ describe('CanvasNode', () => {
const inputHandles = getAllByTestId('canvas-node-input-handle');
expect(inputHandles[1]).toHaveStyle('left: 20%');
expect(inputHandles[2]).toHaveStyle('left: 80%');
expect(inputHandles[1]).toHaveStyle('left: 40px');
expect(inputHandles[2]).toHaveStyle('left: 160px');
expect(inputHandles[3]).toHaveStyle('left: 200px');
});
});

View File

@@ -34,6 +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';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
@@ -183,6 +184,10 @@ const createEndpointMappingFn =
connectingHandle.value?.nodeId === props.id &&
connectingHandle.value?.handleType === handleType &&
connectingHandle.value?.handleId === handleId;
const offsetValue =
position === Position.Bottom
? `${GRID_SIZE * (2 + index * 2)}px`
: `${(100 / (endpoints.length + 1)) * (index + 1)}%`;
return {
...endpoint,
@@ -191,7 +196,7 @@ const createEndpointMappingFn =
isConnecting,
position,
offset: {
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
[offsetAxis]: offsetValue,
},
};
};

View File

@@ -33,84 +33,41 @@ describe('CanvasNodeDefault', () => {
expect(getByTestId('canvas-default-node')).toMatchSnapshot();
});
describe('inputs', () => {
it('should adjust height css variable based on the number of inputs (1 input)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [{ type: NodeConnectionTypes.Main, index: 0 }],
},
}),
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'],
])(
'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)',
(inputCount, outputCount, expected) => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: Array.from({ length: inputCount }).map(() => ({
type: NodeConnectionTypes.Main,
index: 0,
})),
outputs: Array.from({ length: outputCount }).map(() => ({
type: NodeConnectionTypes.Main,
index: 0,
})),
},
}),
},
},
},
});
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-input-count': '1' }); // height calculation based on the number of inputs
});
it('should adjust height css variable based on the number of inputs (multiple inputs)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.Main, index: 0 },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-input-count': '3' }); // height calculation based on the number of inputs
});
});
describe('outputs', () => {
it('should adjust height css variable based on the number of outputs (1 output)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
outputs: [{ type: NodeConnectionTypes.Main, index: 0 }],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '1' }); // height calculation based on the number of outputs
});
it('should adjust height css variable based on the number of outputs (multiple outputs)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
outputs: [
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.Main, index: 0 },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '3' }); // height calculation based on the number of outputs
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--height': expected });
},
);
});
describe('selected', () => {
@@ -244,33 +201,67 @@ describe('CanvasNodeDefault', () => {
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.AiTool, index: 0 },
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true },
],
render: {
type: CanvasNodeRenderType.Default,
options: {
configurable: true,
it.each([
[
'1 required',
[{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true }],
'240px',
],
[
'2 required, 1 optional',
[
{ type: NodeConnectionTypes.AiTool, index: 0 },
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true },
],
'240px',
],
[
'2 required, 2 optional',
[
{ type: NodeConnectionTypes.AiTool, index: 0 },
{ type: NodeConnectionTypes.AiLanguageModel, index: 0 },
{ type: NodeConnectionTypes.AiDocument, index: 0, required: true },
{ type: NodeConnectionTypes.AiMemory, index: 0, required: true },
],
'240px',
],
[
'1 required, 4 optional',
[
{ type: NodeConnectionTypes.AiLanguageModel, index: 0, required: true },
{ type: NodeConnectionTypes.AiTool, index: 0 },
{ type: NodeConnectionTypes.AiDocument, index: 0 },
{ type: NodeConnectionTypes.AiMemory, index: 0 },
{ type: NodeConnectionTypes.AiMemory, index: 0 },
],
'280px',
],
])(
'should adjust width css variable based on the number of non-main inputs (%s)',
(_, nonMainInputs, expected) => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [{ type: NodeConnectionTypes.Main, index: 0 }, ...nonMainInputs],
render: {
type: CanvasNodeRenderType.Default,
options: {
configurable: true,
},
},
},
},
}),
}),
},
},
},
});
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--configurable-node--input-count': '3' });
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--width': expected });
},
);
});
});

View File

@@ -3,10 +3,10 @@ import { computed, ref, useCssModule, watch } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@n8n/i18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
import { useNodeSettingsInCanvas } from '@/components/canvas/composables/useNodeSettingsInCanvas';
import { calculateNodeSize } from '@/utils/nodeViewUtils';
import ExperimentalCanvasNodeSettings from '../../../components/ExperimentalCanvasNodeSettings.vue';
const $style = useCssModule();
@@ -36,18 +36,12 @@ const {
hasIssues,
render,
} = useCanvasNode();
const {
mainOutputs,
mainOutputConnections,
mainInputs,
mainInputConnections,
nonMainInputs,
requiredNonMainInputs,
} = useNodeConnections({
inputs,
outputs,
connections,
});
const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, nonMainInputs } =
useNodeConnections({
inputs,
outputs,
connections,
});
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
@@ -71,29 +65,24 @@ const classes = computed(() => {
};
});
const styles = computed(() => {
const stylesObject: Record<string, string | number> = {};
const iconSize = computed(() => (renderOptions.value.configuration ? 30 : 40));
if (renderOptions.value.configurable) {
let spacerCount = 0;
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS && requiredNonMainInputs.value.length > 0) {
const requiredNonMainInputsCount = requiredNonMainInputs.value.length;
const optionalNonMainInputsCount = nonMainInputs.value.length - requiredNonMainInputsCount;
spacerCount = requiredNonMainInputsCount > 0 && optionalNonMainInputsCount > 0 ? 1 : 0;
}
const nodeSize = computed(() =>
calculateNodeSize(
renderOptions.value.configuration ?? false,
renderOptions.value.configurable ?? false,
mainInputs.value.length,
mainOutputs.value.length,
nonMainInputs.value.length,
),
);
stylesObject['--configurable-node--input-count'] = nonMainInputs.value.length + spacerCount;
}
if (nodeSettingsZoom.value !== undefined) {
stylesObject['--zoom'] = nodeSettingsZoom.value;
}
stylesObject['--canvas-node--main-input-count'] = mainInputs.value.length;
stylesObject['--canvas-node--main-output-count'] = mainOutputs.value.length;
return stylesObject;
});
const styles = computed(() => ({
'--canvas-node--width': `${nodeSize.value.width}px`,
'--canvas-node--height': `${nodeSize.value.height}px`,
'--node-icon-size': `${iconSize.value}px`,
...(nodeSettingsZoom.value === undefined ? {} : { '--zoom': nodeSettingsZoom.value }),
}));
const dataTestId = computed(() => {
let type = 'default';
@@ -117,8 +106,6 @@ const isStrikethroughVisible = computed(() => {
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
});
const iconSize = computed(() => (renderOptions.value.configuration ? 30 : 40));
const iconSource = computed(() => renderOptions.value.icon);
const showTooltip = ref(false);
@@ -156,8 +143,13 @@ function onActivate(event: MouseEvent) {
<ExperimentalCanvasNodeSettings v-if="nodeSettingsZoom !== undefined" :node-id="id" />
<template v-else>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<NodeIcon :icon-source="iconSource" :size="iconSize" :shrink="false" :disabled="isDisabled" />
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<NodeIcon
:icon-source="iconSource"
:size="iconSize"
:shrink="false"
:disabled="isDisabled"
:class="$style.icon"
/>
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">
@@ -168,24 +160,14 @@ function onActivate(event: MouseEvent) {
</div>
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
</div>
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
</template>
</div>
</template>
<style lang="scss" module>
.node {
--canvas-node--max-vertical-handles: max(
var(--canvas-node--main-input-count),
var(--canvas-node--main-output-count),
1
);
--canvas-node--height: calc(100px + max(0, var(--canvas-node--max-vertical-handles) - 3) * 42px);
--canvas-node--width: 100px;
--canvas-node-border-width: 2px;
--configurable-node--min-input-count: 4;
--configurable-node--input-width: 64px;
--configurable-node--icon-offset: 30px;
--configurable-node--icon-size: 30px;
--trigger-node--border-radius: 36px;
--canvas-node--status-icons-offset: var(--spacing-3xs);
--node-icon-color: var(--color-foreground-dark);
@@ -207,7 +189,6 @@ function onActivate(event: MouseEvent) {
}
&.settingsView {
/*margin-top: calc(var(--canvas-node--width) * 0.8);*/
height: calc(var(--canvas-node--height) * 2.4) !important;
width: calc(var(--canvas-node--width) * 1.6) !important;
align-items: flex-start;
@@ -226,13 +207,10 @@ function onActivate(event: MouseEvent) {
*/
&.configuration {
--canvas-node--width: 80px;
--canvas-node--height: 80px;
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: var(--canvas-node-border-width) solid
var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50px;
border-radius: calc(var(--canvas-node--height) / 2);
.statusIcons {
right: unset;
@@ -240,16 +218,8 @@ function onActivate(event: MouseEvent) {
}
&.configurable {
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node--input-count, 4), var(--configurable-node--min-input-count)) *
var(--configurable-node--input-width)
);
justify-content: flex-start;
:global(.n8n-node-icon) {
margin-left: var(--configurable-node--icon-offset);
.icon {
margin-left: calc(40px - (var(--node-icon-size)) / 2 - var(--canvas-node-border-width));
}
.description {
@@ -260,11 +230,10 @@ function onActivate(event: MouseEvent) {
margin-right: var(--spacing-s);
width: auto;
min-width: unset;
max-width: calc(
var(--canvas-node--width) - var(--configurable-node--icon-offset) - var(
--configurable-node--icon-size
) - 2 * var(--spacing-s)
);
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
flex-shrink: 1;
}
.label {
@@ -276,11 +245,19 @@ function onActivate(event: MouseEvent) {
}
&.configuration {
--canvas-node--height: 75px;
.icon {
margin-left: calc((var(--canvas-node--height) - var(--node-icon-size)) / 2);
}
.statusIcons {
right: calc(-1 * var(--spacing-2xs));
bottom: 0;
&:not(.running) {
.statusIcons {
position: static;
margin-right: var(--spacing-2xs);
}
}
.description {
margin-right: var(--spacing-xs);
}
}
}
@@ -333,7 +310,6 @@ function onActivate(event: MouseEvent) {
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
align-items: center;
}
.label,
@@ -367,4 +343,9 @@ function onActivate(event: MouseEvent) {
bottom: var(--canvas-node--status-icons-offset);
right: var(--canvas-node--status-icons-offset);
}
.icon {
flex-grow: 0;
flex-shrink: 0;
}
</style>

View File

@@ -4,12 +4,12 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
<div
class="node configurable"
data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
style="--canvas-node--width: 240px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon"
class="n8n-node-icon icon icon"
shrink="false"
>
<div
@@ -27,7 +27,6 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -43,6 +42,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
Test Node Subtitle
</div>
</div>
<!--v-if-->
</div>
`;
@@ -51,12 +51,12 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
<div
class="node configurable configuration"
data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
style="--canvas-node--width: 240px; --canvas-node--height: 75px; --node-icon-size: 30px;"
>
<!--v-if-->
<div
class="n8n-node-icon"
class="n8n-node-icon icon icon"
shrink="false"
>
<div
@@ -74,7 +74,6 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -90,6 +89,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
Test Node Subtitle
</div>
</div>
<!--v-if-->
</div>
`;
@@ -98,12 +98,12 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
<div
class="node configuration"
data-test-id="canvas-configuration-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
style="--canvas-node--width: 80px; --canvas-node--height: 80px; --node-icon-size: 30px;"
>
<!--v-if-->
<div
class="n8n-node-icon"
class="n8n-node-icon icon icon"
shrink="false"
>
<div
@@ -121,7 +121,6 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -137,6 +136,7 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
Test Node Subtitle
</div>
</div>
<!--v-if-->
</div>
`;
@@ -145,12 +145,12 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
<div
class="node"
data-test-id="canvas-default-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon"
class="n8n-node-icon icon icon"
shrink="false"
>
<div
@@ -168,7 +168,6 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -184,6 +183,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
Test Node Subtitle
</div>
</div>
<!--v-if-->
</div>
`;
@@ -192,12 +192,12 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
<div
class="node trigger"
data-test-id="canvas-trigger-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
style="--canvas-node--width: 100px; --canvas-node--height: 100px; --node-icon-size: 40px;"
>
<!--v-if-->
<div
class="n8n-node-icon"
class="n8n-node-icon icon icon"
shrink="false"
>
<div
@@ -215,7 +215,6 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
</div>
</div>
<!--v-if-->
<!--v-if-->
<div
class="description"
>
@@ -231,6 +230,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
Test Node Subtitle
</div>
</div>
<!--v-if-->
</div>
`;