feat(editor): Add plus handle design with ability to add connected nodes in new canvas (no-changelog) (#10097)

This commit is contained in:
Alex Grozav
2024-07-18 19:01:14 +03:00
committed by GitHub
parent 7a135df768
commit 11db5a5b51
29 changed files with 665 additions and 369 deletions

View File

@@ -1,7 +1,13 @@
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeHandleKey, CanvasNodeKey } from '@/constants';
import { ref } from 'vue'; import { ref } from 'vue';
import type { CanvasNode, CanvasNodeData } from '@/types'; import type {
import { CanvasNodeRenderType } from '@/types'; CanvasNode,
CanvasNodeData,
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
export function createCanvasNodeData({ export function createCanvasNodeData({
id = 'node', id = 'node',
@@ -11,7 +17,7 @@ export function createCanvasNodeData({
disabled = false, disabled = false,
inputs = [], inputs = [],
outputs = [], outputs = [],
connections = { input: {}, output: {} }, connections = { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
execution = { running: false }, execution = { running: false },
issues = { items: [], visible: false }, issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false }, pinnedData = { count: 0, visible: false },
@@ -73,7 +79,12 @@ export function createCanvasNodeProvide({
label = 'Test Node', label = 'Test Node',
selected = false, selected = false,
data = {}, data = {},
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasNodeData> } = {}) { }: {
id?: string;
label?: string;
selected?: boolean;
data?: Partial<CanvasNodeData>;
} = {}) {
const props = createCanvasNodeProps({ id, label, selected, data }); const props = createCanvasNodeProps({ id, label, selected, data });
return { return {
[`${CanvasNodeKey}`]: { [`${CanvasNodeKey}`]: {
@@ -81,7 +92,28 @@ export function createCanvasNodeProvide({
label: ref(props.label), label: ref(props.label),
selected: ref(props.selected), selected: ref(props.selected),
data: ref(props.data), data: ref(props.data),
}, } satisfies CanvasNodeInjectionData,
};
}
export function createCanvasHandleProvide({
label = 'Handle',
mode = CanvasConnectionMode.Input,
type = NodeConnectionType.Main,
connected = false,
}: {
label?: string;
mode?: CanvasConnectionMode;
type?: NodeConnectionType;
connected?: boolean;
} = {}) {
return {
[`${CanvasNodeHandleKey}`]: {
label: ref(label),
mode: ref(mode),
type: ref(type),
connected: ref(connected),
} satisfies CanvasNodeHandleInjectionData,
}; };
} }

View File

@@ -26,6 +26,7 @@ const emit = defineEmits<{
'update:node:selected': [id: string]; 'update:node:selected': [id: string];
'update:node:name': [id: string]; 'update:node:name': [id: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>]; 'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'click:node:add': [id: string, handle: string];
'run:node': [id: string]; 'run:node': [id: string];
'delete:node': [id: string]; 'delete:node': [id: string];
'create:node': [source: NodeCreatorOpenSource]; 'create:node': [source: NodeCreatorOpenSource];
@@ -108,6 +109,10 @@ const paneReady = ref(false);
* Nodes * Nodes
*/ */
function onClickNodeAdd(id: string, handle: string) {
emit('click:node:add', id, handle);
}
function onNodeDragStop(e: NodeDragEvent) { function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => { e.nodes.forEach((node) => {
onUpdateNodePosition(node.id, node.position); onUpdateNodePosition(node.id, node.position);
@@ -351,6 +356,7 @@ onPaneReady(async () => {
@open:contextmenu="onOpenNodeContextMenu" @open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters" @update="onUpdateNodeParameters"
@move="onUpdateNodePosition" @move="onUpdateNodePosition"
@add="onClickNodeAdd"
/> />
</template> </template>

View File

@@ -4,6 +4,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { Position } from '@vue-flow/core'; import { Position } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
sourceX: 0, sourceX: 0,
@@ -14,8 +15,8 @@ const DEFAULT_PROPS = {
targetPosition: Position.Bottom, targetPosition: Position.Bottom,
data: { data: {
status: undefined, status: undefined,
source: { index: 0, type: 'main' }, source: { index: 0, type: NodeConnectionType.Main },
target: { index: 0, type: 'main' }, target: { index: 0, type: NodeConnectionType.Main },
}, },
} satisfies Partial<CanvasEdgeProps>; } satisfies Partial<CanvasEdgeProps>;
const renderComponent = createComponentRenderer(CanvasEdge, { const renderComponent = createComponentRenderer(CanvasEdge, {

View File

@@ -1,20 +1,21 @@
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue'; import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants'; import { CanvasNodeHandleKey } from '@/constants';
import { ref } from 'vue'; import { ref } from 'vue';
import { CanvasConnectionMode } from '@/types';
const renderComponent = createComponentRenderer(HandleRenderer); const renderComponent = createComponentRenderer(CanvasHandleRenderer);
const Handle = { const Handle = {
template: '<div><slot /></div>', template: '<div><slot /></div>',
}; };
describe('HandleRenderer', () => { describe('CanvasHandleRenderer', () => {
it('should render the main input handle correctly', async () => { it('should render the main input handle correctly', async () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
mode: 'input', mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
index: 0, index: 0,
position: 'left', position: 'left',
@@ -29,13 +30,13 @@ describe('HandleRenderer', () => {
}); });
expect(container.querySelector('.handle')).toBeInTheDocument(); expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument(); expect(container.querySelector('.inputs.main')).toBeInTheDocument();
}); });
it('should render the main output handle correctly', async () => { it('should render the main output handle correctly', async () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
mode: 'output', mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
index: 0, index: 0,
position: 'right', position: 'right',
@@ -50,13 +51,13 @@ describe('HandleRenderer', () => {
}); });
expect(container.querySelector('.handle')).toBeInTheDocument(); expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.canvas-node-handle-main-output')).toBeInTheDocument(); expect(container.querySelector('.outputs.main')).toBeInTheDocument();
}); });
it('should render the non-main handle correctly', async () => { it('should render the non-main handle correctly', async () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
mode: 'input', mode: CanvasConnectionMode.Input,
type: NodeConnectionType.AiTool, type: NodeConnectionType.AiTool,
index: 0, index: 0,
position: 'top', position: 'top',
@@ -71,7 +72,7 @@ describe('HandleRenderer', () => {
}); });
expect(container.querySelector('.handle')).toBeInTheDocument(); expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.canvas-node-handle-non-main')).toBeInTheDocument(); expect(container.querySelector('.inputs.ai_tool')).toBeInTheDocument();
}); });
it('should provide the label correctly', async () => { it('should provide the label correctly', async () => {

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
/* eslint-disable vue/no-multiple-template-root */
import { computed, h, provide, toRef, useCssModule } from 'vue';
import type { CanvasConnectionPort, CanvasElementPortWithRenderData } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { ValidConnectionFunc } from '@vue-flow/core';
import { Handle } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue';
import { CanvasNodeHandleKey } from '@/constants';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
const props = defineProps<{
mode: CanvasConnectionMode;
connected?: boolean;
label?: string;
type: CanvasConnectionPort['type'];
index: CanvasConnectionPort['index'];
position: CanvasElementPortWithRenderData['position'];
offset: CanvasElementPortWithRenderData['offset'];
isValidConnection: ValidConnectionFunc;
}>();
const emit = defineEmits<{
add: [handle: string];
}>();
defineOptions({
inheritAttrs: false,
});
const style = useCssModule();
const handleType = computed(() =>
props.mode === CanvasConnectionMode.Input ? 'target' : 'source',
);
const handleString = computed(() =>
createCanvasConnectionHandleString({
mode: props.mode,
type: props.type,
index: props.index,
}),
);
const isConnectableStart = computed(() => {
return props.mode === CanvasConnectionMode.Output || props.type !== NodeConnectionType.Main;
});
const isConnectableEnd = computed(() => {
return props.mode === CanvasConnectionMode.Input || props.type !== NodeConnectionType.Main;
});
const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]);
/**
* Render additional elements
*/
const hasRenderType = computed(() => {
return (
(props.type === NodeConnectionType.Main && props.mode === CanvasConnectionMode.Output) ||
props.type !== NodeConnectionType.Main
);
});
const renderTypeClasses = computed(() => [style.renderType, style[props.position]]);
const RenderType = () => {
let Component;
if (props.mode === CanvasConnectionMode.Output) {
if (props.type === NodeConnectionType.Main) {
Component = CanvasHandleMainOutput;
}
} else {
if (props.type !== NodeConnectionType.Main) {
Component = CanvasHandleNonMainInput;
}
}
return Component ? h(Component) : null;
};
/**
* Event bindings
*/
function onAdd() {
emit('add', handleString.value);
}
/**
* Provide
*/
const label = toRef(props, 'label');
const connected = toRef(props, 'connected');
const mode = toRef(props, 'mode');
const type = toRef(props, 'type');
provide(CanvasNodeHandleKey, {
label,
mode,
type,
connected,
});
</script>
<template>
<Handle
v-bind="$attrs"
:id="handleString"
:class="handleClasses"
:type="handleType"
:position="position"
:style="offset"
:connectable-start="isConnectableStart"
:connectable-end="isConnectableEnd"
:is-valid-connection="isValidConnection"
/>
<RenderType
v-if="hasRenderType"
:class="renderTypeClasses"
:connected="connected"
:style="offset"
:label="label"
@add="onAdd"
/>
</template>
<style lang="scss" module>
.handle {
width: 16px;
height: 16px;
display: inline-flex;
justify-content: center;
align-items: center;
border: 0;
z-index: 1;
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
&.inputs {
&.main {
width: 8px;
border-radius: 0;
}
&:not(.main) {
width: 14px;
height: 14px;
transform-origin: 0 0;
transform: rotate(45deg) translate(2px, 2px);
border-radius: 0;
background: hsl(
var(--node-type-supplemental-color-h) var(--node-type-supplemental-color-s)
var(--node-type-supplemental-color-l)
);
&:hover {
background: var(--color-primary);
}
}
}
}
.renderType {
position: absolute;
z-index: 0;
&.top {
top: 0;
transform: translate(-50%, -50%);
}
&.right {
right: 0;
transform: translate(100%, -50%);
}
&.left {
left: 0;
transform: translate(-50%, -50%);
}
&.bottom {
bottom: 0;
transform: translate(-50%, 50%);
}
}
</style>

View File

@@ -1,91 +0,0 @@
<script lang="ts" setup>
import { computed, h, provide, toRef, useCssModule } from 'vue';
import type { CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
import type { ValidConnectionFunc } from '@vue-flow/core';
import { Handle } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import CanvasHandleNonMain from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue';
import { CanvasNodeHandleKey } from '@/constants';
const props = defineProps<{
mode: 'output' | 'input';
label?: string;
type: CanvasConnectionPort['type'];
index: CanvasConnectionPort['index'];
position: CanvasElementPortWithPosition['position'];
offset: CanvasElementPortWithPosition['offset'];
isValidConnection: ValidConnectionFunc;
}>();
const $style = useCssModule();
const handleType = computed(() => (props.mode === 'input' ? 'target' : 'source'));
const isConnectableStart = computed(() => {
return props.mode === 'output' || props.type !== NodeConnectionType.Main;
});
const isConnectableEnd = computed(() => {
return props.mode === 'input' || props.type !== NodeConnectionType.Main;
});
const Render = (renderProps: { label?: string }) => {
let Component;
if (props.type === NodeConnectionType.Main) {
if (props.mode === 'input') {
Component = CanvasHandleMainInput;
} else {
Component = CanvasHandleMainOutput;
}
} else {
Component = CanvasHandleNonMain;
}
return h(Component, renderProps);
};
/**
* Provide
*/
const label = toRef(props, 'label');
provide(CanvasNodeHandleKey, {
label,
});
</script>
<template>
<Handle
:id="`${mode}s/${type}/${index}`"
:class="[$style.handle]"
:type="handleType"
:position="position"
:style="offset"
:connectable-start="isConnectableStart"
:connectable-end="isConnectableEnd"
:is-valid-connection="isValidConnection"
>
<Render :label="label" />
</Handle>
</template>
<style lang="scss" module>
.handle {
width: auto;
height: auto;
display: inline-flex;
justify-content: center;
align-items: center;
border: 0;
background: none;
> * {
pointer-events: none;
}
}
</style>

View File

@@ -1,22 +0,0 @@
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants';
import { ref } from 'vue';
const renderComponent = createComponentRenderer(CanvasHandleMainInput);
describe('CanvasHandleMainInput', () => {
it('should render correctly', async () => {
const label = 'Test Label';
const { container, getByText } = renderComponent({
global: {
provide: {
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
},
},
});
expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
});
});

View File

@@ -1,39 +0,0 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeHandleKey } from '@/constants';
const handle = inject(CanvasNodeHandleKey);
const $style = useCssModule();
const label = computed(() => handle?.label.value ?? '');
</script>
<template>
<div :class="['canvas-node-handle-main-input', $style.handle]">
<div :class="$style.label">{{ label }}</div>
</div>
</template>
<style lang="scss" module>
.handle {
width: 8px;
height: 16px;
border-radius: 0;
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
.label {
position: absolute;
top: 50%;
right: 12px;
transform: translate(0, -50%);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-background-light);
z-index: 1;
}
</style>

View File

@@ -1,7 +1,6 @@
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue'; import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants'; import { createCanvasHandleProvide } from '@/__tests__/data';
import { ref } from 'vue';
const renderComponent = createComponentRenderer(CanvasHandleMainOutput); const renderComponent = createComponentRenderer(CanvasHandleMainOutput);
@@ -11,7 +10,7 @@ describe('CanvasHandleMainOutput', () => {
const { container, getByText } = renderComponent({ const { container, getByText } = renderComponent({
global: { global: {
provide: { provide: {
[`${CanvasNodeHandleKey}`]: { label: ref(label) }, ...createCanvasHandleProvide({ label }),
}, },
}, },
}); });

View File

@@ -1,89 +1,27 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { CanvasNodeHandleKey } from '@/constants'; import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
const handle = inject(CanvasNodeHandleKey); const emit = defineEmits<{
add: [];
}>();
// const group = svg.node('g'); const { label, connected } = useCanvasNodeHandle();
// const containerBorder = svg.node('rect', {
// rx: 3,
// 'stroke-width': 2,
// fillOpacity: 0,
// height: ep.params.dimensions - 2,
// width: ep.params.dimensions - 2,
// y: 1,
// x: 1,
// });
// const plusPath = svg.node('path', {
// d: 'm16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z',
// });
// if (ep.params.size !== 'medium') {
// ep.addClass(ep.params.size);
// }
// group.appendChild(containerBorder);
// group.appendChild(plusPath);
//
// ep.setupOverlays();
// ep.setVisible(false);
// return group;
const $style = useCssModule(); function onClickAdd() {
emit('add');
const label = computed(() => handle?.label.value ?? ''); }
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-main-output', $style.handle]"> <div :class="['canvas-node-handle-main-output', $style.handle]">
<div :class="$style.label">{{ label }}</div> <div :class="$style.label">{{ label }}</div>
<div :class="$style.circle" /> <CanvasHandlePlus v-if="!connected" @click:plus="onClickAdd" />
<!-- @TODO Determine whether handle is connected and find a way to make it work without pointer-events: none -->
<!-- <svg :class="$style.plus" viewBox="0 0 70 24">-->
<!-- <line x1="0" y1="12" x2="46" y2="12" stroke="var(&#45;&#45;color-foreground-xdark)" />-->
<!-- <rect-->
<!-- x="46"-->
<!-- y="2"-->
<!-- width="20"-->
<!-- height="20"-->
<!-- stroke="var(&#45;&#45;color-foreground-xdark)"-->
<!-- stroke-width="2"-->
<!-- rx="4"-->
<!-- fill="#ffffff"-->
<!-- />-->
<!-- <g transform="translate(44, 0)">-->
<!-- <path-->
<!-- fill="var(&#45;&#45;color-foreground-xdark)"-->
<!-- d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"-->
<!-- ></path>-->
<!-- </g>-->
<!-- </svg>-->
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.handle { .handle {
width: 16px; :global(.vue-flow__handle:not(.connectionindicator)) + & {
height: 16px;
}
.circle {
width: 16px;
height: 16px;
border-radius: 100%;
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
.plus {
position: absolute;
left: 16px;
top: 50%;
transform: translate(0, -50%);
width: 70px;
height: 24px;
:global(.vue-flow__handle.connecting) & {
display: none; display: none;
} }
} }
@@ -91,7 +29,7 @@ const label = computed(() => handle?.label.value ?? '');
.label { .label {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 20px; left: var(--spacing-s);
transform: translate(0, -50%); transform: translate(0, -50%);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--color-foreground-xdark);

View File

@@ -1,49 +0,0 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeHandleKey } from '@/constants';
const handle = inject(CanvasNodeHandleKey);
const $style = useCssModule();
const label = computed(() => handle?.label.value ?? '');
</script>
<template>
<div :class="['canvas-node-handle-non-main', $style.handle]">
<div :class="$style.diamond" />
<div :class="$style.label">{{ label }}</div>
</div>
</template>
<style lang="scss" module>
.handle {
width: 14px;
height: 14px;
border-radius: 0;
}
.diamond {
width: 14px;
height: 14px;
transform: rotate(45deg);
background: hsl(
var(--node-type-supplemental-color-h) var(--node-type-supplemental-color-s)
var(--node-type-supplemental-color-l)
);
&:hover {
background: var(--color-primary);
}
}
.label {
position: absolute;
top: 18px;
left: 50%;
transform: translate(-50%, 0);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-background-light);
z-index: 1;
}
</style>

View File

@@ -1,17 +1,16 @@
import CanvasHandleNonMain from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMain.vue'; import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants'; import { createCanvasHandleProvide } from '@/__tests__/data';
import { ref } from 'vue';
const renderComponent = createComponentRenderer(CanvasHandleNonMain); const renderComponent = createComponentRenderer(CanvasHandleNonMainInput);
describe('CanvasHandleNonMain', () => { describe('CanvasHandleNonMainInput', () => {
it('should render correctly', async () => { it('should render correctly', async () => {
const label = 'Test Label'; const label = 'Test Label';
const { container, getByText } = renderComponent({ const { container, getByText } = renderComponent({
global: { global: {
provide: { provide: {
[`${CanvasNodeHandleKey}`]: { label: ref(label) }, ...createCanvasHandleProvide({ label }),
}, },
}, },
}); });

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { NodeConnectionType } from 'n8n-workflow';
import { computed } from 'vue';
const emit = defineEmits<{
add: [];
}>();
const { label, connected, type } = useCanvasNodeHandle();
const isAddButtonVisible = computed(
() => !connected.value || type.value === NodeConnectionType.AiTool,
);
function onClickAdd() {
emit('add');
}
</script>
<template>
<div :class="['canvas-node-handle-non-main', $style.handle]">
<div :class="$style.label">{{ label }}</div>
<CanvasHandlePlus v-if="isAddButtonVisible" :class="$style.plus" @click="onClickAdd" />
</div>
</template>
<style lang="scss" module>
.handle {
:global(.vue-flow__handle:not(.connectionindicator)) + & {
display: none;
}
}
.label {
position: absolute;
top: 18px;
left: 50%;
transform: translate(-50%, 0);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-background-light);
z-index: 1;
}
.plus {
transform: rotate(90deg) translateX(50%);
}
</style>

View File

@@ -0,0 +1,54 @@
import { fireEvent } from '@testing-library/vue';
import CanvasHandlePlus from './CanvasHandlePlus.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasHandleProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasHandlePlus, {
global: {
provide: {
...createCanvasHandleProvide(),
},
},
});
describe('CanvasHandlePlus', () => {
it('should render with default props', () => {
const { html } = renderComponent();
expect(html()).toMatchSnapshot();
});
it('emits click:plus event when plus icon is clicked', async () => {
const { container, emitted } = renderComponent();
const plusIcon = container.querySelector('svg.plus');
if (!plusIcon) throw new Error('Plus icon not found');
await fireEvent.click(plusIcon);
expect(emitted()).toHaveProperty('click:plus');
});
it('applies correct classes based on position prop', () => {
const positions = ['top', 'right', 'bottom', 'left'];
positions.forEach((position) => {
const { container } = renderComponent({
props: { position },
});
expect(container.firstChild).toHaveClass(position);
});
});
it('renders SVG elements correctly', () => {
const { container } = renderComponent();
const lineSvg = container.querySelector('svg.line');
expect(lineSvg).toBeTruthy();
expect(lineSvg?.getAttribute('viewBox')).toBe('0 0 46 24');
const plusSvg = container.querySelector('svg.plus');
expect(plusSvg).toBeTruthy();
expect(plusSvg?.getAttribute('viewBox')).toBe('0 0 24 24');
});
});

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
const props = withDefaults(
defineProps<{
position: 'top' | 'right' | 'bottom' | 'left';
}>(),
{
position: 'right',
},
);
const emit = defineEmits<{
'click:plus': [event: MouseEvent];
}>();
const style = useCssModule();
const classes = computed(() => [style.wrapper, style[props.position]]);
function onClick(event: MouseEvent) {
emit('click:plus', event);
}
</script>
<template>
<div :class="classes">
<svg :class="$style.line" viewBox="0 0 46 24">
<line
x1="0"
y1="12"
x2="46"
y2="12"
stroke="var(--color-foreground-xdark)"
stroke-width="2"
/>
</svg>
<svg :class="$style.plus" viewBox="0 0 24 24" @click="onClick">
<rect
x="2"
y="2"
width="20"
height="20"
stroke="var(--color-foreground-xdark)"
stroke-width="2"
rx="4"
fill="#ffffff"
/>
<g transform="translate(0, 0)">
<path
fill="var(--color-foreground-xdark)"
d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"
></path>
</g>
</svg>
</div>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
width: 70px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.line {
width: 46px;
height: 24px;
}
.plus {
width: 24px;
height: 24px;
margin-left: -1px;
&:hover {
cursor: pointer;
path {
fill: var(--color-primary);
}
rect {
stroke: var(--color-primary);
}
}
}
</style>

View File

@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandlePlus > should render with default props 1`] = `
"<div class="wrapper right"><svg class="line" viewBox="0 0 46 24">
<line x1="0" y1="12" x2="46" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
</svg><svg class="plus" viewBox="0 0 24 24">
<rect x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect>
<g transform="translate(0, 0)">
<path fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path>
</g>
</svg></div>"
`;

View File

@@ -66,7 +66,7 @@ describe('CanvasNode', () => {
}, },
global: { global: {
stubs: { stubs: {
HandleRenderer: true, CanvasHandleRenderer: true,
}, },
}, },
}); });

View File

@@ -1,11 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, provide, toRef, watch } from 'vue'; import { computed, provide, toRef, watch } from 'vue';
import type { CanvasNodeData, CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types'; import type {
CanvasNodeData,
CanvasConnectionPort,
CanvasElementPortWithRenderData,
} from '@/types';
import { CanvasConnectionMode } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue'; import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue'; import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue'; import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue';
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { useContextMenu } from '@/composables/useContextMenu'; import { useContextMenu } from '@/composables/useContextMenu';
@@ -13,6 +18,7 @@ import { Position } from '@vue-flow/core';
import type { XYPosition, NodeProps } from '@vue-flow/core'; import type { XYPosition, NodeProps } from '@vue-flow/core';
const emit = defineEmits<{ const emit = defineEmits<{
add: [id: string, handle: string];
delete: [id: string]; delete: [id: string];
run: [id: string]; run: [id: string];
select: [id: string, selected: boolean]; select: [id: string, selected: boolean];
@@ -39,7 +45,7 @@ const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs, isValidConnectio
const isDisabled = computed(() => props.data.disabled); const isDisabled = computed(() => props.data.disabled);
const nodeType = computed(() => { const nodeTypeDescription = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion); return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
}); });
@@ -47,10 +53,22 @@ const nodeType = computed(() => {
* Inputs * Inputs
*/ */
const inputsWithPosition = computed(() => { const mappedInputs = computed(() => {
return [ return [
...mainInputs.value.map(mapEndpointWithPosition(Position.Left, 'top')), ...mainInputs.value.map(
...nonMainInputs.value.map(mapEndpointWithPosition(Position.Bottom, 'left')), createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Left,
offsetAxis: 'top',
}),
),
...nonMainInputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Bottom,
offsetAxis: 'left',
}),
),
]; ];
}); });
@@ -58,10 +76,22 @@ const inputsWithPosition = computed(() => {
* Outputs * Outputs
*/ */
const outputsWithPosition = computed(() => { const mappedOutputs = computed(() => {
return [ return [
...mainOutputs.value.map(mapEndpointWithPosition(Position.Right, 'top')), ...mainOutputs.value.map(
...nonMainOutputs.value.map(mapEndpointWithPosition(Position.Top, 'left')), createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Right,
offsetAxis: 'top',
}),
),
...nonMainOutputs.value.map(
createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Top,
offsetAxis: 'left',
}),
),
]; ];
}); });
@@ -77,15 +107,24 @@ const nodeIconSize = computed(() =>
* Endpoints * Endpoints
*/ */
const mapEndpointWithPosition = const createEndpointMappingFn =
(position: Position, offsetAxis: 'top' | 'left') => ({
mode,
position,
offsetAxis,
}: {
mode: CanvasConnectionMode;
position: Position;
offsetAxis: 'top' | 'left';
}) =>
( (
endpoint: CanvasConnectionPort, endpoint: CanvasConnectionPort,
index: number, index: number,
endpoints: CanvasConnectionPort[], endpoints: CanvasConnectionPort[],
): CanvasElementPortWithPosition => { ): CanvasElementPortWithRenderData => {
return { return {
...endpoint, ...endpoint,
connected: !!connections.value[mode][endpoint.type]?.[endpoint.index]?.length,
position, position,
offset: { offset: {
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`, [offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
@@ -97,6 +136,10 @@ const mapEndpointWithPosition =
* Events * Events
*/ */
function onAdd(handle: string) {
emit('add', props.id, handle);
}
function onDelete() { function onDelete() {
emit('delete', props.id); emit('delete', props.id);
} }
@@ -142,7 +185,6 @@ provide(CanvasNodeKey, {
data, data,
label, label,
selected, selected,
nodeType,
}); });
const showToolbar = computed(() => { const showToolbar = computed(() => {
@@ -156,8 +198,8 @@ const showToolbar = computed(() => {
watch( watch(
() => props.selected, () => props.selected,
(selected) => { (value) => {
emit('select', props.id, selected); emit('select', props.id, value);
}, },
); );
</script> </script>
@@ -167,34 +209,44 @@ watch(
:class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]" :class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]"
data-test-id="canvas-node" data-test-id="canvas-node"
> >
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`"> <template
<HandleRenderer v-for="source in mappedOutputs"
mode="output" :key="`${CanvasConnectionMode.Output}/${source.type}/${source.index}`"
>
<CanvasHandleRenderer
data-test-id="canvas-node-output-handle" data-test-id="canvas-node-output-handle"
:connected="source.connected"
:mode="CanvasConnectionMode.Output"
:type="source.type" :type="source.type"
:label="source.label" :label="source.label"
:index="source.index" :index="source.index"
:position="source.position" :position="source.position"
:offset="source.offset" :offset="source.offset"
:is-valid-connection="isValidConnection" :is-valid-connection="isValidConnection"
@add="onAdd"
/> />
</template> </template>
<template v-for="target in inputsWithPosition" :key="`${target.type}/${target.index}`"> <template
<HandleRenderer v-for="target in mappedInputs"
mode="input" :key="`${CanvasConnectionMode.Input}/${target.type}/${target.index}`"
>
<CanvasHandleRenderer
data-test-id="canvas-node-input-handle" data-test-id="canvas-node-input-handle"
:connected="!!connections[CanvasConnectionMode.Input][target.type]?.[target.index]?.length"
:mode="CanvasConnectionMode.Input"
:type="target.type" :type="target.type"
:label="target.label" :label="target.label"
:index="target.index" :index="target.index"
:position="target.position" :position="target.position"
:offset="target.offset" :offset="target.offset"
:is-valid-connection="isValidConnection" :is-valid-connection="isValidConnection"
@add="onAdd"
/> />
</template> </template>
<CanvasNodeToolbar <CanvasNodeToolbar
v-if="nodeType" v-if="nodeTypeDescription"
data-test-id="canvas-node-toolbar" data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar" :class="$style.canvasNodeToolbar"
@delete="onDelete" @delete="onDelete"
@@ -210,8 +262,8 @@ watch(
@open:contextmenu="onOpenContextMenuFromNode" @open:contextmenu="onOpenContextMenuFromNode"
> >
<NodeIcon <NodeIcon
v-if="nodeType" v-if="nodeTypeDescription"
:node-type="nodeType" :node-type="nodeTypeDescription"
:size="nodeIconSize" :size="nodeIconSize"
:shrink="false" :shrink="false"
:disabled="isDisabled" :disabled="isDisabled"

View File

@@ -2,6 +2,7 @@ import CanvasNodeDisabledStrikeThrough from '@/components/canvas/elements/nodes/
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data'; import { createCanvasNodeProvide } from '@/__tests__/data';
import { CanvasConnectionMode } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough); const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
@@ -13,12 +14,12 @@ describe('CanvasNodeDisabledStrikeThrough', () => {
...createCanvasNodeProvide({ ...createCanvasNodeProvide({
data: { data: {
connections: { connections: {
input: { [CanvasConnectionMode.Input]: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
], ],
}, },
output: { [CanvasConnectionMode.Output]: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
], ],

View File

@@ -6,7 +6,10 @@ import { CanvasConnectionMode } from '@/types';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
describe('useNodeConnections', () => { describe('useNodeConnections', () => {
const defaultConnections = { input: {}, output: {} }; const defaultConnections = {
[CanvasConnectionMode.Input]: {},
[CanvasConnectionMode.Output]: {},
};
describe('mainInputs', () => { describe('mainInputs', () => {
it('should return main inputs when provided with main inputs', () => { it('should return main inputs when provided with main inputs', () => {
const inputs = ref<CanvasNodeData['inputs']>([ const inputs = ref<CanvasNodeData['inputs']>([
@@ -73,13 +76,13 @@ describe('useNodeConnections', () => {
const inputs = ref<CanvasNodeData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const connections = ref<CanvasNodeData['connections']>({ const connections = ref<CanvasNodeData['connections']>({
input: { [CanvasConnectionMode.Input]: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
], ],
}, },
output: {}, [CanvasConnectionMode.Output]: {},
}); });
const { mainInputConnections } = useNodeConnections({ const { mainInputConnections } = useNodeConnections({
@@ -89,7 +92,9 @@ describe('useNodeConnections', () => {
}); });
expect(mainInputConnections.value.length).toBe(2); expect(mainInputConnections.value.length).toBe(2);
expect(mainInputConnections.value).toEqual(connections.value.input[NodeConnectionType.Main]); expect(mainInputConnections.value).toEqual(
connections.value[CanvasConnectionMode.Input][NodeConnectionType.Main],
);
}); });
}); });
@@ -139,8 +144,8 @@ describe('useNodeConnections', () => {
const inputs = ref<CanvasNodeData['inputs']>([]); const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([]); const outputs = ref<CanvasNodeData['outputs']>([]);
const connections = ref<CanvasNodeData['connections']>({ const connections = ref<CanvasNodeData['connections']>({
input: {}, [CanvasConnectionMode.Input]: {},
output: { [CanvasConnectionMode.Output]: {
[NodeConnectionType.Main]: [ [NodeConnectionType.Main]: [
[{ node: 'node1', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node1', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'node2', type: NodeConnectionType.Main, index: 0 }], [{ node: 'node2', type: NodeConnectionType.Main, index: 0 }],
@@ -156,7 +161,7 @@ describe('useNodeConnections', () => {
expect(mainOutputConnections.value.length).toBe(2); expect(mainOutputConnections.value.length).toBe(2);
expect(mainOutputConnections.value).toEqual( expect(mainOutputConnections.value).toEqual(
connections.value.output[NodeConnectionType.Main], connections.value[CanvasConnectionMode.Output][NodeConnectionType.Main],
); );
}); });
}); });

View File

@@ -122,11 +122,11 @@ describe('useCanvasMapping', () => {
}, },
], ],
connections: { connections: {
input: {}, [CanvasConnectionMode.Input]: {},
output: {}, [CanvasConnectionMode.Output]: {},
}, },
render: { render: {
type: 'default', type: CanvasNodeRenderType.Default,
options: { options: {
configurable: false, configurable: false,
configuration: false, configuration: false,
@@ -205,10 +205,14 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref<Workflow>, workflowObject: ref(workflowObject) as Ref<Workflow>,
}); });
expect(mappedNodes.value[0]?.data?.connections.output).toHaveProperty( expect(mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output]).toHaveProperty(
NodeConnectionType.Main, NodeConnectionType.Main,
); );
expect(mappedNodes.value[0]?.data?.connections.output[NodeConnectionType.Main][0][0]).toEqual( expect(
mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output][
NodeConnectionType.Main
][0][0],
).toEqual(
expect.objectContaining({ expect.objectContaining({
node: setNode.name, node: setNode.name,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,
@@ -216,8 +220,14 @@ describe('useCanvasMapping', () => {
}), }),
); );
expect(mappedNodes.value[1]?.data?.connections.input).toHaveProperty(NodeConnectionType.Main); expect(mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input]).toHaveProperty(
expect(mappedNodes.value[1]?.data?.connections.input[NodeConnectionType.Main][0][0]).toEqual( NodeConnectionType.Main,
);
expect(
mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input][
NodeConnectionType.Main
][0][0],
).toEqual(
expect.objectContaining({ expect.objectContaining({
node: manualTriggerNode.name, node: manualTriggerNode.name,
type: NodeConnectionType.Main, type: NodeConnectionType.Main,

View File

@@ -18,7 +18,7 @@ import type {
CanvasNodeDefaultRender, CanvasNodeDefaultRender,
CanvasNodeStickyNoteRender, CanvasNodeStickyNoteRender,
} from '@/types'; } from '@/types';
import { CanvasNodeRenderType } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { import {
mapLegacyConnectionsToCanvasConnections, mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort, mapLegacyEndpointsToCanvasConnectionPort,
@@ -263,8 +263,8 @@ export function useCanvasMapping({
inputs: nodeInputsById.value[node.id] ?? [], inputs: nodeInputsById.value[node.id] ?? [],
outputs: nodeOutputsById.value[node.id] ?? [], outputs: nodeOutputsById.value[node.id] ?? [],
connections: { connections: {
input: inputConnections, [CanvasConnectionMode.Input]: inputConnections,
output: outputConnections, [CanvasConnectionMode.Output]: outputConnections,
}, },
issues: { issues: {
items: nodeIssuesById.value[node.id], items: nodeIssuesById.value[node.id],

View File

@@ -1,7 +1,8 @@
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import type { CanvasNodeInjectionData } from '../types'; import type { CanvasNodeData, CanvasNodeInjectionData } from '../types';
import { CanvasNodeRenderType } from '../types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '../types';
import { NodeConnectionType } from 'n8n-workflow';
vi.mock('vue', async () => { vi.mock('vue', async () => {
const actual = await vi.importActual('vue'); const actual = await vi.importActual('vue');
@@ -18,7 +19,10 @@ describe('useCanvasNode', () => {
expect(result.label.value).toBe(''); expect(result.label.value).toBe('');
expect(result.inputs.value).toEqual([]); expect(result.inputs.value).toEqual([]);
expect(result.outputs.value).toEqual([]); expect(result.outputs.value).toEqual([]);
expect(result.connections.value).toEqual({ input: {}, output: {} }); expect(result.connections.value).toEqual({
[CanvasConnectionMode.Input]: {},
[CanvasConnectionMode.Output]: {},
});
expect(result.isDisabled.value).toBe(false); expect(result.isDisabled.value).toBe(false);
expect(result.isSelected.value).toBeUndefined(); expect(result.isSelected.value).toBeUndefined();
expect(result.pinnedDataCount.value).toBe(0); expect(result.pinnedDataCount.value).toBe(0);
@@ -41,9 +45,12 @@ describe('useCanvasNode', () => {
type: 'nodeType1', type: 'nodeType1',
typeVersion: 1, typeVersion: 1,
disabled: true, disabled: true,
inputs: [{ type: 'main', index: 0 }], inputs: [{ type: NodeConnectionType.Main, index: 0 }],
outputs: [{ type: 'main', index: 0 }], outputs: [{ type: NodeConnectionType.Main, index: 0 }],
connections: { input: { '0': [] }, output: {} }, connections: {
[CanvasConnectionMode.Input]: { '0': [] },
[CanvasConnectionMode.Output]: {},
},
issues: { items: ['issue1'], visible: true }, issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: 'waiting', running: true }, execution: { status: 'running', waiting: 'waiting', running: true },
runData: { count: 1, visible: true }, runData: { count: 1, visible: true },
@@ -56,7 +63,7 @@ describe('useCanvasNode', () => {
trigger: false, trigger: false,
}, },
}, },
}), } satisfies CanvasNodeData),
id: ref('1'), id: ref('1'),
label: ref('Node 1'), label: ref('Node 1'),
selected: ref(true), selected: ref(true),
@@ -68,9 +75,12 @@ describe('useCanvasNode', () => {
expect(result.label.value).toBe('Node 1'); expect(result.label.value).toBe('Node 1');
expect(result.name.value).toBe('Node 1'); expect(result.name.value).toBe('Node 1');
expect(result.inputs.value).toEqual([{ type: 'main', index: 0 }]); expect(result.inputs.value).toEqual([{ type: NodeConnectionType.Main, index: 0 }]);
expect(result.outputs.value).toEqual([{ type: 'main', index: 0 }]); expect(result.outputs.value).toEqual([{ type: NodeConnectionType.Main, index: 0 }]);
expect(result.connections.value).toEqual({ input: { '0': [] }, output: {} }); expect(result.connections.value).toEqual({
[CanvasConnectionMode.Input]: { '0': [] },
[CanvasConnectionMode.Output]: {},
});
expect(result.isDisabled.value).toBe(true); expect(result.isDisabled.value).toBe(true);
expect(result.isSelected.value).toBe(true); expect(result.isSelected.value).toBe(true);
expect(result.pinnedDataCount.value).toBe(1); expect(result.pinnedDataCount.value).toBe(1);

View File

@@ -6,7 +6,7 @@
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import type { CanvasNodeData } from '@/types'; import type { CanvasNodeData } from '@/types';
import { CanvasNodeRenderType } from '@/types'; import { CanvasNodeRenderType, CanvasConnectionMode } from '@/types';
export function useCanvasNode() { export function useCanvasNode() {
const node = inject(CanvasNodeKey); const node = inject(CanvasNodeKey);
@@ -20,7 +20,7 @@ export function useCanvasNode() {
disabled: false, disabled: false,
inputs: [], inputs: [],
outputs: [], outputs: [],
connections: { input: {}, output: {} }, connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
issues: { items: [], visible: false }, issues: { items: [], visible: false },
pinnedData: { count: 0, visible: false }, pinnedData: { count: 0, visible: false },
execution: { execution: {

View File

@@ -0,0 +1,25 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { CanvasNodeHandleKey } from '@/constants';
import { computed, inject } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
import { CanvasConnectionMode } from '@/types';
export function useCanvasNodeHandle() {
const handle = inject(CanvasNodeHandleKey);
const label = computed(() => handle?.label.value ?? '');
const connected = computed(() => handle?.connected.value ?? false);
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
return {
label,
connected,
type,
mode,
};
}

View File

@@ -1,4 +1,5 @@
import type { CanvasNodeData } from '@/types'; import type { CanvasNodeData } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { MaybeRef } from 'vue'; import type { MaybeRef } from 'vue';
import { computed, unref } from 'vue'; import { computed, unref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@@ -31,7 +32,7 @@ export function useNodeConnections({
); );
const mainInputConnections = computed( const mainInputConnections = computed(
() => unref(connections).input[NodeConnectionType.Main] ?? [], () => unref(connections)[CanvasConnectionMode.Input][NodeConnectionType.Main] ?? [],
); );
/** /**
@@ -47,7 +48,7 @@ export function useNodeConnections({
); );
const mainOutputConnections = computed( const mainOutputConnections = computed(
() => unref(connections).output[NodeConnectionType.Main] ?? [], () => unref(connections)[CanvasConnectionMode.Output][NodeConnectionType.Main] ?? [],
); );
/** /**

View File

@@ -1,17 +1,16 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */
import type { import type {
ConnectionTypes,
ExecutionStatus, ExecutionStatus,
IConnection,
INodeConnections, INodeConnections,
INodeTypeDescription, IConnection,
NodeConnectionType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core'; import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue'; import type { Ref } from 'vue';
import type { PartialBy } from '@/utils/typeHelpers'; import type { PartialBy } from '@/utils/typeHelpers';
export type CanvasConnectionPortType = ConnectionTypes; export type CanvasConnectionPortType = NodeConnectionType;
export const enum CanvasConnectionMode { export const enum CanvasConnectionMode {
Input = 'inputs', Input = 'inputs',
@@ -30,7 +29,8 @@ export type CanvasConnectionPort = {
label?: string; label?: string;
}; };
export interface CanvasElementPortWithPosition extends CanvasConnectionPort { export interface CanvasElementPortWithRenderData extends CanvasConnectionPort {
connected: boolean;
position: Position; position: Position;
offset?: { top?: string; left?: string }; offset?: { top?: string; left?: string };
} }
@@ -74,8 +74,8 @@ export interface CanvasNodeData {
inputs: CanvasConnectionPort[]; inputs: CanvasConnectionPort[];
outputs: CanvasConnectionPort[]; outputs: CanvasConnectionPort[];
connections: { connections: {
input: INodeConnections; [CanvasConnectionMode.Input]: INodeConnections;
output: INodeConnections; [CanvasConnectionMode.Output]: INodeConnections;
}; };
issues: { issues: {
items: string[]; items: string[];
@@ -122,11 +122,13 @@ export interface CanvasNodeInjectionData {
data: Ref<CanvasNodeData>; data: Ref<CanvasNodeData>;
label: Ref<NodeProps['label']>; label: Ref<NodeProps['label']>;
selected: Ref<NodeProps['selected']>; selected: Ref<NodeProps['selected']>;
nodeType: ComputedRef<INodeTypeDescription | null>;
} }
export interface CanvasNodeHandleInjectionData { export interface CanvasNodeHandleInjectionData {
label: Ref<string | undefined>; label: Ref<string | undefined>;
mode: Ref<CanvasConnectionMode>;
type: Ref<NodeConnectionType>;
connected: Ref<boolean | undefined>;
} }
export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string }; export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string };

View File

@@ -148,7 +148,8 @@ export function mapLegacyEndpointsToCanvasConnectionPort(
} }
return endpoints.map((endpoint, endpointIndex) => { return endpoints.map((endpoint, endpointIndex) => {
const type = typeof endpoint === 'string' ? endpoint : endpoint.type; 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' ? undefined : endpoint.displayName;
const index = const index =
endpoints endpoints

View File

@@ -560,6 +560,16 @@ function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>)
setNodeParameters(id, parameters); setNodeParameters(id, parameters);
} }
function onClickNodeAdd(source: string, sourceHandle: string) {
nodeCreatorStore.openNodeCreatorForConnectingNode({
connection: {
source,
sourceHandle,
},
eventSource: NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
});
}
/** /**
* Credentials * Credentials
*/ */
@@ -1248,6 +1258,7 @@ onBeforeUnmount(() => {
@update:node:enabled="onToggleNodeDisabled" @update:node:enabled="onToggleNodeDisabled"
@update:node:name="onOpenRenameNodeModal" @update:node:name="onOpenRenameNodeModal"
@update:node:parameters="onUpdateNodeParameters" @update:node:parameters="onUpdateNodeParameters"
@click:node:add="onClickNodeAdd"
@run:node="onRunWorkflowToNode" @run:node="onRunWorkflowToNode"
@delete:node="onDeleteNode" @delete:node="onDeleteNode"
@create:connection="onCreateConnection" @create:connection="onCreateConnection"