diff --git a/packages/editor-ui/src/__tests__/data/canvas.ts b/packages/editor-ui/src/__tests__/data/canvas.ts index d14e9cc742..d91ac2629b 100644 --- a/packages/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/editor-ui/src/__tests__/data/canvas.ts @@ -110,19 +110,22 @@ export function createCanvasHandleProvide({ label = 'Handle', mode = CanvasConnectionMode.Input, type = NodeConnectionType.Main, - connected = false, + isConnected = false, + isConnecting = false, }: { label?: string; mode?: CanvasConnectionMode; type?: NodeConnectionType; - connected?: boolean; + isConnected?: boolean; + isConnecting?: boolean; } = {}) { return { [`${CanvasNodeHandleKey}`]: { label: ref(label), mode: ref(mode), type: ref(type), - connected: ref(connected), + isConnected: ref(isConnected), + isConnecting: ref(isConnecting), } satisfies CanvasNodeHandleInjectionData, }; } diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index 866bc644f7..2ce9ac9d28 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -12,7 +12,7 @@ import { Background } from '@vue-flow/background'; import { MiniMap } from '@vue-flow/minimap'; import Node from './elements/nodes/CanvasNode.vue'; import Edge from './elements/edges/CanvasEdge.vue'; -import { computed, onMounted, onUnmounted, ref, useCssModule, watch } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, useCssModule, watch } from 'vue'; import type { EventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system'; import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu'; @@ -22,6 +22,7 @@ import type { NodeCreatorOpenSource } from '@/Interface'; import type { PinDataSource } from '@/composables/usePinnedData'; import { isPresent } from '@/utils/typesUtils'; import { GRID_SIZE } from '@/utils/nodeViewUtils'; +import { CanvasKey } from '@/constants'; const $style = useCssModule(); @@ -48,8 +49,8 @@ const emit = defineEmits<{ 'delete:connection': [connection: Connection]; 'create:connection:start': [handle: ConnectStartEvent]; 'create:connection': [connection: Connection]; - 'create:connection:end': [connection: Connection]; - 'create:connection:cancelled': [handle: ConnectStartEvent]; + 'create:connection:end': [connection: Connection, event?: MouseEvent]; + 'create:connection:cancelled': [handle: ConnectStartEvent, event?: MouseEvent]; 'click:connection:add': [connection: Connection]; 'click:pane': [position: XYPosition]; 'run:workflow': []; @@ -184,35 +185,32 @@ function onUpdateNodeParameters(id: string, parameters: Record) */ const connectionCreated = ref(false); -const connectionEventData = ref(); - -const isConnection = (data: ConnectStartEvent | Connection | undefined): data is Connection => - !!data && connectionCreated.value; - -const isConnectionCancelled = ( - data: ConnectStartEvent | Connection | undefined, -): data is ConnectStartEvent => !!data && !connectionCreated.value; +const connectingHandle = ref(); +const connectedHandle = ref(); function onConnectStart(handle: ConnectStartEvent) { emit('create:connection:start', handle); - connectionEventData.value = handle; + connectingHandle.value = handle; connectionCreated.value = false; } function onConnect(connection: Connection) { emit('create:connection', connection); - connectionEventData.value = connection; + connectedHandle.value = connection; connectionCreated.value = true; } -function onConnectEnd() { - if (isConnection(connectionEventData.value)) { - emit('create:connection:end', connectionEventData.value); - } else if (isConnectionCancelled(connectionEventData.value)) { - emit('create:connection:cancelled', connectionEventData.value); +function onConnectEnd(event?: MouseEvent) { + if (connectedHandle.value) { + emit('create:connection:end', connectedHandle.value, event); + } else if (connectingHandle.value) { + emit('create:connection:cancelled', connectingHandle.value, event); } + + connectedHandle.value = undefined; + connectingHandle.value = undefined; } function onDeleteConnection(connection: Connection) { @@ -393,6 +391,14 @@ onPaneReady(async () => { watch(() => props.readOnly, setReadonly, { immediate: true, }); + +/** + * Provide + */ + +provide(CanvasKey, { + connectingHandle, +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts new file mode 100644 index 0000000000..78d7f073e0 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.spec.ts @@ -0,0 +1,21 @@ +import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import { createCanvasHandleProvide } from '@/__tests__/data'; + +const renderComponent = createComponentRenderer(CanvasHandleMainInput); + +describe('CanvasHandleMainInput', () => { + it('should render correctly', async () => { + const label = 'Test Label'; + const { container, getByText } = renderComponent({ + global: { + provide: { + ...createCanvasHandleProvide({ label }), + }, + }, + }); + + expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument(); + expect(getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue new file mode 100644 index 0000000000..f34cc07789 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue @@ -0,0 +1,34 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue index ba9e32ade4..9a37a3aff7 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue @@ -1,12 +1,25 @@ + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.spec.ts index 9e9dab81e0..19fb1214e0 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.spec.ts +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.spec.ts @@ -15,7 +15,7 @@ describe('CanvasHandleNonMainInput', () => { }, }); - expect(container.querySelector('.canvas-node-handle-non-main')).toBeInTheDocument(); + expect(container.querySelector('.canvas-node-handle-non-main-input')).toBeInTheDocument(); expect(getByText(label)).toBeInTheDocument(); }); }); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue index 44f9f09f98..384eaeecaf 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue @@ -2,48 +2,89 @@ 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'; +import { computed, ref } from 'vue'; const emit = defineEmits<{ add: []; }>(); -const { label, connected, type } = useCanvasNodeHandle(); +const { label, isConnected, isConnecting, type } = useCanvasNodeHandle(); -const isAddButtonVisible = computed( - () => !connected.value || type.value === NodeConnectionType.AiTool, +const handleClasses = 'target'; + +const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool); + +const isHandlePlusAvailable = computed( + () => !isConnected.value || supportsMultipleConnections.value, ); +const isHandlePlusVisible = computed( + () => !isConnecting.value || isHovered.value || supportsMultipleConnections.value, +); +const isHovered = ref(false); + +function onMouseEnter() { + isHovered.value = true; +} + +function onMouseLeave() { + isHovered.value = false; +} function onClickAdd() { emit('add'); } + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.spec.ts new file mode 100644 index 0000000000..69094e4a90 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.spec.ts @@ -0,0 +1,21 @@ +import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import { createCanvasHandleProvide } from '@/__tests__/data'; + +const renderComponent = createComponentRenderer(CanvasHandleNonMainOutput); + +describe('CanvasHandleNonMainOutput', () => { + it('should render correctly', async () => { + const label = 'Test Label'; + const { container, getByText } = renderComponent({ + global: { + provide: { + ...createCanvasHandleProvide({ label }), + }, + }, + }); + + expect(container.querySelector('.canvas-node-handle-non-main-output')).toBeInTheDocument(); + expect(getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue new file mode 100644 index 0000000000..6d62bfc23d --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue @@ -0,0 +1,38 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.spec.ts new file mode 100644 index 0000000000..cc6267034c --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.spec.ts @@ -0,0 +1,21 @@ +import CanvasHandlePlus from './CanvasHandlePlus.vue'; +import { createComponentRenderer } from '@/__tests__/render'; + +const renderComponent = createComponentRenderer(CanvasHandlePlus, {}); + +describe('CanvasHandleDiamond', () => { + it('should render with default props', () => { + const { html } = renderComponent(); + + expect(html()).toMatchSnapshot(); + }); + + it('should apply `handleClasses` prop correctly', () => { + const customClass = 'custom-handle-class'; + const wrapper = renderComponent({ + props: { handleClasses: customClass }, + }); + + expect(wrapper.container.querySelector(`.${customClass}`)).toBeTruthy(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.vue new file mode 100644 index 0000000000..a21f8d84d2 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDiamond.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.spec.ts new file mode 100644 index 0000000000..e3451c92d9 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.spec.ts @@ -0,0 +1,21 @@ +import CanvasHandleDot from './CanvasHandleDot.vue'; +import { createComponentRenderer } from '@/__tests__/render'; + +const renderComponent = createComponentRenderer(CanvasHandleDot, {}); + +describe('CanvasHandleDot', () => { + it('should render with default props', () => { + const { html } = renderComponent(); + + expect(html()).toMatchSnapshot(); + }); + + it('should apply `handleClasses` prop correctly', () => { + const customClass = 'custom-handle-class'; + const wrapper = renderComponent({ + props: { handleClasses: customClass }, + }); + + expect(wrapper.container.querySelector(`.${customClass}`)).toBeTruthy(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.vue new file mode 100644 index 0000000000..691ee755cf --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandleDot.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.spec.ts b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.spec.ts index 841d11a44e..fca5297c72 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.spec.ts +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.spec.ts @@ -18,9 +18,9 @@ describe('CanvasHandlePlus', () => { expect(html()).toMatchSnapshot(); }); - it('emits click:plus event when plus icon is clicked', async () => { + it('should emit click:plus event when plus icon is clicked', async () => { const { container, emitted } = renderComponent(); - const plusIcon = container.querySelector('svg.plus'); + const plusIcon = container.querySelector('.plus'); if (!plusIcon) throw new Error('Plus icon not found'); @@ -29,7 +29,7 @@ describe('CanvasHandlePlus', () => { expect(emitted()).toHaveProperty('click:plus'); }); - it('applies correct classes based on position prop', () => { + it('should apply correct classes based on position prop', () => { const positions = ['top', 'right', 'bottom', 'left']; positions.forEach((position) => { @@ -40,15 +40,17 @@ describe('CanvasHandlePlus', () => { }); }); - it('renders SVG elements correctly', () => { + it('should render 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 svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute('viewBox')).toBe('0 0 70 24'); - const plusSvg = container.querySelector('svg.plus'); + const lineSvg = container.querySelector('line'); + expect(lineSvg).toBeTruthy(); + + const plusSvg = container.querySelector('.plus'); expect(plusSvg).toBeTruthy(); - expect(plusSvg?.getAttribute('viewBox')).toBe('0 0 24 24'); }); }); diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue index eb88dfb3a5..8d0776e4a1 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue @@ -4,9 +4,11 @@ import { computed, useCssModule } from 'vue'; const props = withDefaults( defineProps<{ position?: 'top' | 'right' | 'bottom' | 'left'; + handleClasses?: string; }>(), { position: 'right', + handleClasses: undefined, }, ); @@ -16,7 +18,64 @@ const emit = defineEmits<{ const style = useCssModule(); -const classes = computed(() => [style.wrapper, style[props.position]]); +const classes = computed(() => [style.wrapper, style[props.position], props.handleClasses]); + +const plusSize = 24; +const lineSize = 46; + +const viewBox = computed(() => { + switch (props.position) { + case 'bottom': + case 'top': + return { + width: plusSize, + height: lineSize + plusSize, + }; + default: + return { + width: lineSize + plusSize, + height: plusSize, + }; + } +}); + +const linePosition = computed(() => { + switch (props.position) { + case 'top': + return [ + [viewBox.value.width / 2, viewBox.value.height - lineSize + 1], + [viewBox.value.width / 2, viewBox.value.height], + ]; + case 'bottom': + return [ + [viewBox.value.width / 2, 0], + [viewBox.value.width / 2, lineSize + 1], + ]; + case 'left': + return [ + [viewBox.value.width - lineSize - 1, viewBox.value.height / 2], + [viewBox.value.width, viewBox.value.height / 2], + ]; + default: + return [ + [0, viewBox.value.height / 2], + [lineSize + 1, viewBox.value.height / 2], + ]; + } +}); + +const plusPosition = computed(() => { + switch (props.position) { + case 'bottom': + return [0, viewBox.value.height - plusSize]; + case 'top': + return [0, 0]; + case 'left': + return [0, 0]; + default: + return [viewBox.value.width - plusSize, 0]; + } +}); function onClick(event: MouseEvent) { emit('click:plus', event); @@ -24,19 +83,23 @@ function onClick(event: MouseEvent) { diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDiamond.spec.ts.snap b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDiamond.spec.ts.snap new file mode 100644 index 0000000000..8a04f4a9d4 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDiamond.spec.ts.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CanvasHandleDiamond > should render with default props 1`] = ` +" + + + + + +" +`; diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDot.spec.ts.snap b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDot.spec.ts.snap new file mode 100644 index 0000000000..a902ea6fe9 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleDot.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CanvasHandleDot > should render with default props 1`] = `"
"`; diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandlePlus.spec.ts.snap b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandlePlus.spec.ts.snap index 585581dfae..4e18c15613 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandlePlus.spec.ts.snap +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandlePlus.spec.ts.snap @@ -1,12 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`CanvasHandlePlus > should render with default props 1`] = ` -"
- - - - - - -
" +" + + + + + +" `; diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleRectangle.spec.ts.snap b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleRectangle.spec.ts.snap new file mode 100644 index 0000000000..7ceb928370 --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/__snapshots__/CanvasHandleRectangle.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CanvasHandleRectangle > should render with default props 1`] = `"
"`; diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue index c3b3994622..34c4bd7bff 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -1,9 +1,9 @@