feat(editor): Implement custom edge to support loops (no-changelog) (#10171)

Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Elias Meire
2024-07-24 14:47:18 +02:00
committed by GitHub
parent b81f0bf9ea
commit 79bccf0305
14 changed files with 236 additions and 46 deletions

View File

@@ -12,6 +12,7 @@ import { NodeConnectionType } from 'n8n-workflow';
export function createCanvasNodeData({
id = 'node',
name = 'Test Node',
subtitle = 'Test Node Subtitle',
type = 'test',
typeVersion = 1,
disabled = false,
@@ -30,6 +31,7 @@ export function createCanvasNodeData({
return {
id,
name,
subtitle,
type,
typeVersion,
execution,

View File

@@ -401,7 +401,7 @@ watch(() => props.readOnly, setReadonly, {
/>
</template>
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE" />
<MiniMap data-test-id="canvas-minimap" pannable />

View File

@@ -91,4 +91,35 @@ describe('CanvasEdge', () => {
stroke: 'var(--color-secondary)',
});
});
it('should render a correct bezier path', () => {
const { container } = renderComponent({
props: DEFAULT_PROPS,
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveAttribute('d', 'M0,0 C0,-62.5 100,162.5 100,100');
});
it('should render a correct smooth step path when the connection is backwards', () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: -100,
targetY: -100,
targetPosition: Position.Left,
},
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveAttribute(
'd',
'M0 0L 32,0Q 40,0 40,8L 40,132Q 40,140 32,140L1 140L0 140M0 140L-40 140L -132,140Q -140,140 -140,132L -140,-92Q -140,-100 -132,-100L-100 -100',
);
});
});

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup>
/* eslint-disable vue/no-multiple-template-root */
import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { computed, useCssModule } from 'vue';
import type { CanvasConnectionData } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import { computed, useCssModule } from 'vue';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { getCustomPath } from './utils/edgePath';
const emit = defineEmits<{
add: [connection: Connection];
@@ -66,8 +67,9 @@ const edgeLabelStyle = computed(() => ({
}));
const edgeToolbarStyle = computed(() => {
const [, labelX, labelY] = path.value;
return {
transform: `translate(-50%, -50%) translate(${path.value[1]}px,${path.value[2]}px)`,
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
};
});
@@ -78,16 +80,7 @@ const edgeToolbarClasses = computed(() => ({
nopan: true,
}));
const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}),
);
const path = computed(() => getCustomPath(props));
const connection = computed<Connection>(() => ({
source: props.source,
@@ -118,6 +111,7 @@ function onDelete() {
:label-style="edgeLabelStyle"
:label-show-bg="false"
/>
<EdgeLabelRenderer v-if="!readOnly">
<CanvasEdgeToolbar
:type="connectionType"

View File

@@ -0,0 +1,45 @@
import { getBezierPath, getSmoothStepPath, Position, type EdgeProps } from '@vue-flow/core';
const EDGE_PADDING_Y = 140;
const EDGE_PADDING_Y_TOP = 80;
const EDGE_BORDER_RADIUS = 8;
const EDGE_OFFSET = 40;
export function getCustomPath(props: EdgeProps) {
const { targetX, targetY, sourceX, sourceY, sourcePosition, targetPosition } = props;
const xDiff = targetX - sourceX;
const yDiff = targetY - sourceY;
// Connection is backwards and the source is on the right side
// -> We need to avoid overlapping the source node
if (xDiff < 0 && sourcePosition === Position.Right) {
const direction = yDiff < -EDGE_PADDING_Y || yDiff > 0 ? 'up' : 'down';
const firstSegmentTargetX = sourceX;
const firstSegmentTargetY =
sourceY + (direction === 'up' ? -EDGE_PADDING_Y_TOP : EDGE_PADDING_Y);
const [firstSegmentPath] = getSmoothStepPath({
sourceX,
sourceY,
targetX: firstSegmentTargetX,
targetY: firstSegmentTargetY,
sourcePosition,
targetPosition: Position.Right,
borderRadius: EDGE_BORDER_RADIUS,
offset: EDGE_OFFSET,
});
const path = getSmoothStepPath({
sourceX: firstSegmentTargetX,
sourceY: firstSegmentTargetY,
targetX,
targetY,
sourcePosition: Position.Left,
targetPosition,
borderRadius: EDGE_BORDER_RADIUS,
offset: EDGE_OFFSET,
});
path[0] = firstSegmentPath + path[0];
return path;
}
return getBezierPath(props);
}

View File

@@ -18,6 +18,7 @@ const emit = defineEmits<{
const {
label,
subtitle,
inputs,
outputs,
connections,
@@ -102,17 +103,20 @@ function openContextMenu(event: MouseEvent) {
</N8nTooltip>
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
<div :class="$style.description">
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
--canvas-node--height: calc(96px + max(0, var(--canvas-node--main-output-count, 1) - 4) * 48px);
--canvas-node--width: 96px;
--canvas-node--height: calc(100px + max(0, var(--canvas-node--main-output-count, 1) - 4) * 48px);
--canvas-node--width: 100px;
--canvas-node-border-width: 2px;
--configurable-node--min-input-count: 4;
--configurable-node--input-width: 64px;
@@ -154,13 +158,13 @@ function openContextMenu(event: MouseEvent) {
}
&.configurable {
--canvas-node--height: 96px;
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node--input-count, 5), var(--configurable-node--min-input-count)) *
var(--configurable-node--input-width)
);
.label {
.description {
top: unset;
position: relative;
margin-left: var(--spacing-s);
@@ -205,14 +209,30 @@ function openContextMenu(event: MouseEvent) {
}
}
.label {
.description {
top: 100%;
position: absolute;
font-size: var(--font-size-m);
text-align: center;
width: 100%;
min-width: 200px;
margin-top: var(--spacing-2xs);
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
align-items: center;
}
.label {
font-size: var(--font-size-m);
line-height: var(--font-line-height-compact);
text-align: center;
}
.subtitle {
color: var(--color-text-light);
font-size: var(--font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.statusIcons {

View File

@@ -12,10 +12,19 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
<!--v-if-->
<!--v-if-->
<div
class="label"
class="description"
>
Test Node
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
@@ -32,10 +41,19 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
<!--v-if-->
<!--v-if-->
<div
class="label"
class="description"
>
Test Node
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
@@ -52,10 +70,19 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
<!--v-if-->
<!--v-if-->
<div
class="label"
class="description"
>
Test Node
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
@@ -72,10 +99,19 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
<!--v-if-->
<!--v-if-->
<div
class="label"
class="description"
>
Test Node
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
@@ -115,10 +151,19 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
<!--v-if-->
<!--v-if-->
<div
class="label"
class="description"
>
Test Node
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;

View File

@@ -20,6 +20,7 @@ import {
createCanvasConnectionId,
} from '@/utils/canvasUtilsV2';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { MarkerType } from '@vue-flow/core';
beforeEach(() => {
const pinia = createPinia();
@@ -87,6 +88,7 @@ describe('useCanvasMapping', () => {
data: {
id: manualTriggerNode.id,
name: manualTriggerNode.name,
subtitle: '',
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
@@ -381,6 +383,7 @@ describe('useCanvasMapping', () => {
},
id: connectionId,
label: '',
markerEnd: MarkerType.ArrowClosed,
source,
sourceHandle,
target,
@@ -469,6 +472,7 @@ describe('useCanvasMapping', () => {
},
id: connectionIdA,
label: '',
markerEnd: MarkerType.ArrowClosed,
source: sourceA,
sourceHandle: sourceHandleA,
target: targetA,
@@ -496,6 +500,7 @@ describe('useCanvasMapping', () => {
target: targetB,
targetHandle: targetHandleB,
type: 'canvas-edge',
markerEnd: MarkerType.ArrowClosed,
animated: false,
},
]);

View File

@@ -33,8 +33,10 @@ import type {
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { STICKY_NODE_TYPE, WAIT_TIME_UNLIMITED } from '@/constants';
import { CUSTOM_API_CALL_KEY, STICKY_NODE_TYPE, WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers';
export function useCanvasMapping({
nodes,
@@ -48,6 +50,7 @@ export function useCanvasMapping({
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
return {
@@ -97,9 +100,30 @@ export function useCanvasMapping({
}, {}) ?? {},
);
const nodeSubtitleById = computed(() => {
return nodes.value.reduce<Record<string, string>>((acc, node) => {
try {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeTypeDescription) {
return acc;
}
const nodeSubtitle =
nodeHelpers.getNodeSubtitle(node, nodeTypeDescription, workflowObject.value) ?? '';
if (nodeSubtitle.includes(CUSTOM_API_CALL_KEY)) {
return acc;
}
acc[node.id] = nodeSubtitle;
} catch (e) {}
return acc;
}, {});
});
const nodeInputsById = computed(() =>
nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] =
@@ -110,6 +134,7 @@ export function useCanvasMapping({
workflowObjectNode,
nodeTypeDescription,
),
nodeTypeDescription.inputNames ?? [],
)
: [];
@@ -119,7 +144,7 @@ export function useCanvasMapping({
const nodeOutputsById = computed(() =>
nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] =
@@ -130,6 +155,7 @@ export function useCanvasMapping({
workflowObjectNode,
nodeTypeDescription,
),
nodeTypeDescription.outputNames ?? [],
)
: [];
@@ -260,6 +286,7 @@ export function useCanvasMapping({
const data: CanvasNodeData = {
id: node.id,
name: node.name,
subtitle: nodeSubtitleById.value[node.id] ?? '',
type: node.type,
typeVersion: node.typeVersion,
disabled: !!node.disabled,
@@ -313,6 +340,7 @@ export function useCanvasMapping({
type,
label,
animated: data.status === 'running',
markerEnd: MarkerType.ArrowClosed,
};
},
);

View File

@@ -42,6 +42,7 @@ describe('useCanvasNode', () => {
data: ref({
id: 'node1',
name: 'Node 1',
subtitle: '',
type: 'nodeType1',
typeVersion: 1,
disabled: true,

View File

@@ -15,6 +15,7 @@ export function useCanvasNode() {
node?.data.value ?? {
id: '',
name: '',
subtitle: '',
type: '',
typeVersion: 1,
disabled: false,
@@ -37,6 +38,7 @@ export function useCanvasNode() {
const id = computed(() => node?.id.value ?? '');
const label = computed(() => node?.label.value ?? '');
const subtitle = computed(() => data.value.subtitle);
const name = computed(() => data.value.name);
const inputs = computed(() => data.value.inputs);
const outputs = computed(() => data.value.outputs);
@@ -66,6 +68,7 @@ export function useCanvasNode() {
id,
name,
label,
subtitle,
inputs,
outputs,
connections,

View File

@@ -68,6 +68,7 @@ export type CanvasNodeStickyNoteRender = {
export interface CanvasNodeData {
id: INodeUi['id'];
name: INodeUi['name'];
subtitle: string;
type: INodeUi['type'];
typeVersion: INodeUi['typeVersion'];
disabled: INodeUi['disabled'];

View File

@@ -776,6 +776,19 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
]);
});
it('should handle endpoints with separate names', () => {
const endpoints: INodeTypeDescription['inputs'] = [
NodeConnectionType.Main,
NodeConnectionType.Main,
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints, ['First', 'Second']);
expect(result).toEqual([
{ type: NodeConnectionType.Main, index: 0, label: 'First' },
{ type: NodeConnectionType.Main, index: 1, label: 'Second' },
]);
});
it('should map required and non-required endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = [
{ type: NodeConnectionType.Main, displayName: 'Main Input', required: true },

View File

@@ -167,6 +167,7 @@ export function mapCanvasConnectionToLegacyConnection(
export function mapLegacyEndpointsToCanvasConnectionPort(
endpoints: INodeTypeDescription['inputs'],
endpointNames: string[] = [],
): CanvasConnectionPort[] {
if (typeof endpoints === 'string') {
console.warn('Node endpoints have not been evaluated', endpoints);
@@ -176,7 +177,8 @@ export function mapLegacyEndpointsToCanvasConnectionPort(
return endpoints.map((endpoint, endpointIndex) => {
const typeValue = typeof endpoint === 'string' ? endpoint : endpoint.type;
const type = isValidNodeConnectionType(typeValue) ? typeValue : NodeConnectionType.Main;
const label = typeof endpoint === 'string' ? undefined : endpoint.displayName;
const label =
typeof endpoint === 'string' ? endpointNames[endpointIndex] : endpoint.displayName;
const index =
endpoints
.slice(0, endpointIndex + 1)