Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Add remove node and connections functionality to canvas v2 #9602

Merged
merged 10 commits into from
Jun 4, 2024
6 changes: 3 additions & 3 deletions packages/editor-ui/src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,11 @@ export function createTestWorkflow(options: {
} as IWorkflowDb;
}

export function createTestNode(
node: Partial<INode> & { name: INode['name']; type: INode['type'] },
): INode {
export function createTestNode(node: Partial<INode> = {}): INode {
return {
id: uuid(),
name: 'Node',
type: 'n8n-nodes-base.test',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
Expand Down
37 changes: 32 additions & 5 deletions packages/editor-ui/src/components/canvas/Canvas.vue
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
<script lang="ts" setup>
import type { CanvasConnection, CanvasElement } from '@/types';
import type { NodeDragEvent, Connection } from '@vue-flow/core';
import { VueFlow, PanelPosition } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import CanvasNode from './elements/nodes/CanvasNode.vue';
import CanvasEdge from './elements/edges/CanvasEdge.vue';
import { useCssModule } from 'vue';
import { onMounted, onUnmounted, useCssModule } from 'vue';

const $style = useCssModule();

const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]];
'update:node:position': [id: string, position: { x: number; y: number }];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
'create:connection': [connection: Connection];
}>();

withDefaults(
const props = withDefaults(
defineProps<{
id?: string;
elements: CanvasElement[];
Expand All @@ -32,15 +34,40 @@ withDefaults(
},
);

const { getSelectedEdges, getSelectedNodes } = useVueFlow({ id: props.id });

onMounted(() => {
document.addEventListener('keydown', onKeyDown);
});

onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown);
});

function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => {
emit('update:node:position', node.id, node.position);
});
}

function onDeleteNode(id: string) {
emit('delete:node', id);
}

function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}

function onConnect(...args: unknown[]) {
emit('create:connection', args[0] as Connection);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete') {
getSelectedEdges.value.forEach(onDeleteConnection);
getSelectedNodes.value.forEach(({ id }) => onDeleteNode(id));
}
}
</script>

<template>
Expand All @@ -58,11 +85,11 @@ function onConnect(...args: unknown[]) {
@connect="onConnect"
>
<template #node-canvas-node="canvasNodeProps">
<CanvasNode v-bind="canvasNodeProps" />
<CanvasNode v-bind="canvasNodeProps" @delete="onDeleteNode" />
</template>

<template #edge-canvas-edge="canvasEdgeProps">
<CanvasEdge v-bind="canvasEdgeProps" />
<CanvasEdge v-bind="canvasEdgeProps" @delete="onDeleteConnection" />
</template>

<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
<script lang="ts" setup>
import type { EdgeProps } from '@vue-flow/core';
import { BaseEdge, getBezierPath } from '@vue-flow/core';
import { computed } from 'vue';
/* eslint-disable vue/no-multiple-template-root */
import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
import { computed, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';

const emit = defineEmits<{
delete: [connection: Connection];
}>();

const props = defineProps<EdgeProps>();

const i18n = useI18n();
const $style = useCssModule();

const edgeStyle = computed(() => ({
strokeWidth: 2,
...props.style,
}));

const edgeLabelStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${path.value[1]}px,${path.value[2]}px)`,
}));

const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
Expand All @@ -20,6 +33,17 @@ const path = computed(() =>
targetPosition: props.targetPosition,
}),
);

const connection = computed<Connection>(() => ({
source: props.source,
target: props.target,
sourceHandle: props.sourceHandleId,
targetHandle: props.targetHandleId,
}));

function onDelete() {
emit('delete', connection.value);
}
</script>

<template>
Expand All @@ -37,4 +61,23 @@ const path = computed(() =>
:label-bg-padding="[2, 4]"
:label-bg-border-radius="2"
/>
<EdgeLabelRenderer>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to have multiple template roots here? If yes, can we use fragments for this? Otherwise, we can just wrap them in a container

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what we're doing here. There's no explicit syntax for fragments in Vue(like <>..</> in React). It's just ESlint not "realizing" this is Vue 3 project. So, I wouldn't add another container as those would add up quickly for each edge.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I thought we are not handling the props correctly or something and that's why eslint is complaining. Does it make sense to turn off this rule for editor-ui?

<div :class="[$style.edgeToolbar, 'nodrag', 'nopan']" :style="edgeLabelStyle">
<N8nIconButton
data-test-id="delete-connection-button"
type="tertiary"
size="small"
icon="trash"
:title="i18n.baseText('node.delete')"
@click="onDelete"
/>
</div>
</EdgeLabelRenderer>
</template>

<style lang="scss" module>
.edgeToolbar {
pointer-events: all;
position: absolute;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import type { NodeProps } from '@vue-flow/core';

const emit = defineEmits<{
delete: [id: string];
}>();

const props = defineProps<NodeProps<CanvasElementData>>();

const inputs = computed(() => props.data.inputs);
Expand Down Expand Up @@ -89,6 +93,10 @@ provide(CanvasNodeKey, {
selected,
nodeType,
});

function onDelete() {
emit('delete', props.id);
}
</script>

<template>
Expand Down Expand Up @@ -121,6 +129,7 @@ provide(CanvasNodeKey, {
v-if="nodeType"
data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar"
@delete="onDelete"
/>

<CanvasNodeRenderer v-if="nodeType">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';

const emit = defineEmits(['delete']);
const $style = useCssModule();

const node = inject(CanvasNodeKey);
OlegIvaniv marked this conversation as resolved.
Show resolved Hide resolved

const data = computed(() => node?.data.value);
const i18n = useI18n();

const $style = useCssModule();
const data = computed(() => node?.data.value);

// @TODO
const workflowRunning = false;
Expand All @@ -20,8 +24,9 @@ function executeNode() {}
// @TODO
function toggleDisableNode() {}

// @TODO
function deleteNode() {}
function deleteNode() {
emit('delete');
}

// @TODO
function openContextMenu(_e: MouseEvent, _type: string) {}
Expand All @@ -38,7 +43,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
icon="play"
:disabled="workflowRunning"
:title="$locale.baseText('node.testStep')"
:title="i18n.baseText('node.testStep')"
@click="executeNode"
/>
<N8nIconButton
Expand All @@ -56,7 +61,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
text
icon="trash"
:title="$locale.baseText('node.delete')"
:title="i18n.baseText('node.delete')"
@click="deleteNode"
/>
<N8nIconButton
Expand Down
Loading
Loading