Skip to content

Commit

Permalink
feat(editor): Add plus handle design with ability to add connected no…
Browse files Browse the repository at this point in the history
…des in new canvas (no-changelog) (#10097)
  • Loading branch information
alexgrozav authored Jul 18, 2024
1 parent 7a135df commit 11db5a5
Show file tree
Hide file tree
Showing 29 changed files with 665 additions and 369 deletions.
44 changes: 38 additions & 6 deletions packages/editor-ui/src/__tests__/data/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { CanvasNodeKey } from '@/constants';
import { CanvasNodeHandleKey, CanvasNodeKey } from '@/constants';
import { ref } from 'vue';
import type { CanvasNode, CanvasNodeData } from '@/types';
import { CanvasNodeRenderType } from '@/types';
import type {
CanvasNode,
CanvasNodeData,
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';

export function createCanvasNodeData({
id = 'node',
Expand All @@ -11,7 +17,7 @@ export function createCanvasNodeData({
disabled = false,
inputs = [],
outputs = [],
connections = { input: {}, output: {} },
connections = { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
execution = { running: false },
issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false },
Expand Down Expand Up @@ -73,15 +79,41 @@ export function createCanvasNodeProvide({
label = 'Test Node',
selected = false,
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 });
return {
[`${CanvasNodeKey}`]: {
id: ref(props.id),
label: ref(props.label),
selected: ref(props.selected),
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,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/editor-ui/src/components/canvas/Canvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const emit = defineEmits<{
'update:node:selected': [id: string];
'update:node:name': [id: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
'delete:node': [id: string];
'create:node': [source: NodeCreatorOpenSource];
Expand Down Expand Up @@ -108,6 +109,10 @@ const paneReady = ref(false);
* Nodes
*/
function onClickNodeAdd(id: string, handle: string) {
emit('click:node:add', id, handle);
}
function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => {
onUpdateNodePosition(node.id, node.position);
Expand Down Expand Up @@ -351,6 +356,7 @@ onPaneReady(async () => {
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
/>
</template>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { Position } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';

const DEFAULT_PROPS = {
sourceX: 0,
Expand All @@ -14,8 +15,8 @@ const DEFAULT_PROPS = {
targetPosition: Position.Bottom,
data: {
status: undefined,
source: { index: 0, type: 'main' },
target: { index: 0, type: 'main' },
source: { index: 0, type: NodeConnectionType.Main },
target: { index: 0, type: NodeConnectionType.Main },
},
} satisfies Partial<CanvasEdgeProps>;
const renderComponent = createComponentRenderer(CanvasEdge, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants';
import { ref } from 'vue';
import { CanvasConnectionMode } from '@/types';

const renderComponent = createComponentRenderer(HandleRenderer);
const renderComponent = createComponentRenderer(CanvasHandleRenderer);

const Handle = {
template: '<div><slot /></div>',
};

describe('HandleRenderer', () => {
describe('CanvasHandleRenderer', () => {
it('should render the main input handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: 'input',
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
position: 'left',
Expand All @@ -29,13 +30,13 @@ describe('HandleRenderer', () => {
});

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 () => {
const { container } = renderComponent({
props: {
mode: 'output',
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
position: 'right',
Expand All @@ -50,13 +51,13 @@ describe('HandleRenderer', () => {
});

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 () => {
const { container } = renderComponent({
props: {
mode: 'input',
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.AiTool,
index: 0,
position: 'top',
Expand All @@ -71,7 +72,7 @@ describe('HandleRenderer', () => {
});

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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit 11db5a5

Please sign in to comment.