refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,276 @@
// @vitest-environment jsdom
import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import Canvas from '@/components/canvas/Canvas.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { CanvasConnection, CanvasNode } from '@/types';
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
import { NodeConnectionType } from 'n8n-workflow';
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useVueFlow } from '@vue-flow/core';
const matchMedia = global.window.matchMedia;
// @ts-expect-error Initialize window object
global.window = jsdom.window as unknown as Window & typeof globalThis;
global.window.matchMedia = matchMedia;
vi.mock('@n8n/design-system', async (importOriginal) => {
const actual = await importOriginal<typeof useDeviceSupport>();
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
});
const canvasId = 'canvas';
let renderComponent: ReturnType<typeof createComponentRenderer>;
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
renderComponent = createComponentRenderer(Canvas, {
pinia,
props: {
id: canvasId,
nodes: [],
connections: [],
},
});
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('Canvas', () => {
it('should initialize with default props', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('canvas')).toBeVisible();
expect(getByTestId('canvas-background')).toBeVisible();
expect(getByTestId('canvas-controls')).toBeVisible();
expect(getByTestId('canvas-minimap')).toBeInTheDocument();
});
it('should render nodes and edges', async () => {
const nodes: CanvasNode[] = [
createCanvasNodeElement({
id: '1',
label: 'Node 1',
data: {
outputs: [
{
type: NodeConnectionType.Main,
index: 0,
},
],
},
}),
createCanvasNodeElement({
id: '2',
label: 'Node 2',
position: { x: 200, y: 200 },
data: {
inputs: [
{
type: NodeConnectionType.Main,
index: 0,
},
],
},
}),
];
const connections: CanvasConnection[] = [createCanvasConnection(nodes[0], nodes[1])];
const { container } = renderComponent({
props: {
nodes,
connections,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
expect(container.querySelector(`[data-id="${nodes[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${nodes[1].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
});
it('should emit `update:nodes:position` event', async () => {
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
await fireEvent.mouseDown(node, { view: window });
await fireEvent.mouseMove(node, {
view: window,
clientX: 20,
clientY: 20,
});
await fireEvent.mouseMove(node, {
view: window,
clientX: 40,
clientY: 40,
});
await fireEvent.mouseUp(node, { view: window });
expect(emitted()['update:nodes:position']).toEqual([
[
[
{
id: '1',
position: { x: 120, y: 120 },
},
],
],
]);
});
it('should emit `update:node:name` event', async () => {
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
addSelectedNodes(graphNodes.value);
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
await fireEvent.keyDown(node, { key: ' ', view: window });
await fireEvent.keyUp(node, { key: ' ', view: window });
expect(emitted()['update:node:name']).toEqual([['1']]);
});
it('should not emit `update:node:name` event if long key press', async () => {
vi.useFakeTimers();
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
addSelectedNodes(graphNodes.value);
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
await fireEvent.keyDown(node, { key: ' ', view: window });
await vi.advanceTimersByTimeAsync(1000);
await fireEvent.keyUp(node, { key: ' ', view: window });
expect(emitted()['update:node:name']).toBeUndefined();
});
describe('minimap', () => {
const minimapVisibilityDelay = 1000;
const minimapTransitionDuration = 300;
it('should show minimap for 1sec after panning', async () => {
vi.useFakeTimers();
const nodes = [createCanvasNodeElement()];
const { getByTestId, container } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const canvas = getByTestId('canvas');
const pane = canvas.querySelector('.vue-flow__pane');
if (!pane) throw new Error('VueFlow pane not found');
await fireEvent.keyDown(pane, { view: window, key: ' ' });
await fireEvent.mouseDown(pane, { view: window });
await fireEvent.mouseMove(pane, {
view: window,
clientX: 100,
clientY: 100,
});
await fireEvent.mouseUp(pane, { view: window });
await fireEvent.keyUp(pane, { view: window, key: ' ' });
vi.advanceTimersByTime(minimapTransitionDuration);
await waitFor(() => expect(getByTestId('canvas-minimap')).toBeVisible());
vi.advanceTimersByTime(minimapVisibilityDelay + minimapTransitionDuration);
await waitFor(() => expect(getByTestId('canvas-minimap')).not.toBeVisible());
});
it('should keep minimap visible when hovered', async () => {
vi.useFakeTimers();
const nodes = [createCanvasNodeElement()];
const { getByTestId, container } = renderComponent({
props: {
nodes,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const canvas = getByTestId('canvas');
const pane = canvas.querySelector('.vue-flow__pane');
if (!pane) throw new Error('VueFlow pane not found');
await fireEvent.keyDown(pane, { view: window, key: ' ' });
await fireEvent.mouseDown(pane, { view: window });
await fireEvent.mouseMove(pane, {
view: window,
clientX: 100,
clientY: 100,
});
await fireEvent.mouseUp(pane, { view: window });
await fireEvent.keyUp(pane, { view: window, key: ' ' });
vi.advanceTimersByTime(minimapTransitionDuration);
await waitFor(() => expect(getByTestId('canvas-minimap')).toBeVisible());
await fireEvent.mouseEnter(getByTestId('canvas-minimap'));
vi.advanceTimersByTime(minimapVisibilityDelay + minimapTransitionDuration);
await waitFor(() => expect(getByTestId('canvas-minimap')).toBeVisible());
await fireEvent.mouseLeave(getByTestId('canvas-minimap'));
vi.advanceTimersByTime(minimapVisibilityDelay + minimapTransitionDuration);
await waitFor(() => expect(getByTestId('canvas-minimap')).not.toBeVisible());
});
});
describe('background', () => {
it('should render default background', () => {
const { container } = renderComponent();
const patternCanvas = container.querySelector('#pattern-canvas');
expect(patternCanvas).toBeInTheDocument();
expect(patternCanvas?.innerHTML).toContain('<circle');
expect(patternCanvas?.innerHTML).not.toContain('<path');
});
it('should render striped background', () => {
const { container } = renderComponent({ props: { readOnly: true } });
const patternCanvas = container.querySelector('#pattern-canvas');
expect(patternCanvas).toBeInTheDocument();
expect(patternCanvas?.innerHTML).toContain('<path');
expect(patternCanvas?.innerHTML).not.toContain('<circle');
});
});
});

View File

@@ -0,0 +1,886 @@
<script lang="ts" setup>
import {
type CanvasConnection,
type CanvasNode,
type CanvasNodeMoveEvent,
type CanvasEventBusEvents,
type ConnectStartEvent,
CanvasNodeRenderType,
} from '@/types';
import type {
Connection,
XYPosition,
NodeDragEvent,
NodeMouseEvent,
GraphNode,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, watch } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
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';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
const $style = useCssModule();
const emit = defineEmits<{
'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition];
'update:nodes:position': [events: CanvasNodeMoveEvent[]];
'update:node:activated': [id: string];
'update:node:deactivated': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'update:node:name': [id: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'update:node:inputs': [id: string];
'update:node:outputs': [id: string];
'click:node': [id: string];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
'delete:node': [id: string];
'create:node': [source: NodeCreatorOpenSource];
'create:sticky': [];
'delete:nodes': [ids: string[]];
'update:nodes:enabled': [ids: string[]];
'copy:nodes': [ids: string[]];
'duplicate:nodes': [ids: string[]];
'update:nodes:pin': [ids: string[], source: PinDataSource];
'cut:nodes': [ids: string[]];
'delete:connection': [connection: Connection];
'create:connection:start': [handle: ConnectStartEvent];
'create:connection': [connection: Connection];
'create:connection:end': [connection: Connection, event?: MouseEvent];
'create:connection:cancelled': [
handle: ConnectStartEvent,
position: XYPosition,
event?: MouseEvent,
];
'click:connection:add': [connection: Connection];
'click:pane': [position: XYPosition];
'run:workflow': [];
'save:workflow': [];
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
}>();
const props = withDefaults(
defineProps<{
id?: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
controlsPosition?: PanelPosition;
eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean;
executing?: boolean;
keyBindings?: boolean;
loading?: boolean;
}>(),
{
id: 'canvas',
nodes: () => [],
connections: () => [],
controlsPosition: PanelPosition.BottomLeft,
eventBus: () => createEventBus(),
readOnly: false,
executing: false,
keyBindings: true,
loading: false,
},
);
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null });
const {
getSelectedNodes: selectedNodes,
addSelectedNodes,
removeSelectedNodes,
viewportRef,
fitView,
zoomIn,
zoomOut,
zoomTo,
setInteractive,
elementsSelectable,
project,
nodes: graphNodes,
onPaneReady,
onNodesInitialized,
findNode,
viewport,
onEdgeMouseLeave,
onEdgeMouseEnter,
onEdgeMouseMove,
onNodeMouseEnter,
onNodeMouseLeave,
} = vueFlow;
const {
getIncomingNodes,
getOutgoingNodes,
getSiblingNodes,
getDownstreamNodes,
getUpstreamNodes,
} = useCanvasTraversal(vueFlow);
const isPaneReady = ref(false);
const classes = computed(() => ({
[$style.canvas]: true,
[$style.ready]: !props.loading && isPaneReady.value,
}));
/**
* Panning and Selection key bindings
*/
// @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
function switchToPanningMode() {
selectionKeyCode.value = null;
panningMouseButton.value = [0, 1];
}
function switchToSelectionMode() {
selectionKeyCode.value = true;
panningMouseButton.value = [1];
}
onKeyDown(panningKeyCode.value, switchToPanningMode, {
dedupe: true,
});
onKeyUp(panningKeyCode.value, switchToSelectionMode);
/**
* Rename node key bindings
* We differentiate between short and long press because the space key is also used for activating panning
*/
const renameKeyCode = ' ';
useShortKeyPress(
renameKeyCode,
() => {
if (lastSelectedNode.value) {
emit('update:node:name', lastSelectedNode.value.id);
}
},
{
disabled: toRef(props, 'readOnly'),
},
);
/**
* Key bindings
*/
const disableKeyBindings = computed(() => !props.keyBindings);
function selectLeftNode(id: string) {
const incomingNodes = getIncomingNodes(id);
const previousNode = incomingNodes[0];
if (previousNode) {
onSelectNodes({ ids: [previousNode.id] });
}
}
function selectRightNode(id: string) {
const outgoingNodes = getOutgoingNodes(id);
const nextNode = outgoingNodes[0];
if (nextNode) {
onSelectNodes({ ids: [nextNode.id] });
}
}
function selectLowerSiblingNode(id: string) {
const siblingNodes = getSiblingNodes(id);
const index = siblingNodes.findIndex((n) => n.id === id);
const nextNode = siblingNodes[index + 1] ?? siblingNodes[0];
if (nextNode) {
onSelectNodes({
ids: [nextNode.id],
});
}
}
function selectUpperSiblingNode(id: string) {
const siblingNodes = getSiblingNodes(id);
const index = siblingNodes.findIndex((n) => n.id === id);
const previousNode = siblingNodes[index - 1] ?? siblingNodes[siblingNodes.length - 1];
if (previousNode) {
onSelectNodes({
ids: [previousNode.id],
});
}
}
function selectDownstreamNodes(id: string) {
const downstreamNodes = getDownstreamNodes(id);
onSelectNodes({ ids: [...downstreamNodes.map((node) => node.id), id] });
}
function selectUpstreamNodes(id: string) {
const upstreamNodes = getUpstreamNodes(id);
onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] });
}
const keyMap = computed(() => ({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
// Support both key and code for zooming in and out
'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(),
'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(),
0: async () => await onResetZoom(),
1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode),
ArrowLeft: emitWithLastSelectedNode(selectLeftNode),
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
...(props.readOnly
? {}
: {
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
}),
}));
useKeybindings(keyMap, { disabled: disableKeyBindings });
/**
* Nodes
*/
const hasSelection = computed(() => selectedNodes.value.length > 0);
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
const lastSelectedNode = ref<GraphNode>();
const triggerNodes = computed(() =>
props.nodes.filter(
(node) =>
node.data?.render.type === CanvasNodeRenderType.Default && node.data.render.options.trigger,
),
);
const hoveredTriggerNode = useCanvasNodeHover(triggerNodes, vueFlow, (nodeRect) => ({
x: nodeRect.x - nodeRect.width * 2, // should cover the width of trigger button
y: nodeRect.y - nodeRect.height,
width: nodeRect.width * 4,
height: nodeRect.height * 3,
}));
watch(selectedNodes, (nodes) => {
if (!lastSelectedNode.value || !nodes.find((node) => node.id === lastSelectedNode.value?.id)) {
lastSelectedNode.value = nodes[nodes.length - 1];
}
});
function onClickNodeAdd(id: string, handle: string) {
emit('click:node:add', id, handle);
}
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
emit('update:nodes:position', events);
}
function onUpdateNodePosition(id: string, position: XYPosition) {
emit('update:node:position', id, position);
}
function onNodeDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onNodeClick({ event, node }: NodeMouseEvent) {
emit('click:node', node.id);
if (event.ctrlKey || event.metaKey || selectedNodes.value.length < 2) {
return;
}
onSelectNodes({ ids: [node.id] });
}
function onSelectionDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onSetNodeActivated(id: string) {
props.eventBus.emit('nodes:action', { ids: [id], action: 'update:node:activated' });
emit('update:node:activated', id);
}
function onSetNodeDeactivated(id: string) {
emit('update:node:deactivated', id);
}
function clearSelectedNodes() {
removeSelectedNodes(selectedNodes.value);
}
function onSelectNode() {
emit('update:node:selected', lastSelectedNode.value?.id);
}
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
}
function onToggleNodeEnabled(id: string) {
emit('update:node:enabled', id);
}
function onDeleteNode(id: string) {
emit('delete:node', id);
}
function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>) {
emit('update:node:parameters', id, parameters);
}
function onUpdateNodeInputs(id: string) {
emit('update:node:inputs', id);
}
function onUpdateNodeOutputs(id: string) {
emit('update:node:outputs', id);
}
/**
* Connections / Edges
*/
const connectionCreated = ref(false);
const connectingHandle = ref<ConnectStartEvent>();
const connectedHandle = ref<Connection>();
function onConnectStart(handle: ConnectStartEvent) {
emit('create:connection:start', handle);
connectingHandle.value = handle;
connectionCreated.value = false;
}
function onConnect(connection: Connection) {
emit('create:connection', connection);
connectedHandle.value = connection;
connectionCreated.value = true;
}
function onConnectEnd(event?: MouseEvent) {
if (connectedHandle.value) {
emit('create:connection:end', connectedHandle.value, event);
} else if (connectingHandle.value) {
emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event);
}
connectedHandle.value = undefined;
connectingHandle.value = undefined;
}
function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}
function onClickConnectionAdd(connection: Connection) {
emit('click:connection:add', connection);
}
const arrowHeadMarkerId = ref('custom-arrow-head');
/**
* Edge and Nodes Hovering
*/
const edgesHoveredById = ref<Record<string, boolean>>({});
const edgesBringToFrontById = ref<Record<string, boolean>>({});
onEdgeMouseEnter(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: true };
edgesHoveredById.value = { [edge.id]: true };
});
onEdgeMouseMove(
useThrottleFn(({ edge, event }) => {
const type = edge.data.source.type;
if (type !== NodeConnectionType.AiTool) {
return;
}
if (!edge.data.maxConnections || edge.data.maxConnections > 1) {
const projectedPosition = getProjectedPosition(event);
const yDiff = projectedPosition.y - edge.targetY;
if (yDiff < 4 * GRID_SIZE) {
edgesBringToFrontById.value = { [edge.id]: false };
} else {
edgesBringToFrontById.value = { [edge.id]: true };
}
}
}, 100),
);
onEdgeMouseLeave(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: false };
edgesHoveredById.value = { [edge.id]: false };
});
function onUpdateEdgeLabelHovered(id: string, hovered: boolean) {
edgesBringToFrontById.value = { [id]: true };
edgesHoveredById.value[id] = hovered;
}
const nodesHoveredById = ref<Record<string, boolean>>({});
onNodeMouseEnter(({ node }) => {
nodesHoveredById.value = { [node.id]: true };
});
onNodeMouseLeave(({ node }) => {
nodesHoveredById.value = { [node.id]: false };
});
/**
* Executions
*/
function onRunNode(id: string) {
emit('run:node', id);
}
/**
* Emit helpers
*/
function emitWithSelectedNodes(emitFn: (ids: string[]) => void) {
return () => {
if (hasSelection.value) {
emitFn(selectedNodeIds.value);
}
};
}
function emitWithLastSelectedNode(emitFn: (id: string) => void) {
return () => {
if (lastSelectedNode.value) {
emitFn(lastSelectedNode.value.id);
}
};
}
/**
* View
*/
const defaultZoom = 1;
const isPaneMoving = ref(false);
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
const offsetX = event?.clientX ?? 0;
const offsetY = event?.clientY ?? 0;
return project({
x: offsetX - bounds.left,
y: offsetY - bounds.top,
});
}
function onClickPane(event: MouseEvent) {
emit('click:pane', getProjectedPosition(event));
}
async function onFitView() {
await fitView({ maxZoom: defaultZoom, padding: 0.2 });
}
async function onZoomTo(zoomLevel: number) {
await zoomTo(zoomLevel);
}
async function onZoomIn() {
await zoomIn();
}
async function onZoomOut() {
await zoomOut();
}
async function onResetZoom() {
await onZoomTo(defaultZoom);
}
function setReadonly(value: boolean) {
setInteractive(!value);
elementsSelectable.value = true;
}
function onPaneMoveStart() {
isPaneMoving.value = true;
}
function onPaneMoveEnd() {
isPaneMoving.value = false;
}
/**
* Context menu
*/
const contextMenu = useContextMenu();
function onOpenContextMenu(event: MouseEvent) {
contextMenu.open(event, {
source: 'canvas',
nodeIds: selectedNodeIds.value,
});
}
function onOpenSelectionContextMenu({ event }: { event: MouseEvent }) {
contextMenu.open(event, {
source: 'canvas',
nodeIds: selectedNodeIds.value,
});
}
function onOpenNodeContextMenu(
id: string,
event: MouseEvent,
source: 'node-button' | 'node-right-click',
) {
if (selectedNodeIds.value.includes(id)) {
onOpenContextMenu(event);
}
contextMenu.open(event, { source, nodeId: id });
}
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
switch (action) {
case 'add_node':
return emit('create:node', 'context_menu');
case 'add_sticky':
return emit('create:sticky');
case 'copy':
return emit('copy:nodes', nodeIds);
case 'delete':
return emit('delete:nodes', nodeIds);
case 'select_all':
return addSelectedNodes(graphNodes.value);
case 'deselect_all':
return clearSelectedNodes();
case 'duplicate':
return emit('duplicate:nodes', nodeIds);
case 'toggle_pin':
return emit('update:nodes:pin', nodeIds, 'context-menu');
case 'execute':
return emit('run:node', nodeIds[0]);
case 'toggle_activation':
return emit('update:nodes:enabled', nodeIds);
case 'open':
return onSetNodeActivated(nodeIds[0]);
case 'rename':
return emit('update:node:name', nodeIds[0]);
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
}
}
/**
* Drag and drop
*/
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(event: DragEvent) {
const position = getProjectedPosition(event);
emit('drag-and-drop', position, event);
}
/**
* Minimap
*/
const minimapVisibilityDelay = 1000;
const minimapHideTimeout = ref<NodeJS.Timeout | null>(null);
const isMinimapVisible = ref(false);
function minimapNodeClassnameFn(node: CanvasNode) {
return `minimap-node-${node.data?.render.type.replace(/\./g, '-') ?? 'default'}`;
}
watch(isPaneMoving, (value) => {
if (value) {
showMinimap();
} else {
hideMinimap();
}
});
function showMinimap() {
if (minimapHideTimeout.value) {
clearTimeout(minimapHideTimeout.value);
minimapHideTimeout.value = null;
}
isMinimapVisible.value = true;
}
function hideMinimap() {
minimapHideTimeout.value = setTimeout(() => {
isMinimapVisible.value = false;
}, minimapVisibilityDelay);
}
function onMinimapMouseEnter() {
showMinimap();
}
function onMinimapMouseLeave() {
hideMinimap();
}
/**
* Window Events
*/
function onWindowBlur() {
switchToSelectionMode();
}
/**
* Lifecycle
*/
const initialized = ref(false);
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes);
window.addEventListener('blur', onWindowBlur);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
props.eventBus.off('nodes:select', onSelectNodes);
window.removeEventListener('blur', onWindowBlur);
});
onPaneReady(async () => {
await onFitView();
isPaneReady.value = true;
});
onNodesInitialized(() => {
initialized.value = true;
});
watch(() => props.readOnly, setReadonly, {
immediate: true,
});
/**
* Provide
*/
const isExecuting = toRef(props, 'executing');
provide(CanvasKey, {
connectingHandle,
isExecuting,
initialized,
viewport,
});
</script>
<template>
<VueFlow
:id="id"
:nodes="nodes"
:edges="connections"
:class="classes"
:apply-changes="false"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
:connection-radius="60"
:pan-on-drag="panningMouseButton"
pan-on-scroll
snap-to-grid
:snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0"
:max-zoom="4"
:selection-key-code="selectionKeyCode"
:zoom-activation-key-code="panningKeyCode"
:pan-activation-key-code="panningKeyCode"
:disable-keyboard-a11y="true"
data-test-id="canvas"
@connect-start="onConnectStart"
@connect="onConnect"
@connect-end="onConnectEnd"
@pane-click="onClickPane"
@pane-context-menu="onOpenContextMenu"
@move-start="onPaneMoveStart"
@move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop"
@node-click="onNodeClick"
@selection-drag-stop="onSelectionDragStop"
@selection-context-menu="onOpenSelectionContextMenu"
@dragover="onDragOver"
@drop="onDrop"
>
<template #node-canvas-node="nodeProps">
<Node
v-bind="nodeProps"
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActivated"
@deactivate="onSetNodeDeactivated"
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@update:inputs="onUpdateNodeInputs"
@update:outputs="onUpdateNodeOutputs"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
>
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
<slot name="nodeToolbar" v-bind="toolbarProps" />
</template>
</Node>
</template>
<template #edge-canvas-edge="edgeProps">
<Edge
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
:read-only="readOnly"
:hovered="edgesHoveredById[edgeProps.id]"
:bring-to-front="edgesBringToFrontById[edgeProps.id]"
@add="onClickConnectionAdd"
@delete="onDeleteConnection"
@update:label:hovered="onUpdateEdgeLabelHovered(edgeProps.id, $event)"
/>
</template>
<template #connection-line="connectionLineProps">
<CanvasConnectionLine v-bind="connectionLineProps" />
</template>
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<CanvasBackground :viewport="viewport" :striped="readOnly" />
<Transition name="minimap">
<MiniMap
v-show="isMinimapVisible"
data-test-id="canvas-minimap"
aria-label="n8n Minimap"
:height="120"
:width="200"
:position="PanelPosition.BottomLeft"
pannable
zoomable
:node-class-name="minimapNodeClassnameFn"
:node-border-radius="16"
@mouseenter="onMinimapMouseEnter"
@mouseleave="onMinimapMouseLeave"
/>
</Transition>
<CanvasControlButtons
data-test-id="canvas-controls"
:class="$style.canvasControls"
:position="controlsPosition"
:show-interactive="false"
:zoom="viewport.zoom"
@zoom-to-fit="onFitView"
@zoom-in="onZoomIn"
@zoom-out="onZoomOut"
@reset-zoom="onResetZoom"
/>
<Suspense>
<ContextMenu @action="onContextMenuAction" />
</Suspense>
</VueFlow>
</template>
<style lang="scss" module>
.canvas {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 300ms ease;
&.ready {
opacity: 1;
}
:global(.vue-flow__pane) {
cursor: grab;
&:global(.selection) {
cursor: default;
}
&:global(.dragging) {
cursor: grabbing;
}
}
}
</style>
<style lang="scss" scoped>
.minimap-enter-active,
.minimap-leave-active {
transition: opacity 0.3s ease;
}
.minimap-enter-from,
.minimap-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,146 @@
import { waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { createEventBus } from '@n8n/utils/event-bus';
import { createCanvasNodeElement, createCanvasConnection } from '@/__tests__/data';
import type { Workflow } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import { STICKY_NODE_TYPE } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
defaultNodeDescriptions,
} from '@/__tests__/mocks';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
const renderComponent = createComponentRenderer(WorkflowCanvas, {
props: {
id: 'canvas',
workflow: {
id: '1',
name: 'Test Workflow',
nodes: [],
connections: [],
},
workflowObject: {} as Workflow,
eventBus: createEventBus(),
},
});
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('WorkflowCanvas', () => {
it('should initialize with default props', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('canvas')).toBeVisible();
});
it('should render nodes and connections', async () => {
const nodes = [
createCanvasNodeElement({ id: '1', label: 'Node 1' }),
createCanvasNodeElement({ id: '2', label: 'Node 2' }),
];
const connections = [createCanvasConnection(nodes[0], nodes[1])];
const { container } = renderComponent({
props: {
nodes,
connections,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
expect(container.querySelector(`[data-id="${nodes[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${nodes[1].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
});
it('should handle empty nodes and connections gracefully', async () => {
const { container } = renderComponent();
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(0));
expect(container.querySelectorAll('.vue-flow__connection')).toHaveLength(0);
});
it('should render fallback nodes when sticky nodes are present', async () => {
const stickyNodes = [createTestNode({ id: '2', name: 'Sticky Node', type: STICKY_NODE_TYPE })];
const fallbackNodes = [
createTestNode({
id: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
}),
];
const workflow = createTestWorkflow({
id: '1',
name: 'Test Workflow',
nodes: [...stickyNodes],
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
const { container } = renderComponent({
props: {
workflow,
workflowObject,
fallbackNodes,
showFallbackNodes: true,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
expect(container.querySelector(`[data-id="${stickyNodes[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${fallbackNodes[0].id}"]`)).toBeInTheDocument();
});
it('should not render fallback nodes when showFallbackNodes is false', async () => {
const nodes = [createTestNode({ id: '1', name: 'Non-Sticky Node 1' })];
const fallbackNodes = [
createTestNode({
id: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
name: CanvasNodeRenderType.AddNodes,
}),
];
const workflow = createTestWorkflow({
id: '1',
name: 'Test Workflow',
nodes,
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
const { container } = renderComponent({
props: {
workflow,
workflowObject,
fallbackNodes,
showFallbackNodes: false,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
expect(container.querySelector(`[data-id="${nodes[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${fallbackNodes[0].id}"]`)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import Canvas from '@/components/canvas/Canvas.vue';
import { computed, ref, toRef, useCssModule } from 'vue';
import type { Workflow } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type { CanvasEventBusEvents } from '@/types';
import { useVueFlow } from '@vue-flow/core';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{
id?: string;
workflow: IWorkflowDb;
workflowObject: Workflow;
fallbackNodes?: IWorkflowDb['nodes'];
showFallbackNodes?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean;
executing?: boolean;
}>(),
{
id: 'canvas',
eventBus: () => createEventBus<CanvasEventBusEvents>(),
fallbackNodes: () => [],
showFallbackNodes: true,
},
);
const $style = useCssModule();
const { onNodesInitialized } = useVueFlow({ id: props.id });
const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject');
const nodes = computed(() => {
return props.showFallbackNodes
? [...props.workflow.nodes, ...props.fallbackNodes]
: props.workflow.nodes;
});
const connections = computed(() => props.workflow.connections);
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
nodes,
connections,
workflowObject,
});
const initialFitViewDone = ref(false); // Workaround for https://github.com/bcakmakoglu/vue-flow/issues/1636
onNodesInitialized(() => {
if (!initialFitViewDone.value || props.showFallbackNodes) {
props.eventBus.emit('fitView');
initialFitViewDone.value = true;
}
});
</script>
<template>
<div :class="$style.wrapper" data-test-id="canvas-wrapper">
<div :class="$style.canvas">
<Canvas
v-if="workflow"
:id="id"
:nodes="mappedNodes"
:connections="mappedConnections"
:event-bus="eventBus"
:read-only="readOnly"
v-bind="$attrs"
/>
</div>
<slot />
</div>
</template>
<style lang="scss" module>
.wrapper {
display: block;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
position: relative;
display: block;
}
</style>

View File

@@ -0,0 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasBackground from '@/components/canvas/elements/background/CanvasBackground.vue';
const renderComponent = createComponentRenderer(CanvasBackground);
describe('CanvasBackground', () => {
it('should render the background with the correct gap', () => {
const { getByTestId, html } = renderComponent({
props: { striped: false, viewport: { x: 0, y: 0, zoom: 1 } },
});
const background = getByTestId('canvas-background');
expect(background).toBeInTheDocument();
expect(html()).toMatchSnapshot();
});
it('should render the striped pattern when striped is true', () => {
const { getByTestId } = renderComponent({
props: { striped: true, viewport: { x: 0, y: 0, zoom: 1 } },
});
const pattern = getByTestId('canvas-background-striped-pattern');
expect(pattern).toBeInTheDocument();
});
it('should not render the striped pattern when striped is false', () => {
const { getByTestId } = renderComponent({
props: { striped: false, viewport: { x: 0, y: 0, zoom: 1 } },
});
expect(() => getByTestId('canvas-background-striped-pattern')).toThrow();
});
});

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import CanvasBackgroundStripedPattern from './CanvasBackgroundStripedPattern.vue';
import { Background } from '@vue-flow/background';
import type { ViewportTransform } from '@vue-flow/core';
defineProps<{
striped: boolean;
viewport: ViewportTransform;
}>();
</script>
<template>
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
<template v-if="striped" #pattern-container="patternProps">
<CanvasBackgroundStripedPattern
:id="patternProps.id"
data-test-id="canvas-background-striped-pattern"
:x="viewport.x"
:y="viewport.y"
:zoom="viewport.zoom"
/>
</template>
</Background>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
/* eslint-disable vue/no-multiple-template-root */
/**
* @see https://github.com/bcakmakoglu/vue-flow/blob/master/packages/background/src/Background.vue
*/
import { computed } from 'vue';
const props = defineProps<{
id: string;
x: number;
y: number;
zoom: number;
}>();
const scaledGap = computed(() => 20 * props.zoom || 1);
const patternOffset = computed(() => scaledGap.value / 2);
</script>
<template>
<pattern
:id="id"
patternUnits="userSpaceOnUse"
:x="x % scaledGap"
:y="y % scaledGap"
:width="scaledGap"
:height="scaledGap"
:patternTransform="`rotate(135) translate(-${patternOffset},-${patternOffset})`"
>
<path :d="`M0 ${scaledGap / 2} H${scaledGap}`" :stroke-width="scaledGap / 2" />
</pattern>
</template>
<style scoped>
path {
stroke: var(--color-canvas-read-only-line);
}
</style>

View File

@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasBackground > should render the background with the correct gap 1`] = `
"<svg class="vue-flow__background vue-flow__container" style="height: 100%; width: 100%;" data-test-id="canvas-background">
<pattern id="pattern-vue-flow-0" x="0" y="0" width="20" height="20" patternTransform="translate(-11,-11)" patternUnits="userSpaceOnUse">
<circle cx="0.5" cy="0.5" r="0.5" fill="#aaa"></circle>
<!---->
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern-vue-flow-0)"></rect>
</svg>"
`;

View File

@@ -0,0 +1,12 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasChatButton from './CanvasChatButton.vue';
const renderComponent = createComponentRenderer(CanvasChatButton);
describe('CanvasChatButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
export interface Props {
type: 'primary' | 'tertiary';
label: string;
}
defineProps<Props>();
</script>
<template>
<N8nButton
:label="label"
size="large"
icon="comment"
:type="type"
data-test-id="workflow-chat-button"
/>
</template>

View File

@@ -0,0 +1,12 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasClearExecutionDataButton from './CanvasClearExecutionDataButton.vue';
const renderComponent = createComponentRenderer(CanvasClearExecutionDataButton);
describe('CanvasClearExecutionDataButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
</script>
<template>
<N8nIconButton
:title="i18n.baseText('nodeView.deletesTheCurrentExecutionData')"
icon="trash"
size="large"
data-test-id="clear-execution-data-button"
/>
</template>

View File

@@ -0,0 +1,32 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasControlButtons from './CanvasControlButtons.vue';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
const renderComponent = createComponentRenderer(CanvasControlButtons);
describe('CanvasControlButtons', () => {
beforeAll(() => {
setActivePinia(createTestingPinia());
});
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.getByTestId('zoom-in-button')).toBeVisible();
expect(wrapper.getByTestId('zoom-out-button')).toBeVisible();
expect(wrapper.getByTestId('zoom-to-fit')).toBeVisible();
expect(wrapper.html()).toMatchSnapshot();
});
it('should show reset zoom button when zoom is not equal to 1', () => {
const wrapper = renderComponent({
props: {
zoom: 1.5,
},
});
expect(wrapper.getByTestId('reset-zoom-button')).toBeVisible();
});
});

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { Controls } from '@vue-flow/controls';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
const props = withDefaults(
defineProps<{
zoom?: number;
}>(),
{
zoom: 1,
},
);
const emit = defineEmits<{
'reset-zoom': [];
'zoom-in': [];
'zoom-out': [];
'zoom-to-fit': [];
}>();
const i18n = useI18n();
const isResetZoomVisible = computed(() => props.zoom !== 1);
function onResetZoom() {
emit('reset-zoom');
}
function onZoomIn() {
emit('zoom-in');
}
function onZoomOut() {
emit('zoom-out');
}
function onZoomToFit() {
emit('zoom-to-fit');
}
</script>
<template>
<Controls :show-zoom="false" :show-fit-view="false">
<KeyboardShortcutTooltip
:label="i18n.baseText('nodeView.zoomToFit')"
:shortcut="{ keys: ['1'] }"
>
<N8nIconButton
type="tertiary"
size="large"
icon="expand"
data-test-id="zoom-to-fit"
@click="onZoomToFit"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip :label="i18n.baseText('nodeView.zoomIn')" :shortcut="{ keys: ['+'] }">
<N8nIconButton
type="tertiary"
size="large"
icon="search-plus"
data-test-id="zoom-in-button"
@click="onZoomIn"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip :label="i18n.baseText('nodeView.zoomOut')" :shortcut="{ keys: ['-'] }">
<N8nIconButton
type="tertiary"
size="large"
icon="search-minus"
data-test-id="zoom-out-button"
@click="onZoomOut"
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
v-if="isResetZoomVisible"
:label="i18n.baseText('nodeView.resetZoom')"
:shortcut="{ keys: ['0'] }"
>
<N8nIconButton
type="tertiary"
size="large"
icon="undo"
data-test-id="reset-zoom-button"
@click="onResetZoom"
/>
</KeyboardShortcutTooltip>
</Controls>
</template>
<style lang="scss">
.vue-flow__controls {
display: flex;
gap: var(--spacing-xs);
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton);
describe('CanvasRunWorkflowButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different label when executing', () => {
const wrapper = renderComponent({
props: {
executing: true,
},
});
expect(wrapper.getAllByText('Executing workflow')).toHaveLength(2);
});
it('should render different label when executing and waiting for webhook', () => {
const wrapper = renderComponent({
props: {
executing: true,
waitingForWebhook: true,
},
});
expect(wrapper.getAllByText('Waiting for trigger event')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
defineEmits<{
mouseenter: [event: MouseEvent];
mouseleave: [event: MouseEvent];
click: [event: MouseEvent];
}>();
const props = defineProps<{
waitingForWebhook?: boolean;
executing?: boolean;
disabled?: boolean;
}>();
const i18n = useI18n();
const label = computed(() => {
if (!props.executing) {
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
}
if (props.waitingForWebhook) {
return i18n.baseText('nodeView.runButtonText.waitingForTriggerEvent');
}
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
});
</script>
<template>
<KeyboardShortcutTooltip :label="label" :shortcut="{ metaKey: true, keys: ['↵'] }">
<N8nButton
:loading="executing"
:label="label"
:disabled="disabled"
size="large"
icon="flask"
type="primary"
data-test-id="execute-workflow-button"
@mouseenter="$emit('mouseenter', $event)"
@mouseleave="$emit('mouseleave', $event)"
@click.stop="$emit('click', $event)"
/>
</KeyboardShortcutTooltip>
</template>

View File

@@ -0,0 +1,22 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopCurrentExecutionButton from './CanvasStopCurrentExecutionButton.vue';
const renderComponent = createComponentRenderer(CanvasStopCurrentExecutionButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different title when loading', () => {
const wrapper = renderComponent({
props: {
stopping: true,
},
});
expect(wrapper.getByTitle('Stopping current execution')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
const props = defineProps<{
stopping?: boolean;
}>();
const i18n = useI18n();
const title = computed(() =>
props.stopping
? i18n.baseText('nodeView.stoppingCurrentExecution')
: i18n.baseText('nodeView.stopCurrentExecution'),
);
</script>
<template>
<N8nIconButton
icon="stop"
size="large"
class="stop-execution"
type="secondary"
:title="title"
:loading="stopping"
data-test-id="stop-execution-button"
/>
</template>

View File

@@ -0,0 +1,12 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopWaitingForWebhookButton from './CanvasStopWaitingForWebhookButton.vue';
const renderComponent = createComponentRenderer(CanvasStopWaitingForWebhookButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
</script>
<template>
<N8nIconButton
class="stop-execution"
icon="stop"
size="large"
:title="i18n.baseText('nodeView.stopWaitingForWebhookCall')"
type="secondary"
data-test-id="stop-execution-waiting-for-webhook-button"
/>
</template>

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasChatButton > should render correctly 1`] = `
"<button class="button button primary large withIcon" aria-live="polite" data-test-id="workflow-chat-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-comment fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="comment" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasClearExecutionDataButton > should render correctly 1`] = `
"<button class="button button primary large withIcon square" aria-live="polite" title="Deletes the current execution data" data-test-id="clear-execution-data-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-trash fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trash" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,25 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasControlButtons > should render correctly 1`] = `
"<div class="vue-flow__panel bottom left vue-flow__controls" style="pointer-events: all;">
<!---->
<!----><button class="vue-flow__controls-button vue-flow__controls-interactive"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 0 0 0 13.714v15.238A3.056 3.056 0 0 0 3.048 32h18.285a3.056 3.056 0 0 0 3.048-3.048V13.714a3.056 3.056 0 0 0-3.048-3.047zM12.19 24.533a3.056 3.056 0 0 1-3.047-3.047 3.056 3.056 0 0 1 3.047-3.048 3.056 3.056 0 0 1 3.048 3.048 3.056 3.056 0 0 1-3.048 3.047z"></path>
</svg>
<!---->
</button><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-to-fit"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-expand fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="expand" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg></span></span>
<!--v-if-->
</button>
<!--teleport start-->
<!--teleport end--><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-in-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-search-plus fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search-plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M304 192v32c0 6.6-5.4 12-12 12h-56v56c0 6.6-5.4 12-12 12h-32c-6.6 0-12-5.4-12-12v-56h-56c-6.6 0-12-5.4-12-12v-32c0-6.6 5.4-12 12-12h56v-56c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v56h56c6.6 0 12 5.4 12 12zm201 284.7L476.7 505c-9.4 9.4-24.6 9.4-33.9 0L343 405.3c-4.5-4.5-7-10.6-7-17V372c-35.3 27.6-79.7 44-128 44C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208c0 48.3-16.4 92.7-44 128h16.3c6.4 0 12.5 2.5 17 7l99.7 99.7c9.3 9.4 9.3 24.6 0 34zM344 208c0-75.2-60.8-136-136-136S72 132.8 72 208s60.8 136 136 136 136-60.8 136-136z"></path></svg></span></span>
<!--v-if-->
</button>
<!--teleport start-->
<!--teleport end--><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-out-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-search-minus fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search-minus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M304 192v32c0 6.6-5.4 12-12 12H124c-6.6 0-12-5.4-12-12v-32c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm201 284.7L476.7 505c-9.4 9.4-24.6 9.4-33.9 0L343 405.3c-4.5-4.5-7-10.6-7-17V372c-35.3 27.6-79.7 44-128 44C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208c0 48.3-16.4 92.7-44 128h16.3c6.4 0 12.5 2.5 17 7l99.7 99.7c9.3 9.4 9.3 24.6 0 34zM344 208c0-75.2-60.8-136-136-136S72 132.8 72 208s60.8 136 136 136 136-60.8 136-136z"></path></svg></span></span>
<!--v-if-->
</button>
<!--teleport start-->
<!--teleport end-->
<!--v-if-->
</div>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasExecuteWorkflowButton > should render correctly 1`] = `
"<button class="button button primary large withIcon el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span>Test workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasRunWorkflowButton > should render correctly 1`] = `
"<button class="button button primary large withIcon el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span>Test workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop current execution" data-test-id="stop-execution-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop waiting for webhook call" data-test-id="stop-execution-waiting-for-webhook-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
defineProps<{ id: string }>();
</script>
<template>
<svg>
<defs>
<marker
:id="id"
viewBox="-10 -10 20 20"
refX="0"
refY="0"
markerWidth="12.5"
markerHeight="12.5"
markerUnits="strokeWidth"
orient="auto-start-reverse"
>
<polyline
stroke-linecap="round"
stroke-linejoin="round"
points="-5,-4 0,0 -5,4 -5,-4"
stroke-width="2"
stroke="context-stroke"
fill="context-stroke"
/>
</marker>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,82 @@
import CanvasConnectionLine from './CanvasConnectionLine.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { ConnectionLineProps } from '@vue-flow/core';
import { Position } from '@vue-flow/core';
import { createCanvasProvide } from '@/__tests__/data';
import { waitFor } from '@testing-library/vue';
const DEFAULT_PROPS = {
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Top,
targetX: 100,
targetY: 100,
targetPosition: Position.Bottom,
} satisfies Partial<ConnectionLineProps>;
const renderComponent = createComponentRenderer(CanvasConnectionLine, {
props: DEFAULT_PROPS,
global: {
provide: {
...createCanvasProvide(),
},
},
});
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasConnectionLine', () => {
it('should render a correct bezier path', () => {
const { container } = renderComponent({
props: DEFAULT_PROPS,
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveAttribute('d', 'M0,0 C0,-62.5 100,162.5 100,100');
});
it('should render a correct smooth step path when the connection is backwards', () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: -100,
targetY: -100,
targetPosition: Position.Left,
},
});
const edges = container.querySelectorAll('.vue-flow__edge-path');
expect(edges[0]).toHaveAttribute(
'd',
'M0 0L 24,0Q 40,0 40,16L 40,114Q 40,130 24,130L-10 130L-50 130',
);
expect(edges[1]).toHaveAttribute(
'd',
'M-50 130L-90 130L -124,130Q -140,130 -140,114L -140,-84Q -140,-100 -124,-100L-100 -100',
);
});
it('should show the connection line after a short delay', async () => {
vi.useFakeTimers();
const { container } = renderComponent({
props: DEFAULT_PROPS,
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).not.toHaveClass('visible');
vi.advanceTimersByTime(300);
await waitFor(() => expect(edge).toHaveClass('visible'));
});
});

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
/* eslint-disable vue/no-multiple-template-root */
import type { ConnectionLineProps } from '@vue-flow/core';
import { BaseEdge } from '@vue-flow/core';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { getEdgeRenderData } from './utils';
import { useCanvas } from '@/composables/useCanvas';
import { NodeConnectionType } from 'n8n-workflow';
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtils';
const props = defineProps<ConnectionLineProps>();
const $style = useCssModule();
const { connectingHandle } = useCanvas();
const connectionType = computed(
() => parseCanvasConnectionHandleString(connectingHandle.value?.handleId).type,
);
const classes = computed(() => {
return {
[$style.edge]: true,
[$style.visible]: isVisible.value,
};
});
const edgeColor = computed(() => {
if (connectionType.value !== NodeConnectionType.Main) {
return 'var(--node-type-supplemental-color)';
} else {
return 'var(--color-foreground-xdark)';
}
});
const edgeStyle = computed(() => ({
...(connectionType.value === NodeConnectionType.Main ? {} : { strokeDasharray: '8,8' }),
strokeWidth: 2,
stroke: edgeColor.value,
}));
const renderData = computed(() =>
getEdgeRenderData(props, { connectionType: connectionType.value }),
);
const segments = computed(() => renderData.value.segments);
/**
* Used to delay the visibility of the connection line to prevent flickering
* when the actual user intent is to click the plus button
*/
const isVisible = ref(false);
onMounted(() => {
setTimeout(() => {
isVisible.value = true;
}, 300);
});
</script>
<template>
<BaseEdge
v-for="segment in segments"
:key="segment[0]"
:class="classes"
:style="edgeStyle"
:path="segment[0]"
:marker-end="markerEnd"
/>
</template>
<style lang="scss" module>
.edge {
transition-property: stroke, opacity;
transition-duration: 300ms;
transition-timing-function: ease;
opacity: 0;
&.visible {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,206 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { Position } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import CanvasEdge, { type CanvasEdgeProps } from './CanvasEdge.vue';
const DEFAULT_PROPS = {
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Top,
targetX: 100,
targetY: 100,
targetPosition: Position.Bottom,
data: {
status: undefined,
source: { index: 0, type: NodeConnectionType.Main },
target: { index: 0, type: NodeConnectionType.Main },
},
} satisfies Partial<CanvasEdgeProps>;
const renderComponent = createComponentRenderer(CanvasEdge, {
props: DEFAULT_PROPS,
});
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasEdge', () => {
it('should emit delete event when toolbar delete is clicked', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
hovered: true,
},
});
await userEvent.hover(getByTestId('edge-label'));
const deleteButton = getByTestId('delete-connection-button');
await userEvent.click(deleteButton);
expect(emitted()).toHaveProperty('delete');
});
it('should emit add event when toolbar add is clicked', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
hovered: true,
},
});
await userEvent.hover(getByTestId('edge-label'));
const addButton = getByTestId('add-connection-button');
await userEvent.click(addButton);
expect(emitted()).toHaveProperty('add');
});
it('should not render toolbar actions when readOnly', async () => {
const { getByTestId } = renderComponent({
props: {
readOnly: true,
},
});
await userEvent.hover(getByTestId('edge-label'));
expect(() => getByTestId('add-connection-button')).toThrow();
expect(() => getByTestId('delete-connection-button')).toThrow();
});
it('should hide toolbar after delay', async () => {
vi.useFakeTimers();
const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime,
});
const { rerender, getByTestId, queryByTestId } = renderComponent({
props: { hovered: true },
});
await user.hover(getByTestId('edge-label'));
expect(queryByTestId('canvas-edge-toolbar')).toBeInTheDocument();
await rerender({ hovered: false });
await user.unhover(getByTestId('edge-label'));
expect(getByTestId('canvas-edge-toolbar')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(300);
expect(queryByTestId('canvas-edge-toolbar')).not.toBeInTheDocument();
});
it('should compute edgeStyle correctly', () => {
const { container } = renderComponent();
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-foreground-xdark)',
});
});
it('should correctly style a pinned connection', () => {
const { container } = renderComponent({
props: { ...DEFAULT_PROPS, data: { ...DEFAULT_PROPS.data, status: 'pinned' } },
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-secondary)',
});
});
it('should render a correct bezier path', () => {
const { container } = renderComponent({
props: DEFAULT_PROPS,
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveAttribute('d', 'M0,0 C0,-62.5 100,162.5 100,100');
});
it('should render a correct smooth step path when the connection is backwards', () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: -100,
targetY: -100,
targetPosition: Position.Left,
},
});
const edges = container.querySelectorAll('.vue-flow__edge-path');
expect(edges[0]).toHaveAttribute(
'd',
'M0 0L 24,0Q 40,0 40,16L 40,114Q 40,130 24,130L-10 130L-50 130',
);
expect(edges[1]).toHaveAttribute(
'd',
'M-50 130L-90 130L -124,130Q -140,130 -140,114L -140,-84Q -140,-100 -124,-100L-100 -100',
);
});
it('should render a correct bezier path when the connection is backwards and node connection type is non-main', () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
data: {
...DEFAULT_PROPS.data,
source: {
type: NodeConnectionType.AiTool,
},
},
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: -100,
targetY: -100,
targetPosition: Position.Left,
},
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveAttribute('d', 'M0,0 C62.5,0 -162.5,-100 -100,-100');
});
it('should render a label above the connector when it is straight', () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
sourceY: 50,
targetY: 50,
},
});
const label = container.querySelector('.vue-flow__edge-label')?.childNodes[0];
expect(label).toHaveAttribute('style', 'transform: translate(0, -100%);');
});
it("should render a label in the middle of the connector when it isn't straight", () => {
const { container } = renderComponent({
props: {
...DEFAULT_PROPS,
sourceY: 0,
targetY: 100,
},
});
const label = container.querySelector('.vue-flow__edge-label')?.childNodes[0];
expect(label).toHaveAttribute('style', 'transform: translate(0, 0%);');
});
});

View File

@@ -0,0 +1,201 @@
<script lang="ts" setup>
/* eslint-disable vue/no-multiple-template-root */
import type { CanvasConnectionData } from '@/types';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import { computed, ref, toRef, useCssModule, watch } from 'vue';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { getEdgeRenderData } from './utils';
const emit = defineEmits<{
add: [connection: Connection];
delete: [connection: Connection];
'update:label:hovered': [hovered: boolean];
}>();
export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
readOnly?: boolean;
hovered?: boolean;
bringToFront?: boolean; // Determines if entire edges layer should be brought to front
};
const props = defineProps<CanvasEdgeProps>();
const data = toRef(props, 'data');
const $style = useCssModule();
const connectionType = computed(() =>
isValidNodeConnectionType(props.data.source.type)
? props.data.source.type
: NodeConnectionType.Main,
);
const delayedHovered = ref(props.hovered);
const delayedHoveredSetTimeoutRef = ref<NodeJS.Timeout | null>(null);
const delayedHoveredTimeout = 300;
watch(
() => props.hovered,
(isHovered) => {
if (isHovered) {
if (delayedHoveredSetTimeoutRef.value) clearTimeout(delayedHoveredSetTimeoutRef.value);
delayedHovered.value = true;
} else {
delayedHoveredSetTimeoutRef.value = setTimeout(() => {
delayedHovered.value = false;
}, delayedHoveredTimeout);
}
},
{ immediate: true },
);
const renderToolbar = computed(() => (props.selected || delayedHovered.value) && !props.readOnly);
const isMainConnection = computed(() => data.value.source.type === NodeConnectionType.Main);
const status = computed(() => props.data.status);
const edgeColor = computed(() => {
if (status.value === 'success') {
return 'var(--color-success)';
} else if (status.value === 'pinned') {
return 'var(--color-secondary)';
} else if (!isMainConnection.value) {
return 'var(--node-type-supplemental-color)';
} else if (props.selected) {
return 'var(--color-background-dark)';
} else {
return 'var(--color-foreground-xdark)';
}
});
const edgeStyle = computed(() => ({
...props.style,
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
strokeWidth: 2,
stroke: delayedHovered.value ? 'var(--color-primary)' : edgeColor.value,
}));
const edgeClasses = computed(() => ({
[$style.edge]: true,
hovered: delayedHovered.value,
'bring-to-front': props.bringToFront,
}));
const edgeLabelStyle = computed(() => ({
transform: `translate(0, ${isConnectorStraight.value ? '-100%' : '0%'})`,
color: 'var(--color-text-base)',
}));
const isConnectorStraight = computed(() => renderData.value.isConnectorStraight);
const edgeToolbarStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${labelPosition.value[0]}px, ${labelPosition.value[1]}px)`,
...(delayedHovered.value ? { zIndex: 1 } : {}),
}));
const edgeToolbarClasses = computed(() => ({
[$style.edgeLabelWrapper]: true,
'vue-flow__edge-label': true,
selected: props.selected,
}));
const renderData = computed(() =>
getEdgeRenderData(props, {
connectionType: connectionType.value,
}),
);
const segments = computed(() => renderData.value.segments);
const labelPosition = computed(() => renderData.value.labelPosition);
const connection = computed<Connection>(() => ({
source: props.source,
target: props.target,
sourceHandle: props.sourceHandleId,
targetHandle: props.targetHandleId,
}));
function onAdd() {
emit('add', connection.value);
}
function onDelete() {
emit('delete', connection.value);
}
function onEdgeLabelMouseEnter() {
emit('update:label:hovered', true);
}
function onEdgeLabelMouseLeave() {
emit('update:label:hovered', false);
}
</script>
<template>
<g
data-test-id="edge"
:data-source-node-name="data.source?.node"
:data-target-node-name="data.target?.node"
>
<BaseEdge
v-for="(segment, index) in segments"
:id="`${id}-${index}`"
:key="segment[0]"
:class="edgeClasses"
:style="edgeStyle"
:path="segment[0]"
:marker-end="markerEnd"
:interaction-width="40"
/>
</g>
<EdgeLabelRenderer>
<div
data-test-id="edge-label"
:data-source-node-name="data.source?.node"
:data-target-node-name="data.target?.node"
:data-edge-status="status"
:style="edgeToolbarStyle"
:class="edgeToolbarClasses"
@mouseenter="onEdgeLabelMouseEnter"
@mouseleave="onEdgeLabelMouseLeave"
>
<CanvasEdgeToolbar
v-if="renderToolbar"
:type="connectionType"
@add="onAdd"
@delete="onDelete"
/>
<div v-else :style="edgeLabelStyle" :class="$style.edgeLabel">{{ label }}</div>
</div>
</EdgeLabelRenderer>
</template>
<style lang="scss" module>
.edge {
transition:
stroke 0.3s ease,
fill 0.3s ease;
}
.edgeLabelWrapper {
transform: translateY(calc(var(--spacing-xs) * -1));
position: absolute;
}
.edgeLabel {
font-size: var(--font-size-xs);
background-color: hsla(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l),
0.85
);
}
</style>

View File

@@ -0,0 +1,16 @@
import { fireEvent } from '@testing-library/vue';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasEdgeToolbar);
describe('CanvasEdgeToolbar', () => {
it('should emit delete event when delete button is clicked', async () => {
const { getByTestId, emitted } = renderComponent();
const deleteButton = getByTestId('delete-connection-button');
await fireEvent.click(deleteButton);
expect(emitted()).toHaveProperty('delete');
});
});

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed, useCssModule } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
const emit = defineEmits<{
add: [];
delete: [];
}>();
const props = defineProps<{
type: NodeConnectionType;
}>();
const $style = useCssModule();
const i18n = useI18n();
const classes = computed(() => ({
[$style.canvasEdgeToolbar]: true,
}));
const isAddButtonVisible = computed(() => props.type === NodeConnectionType.Main);
function onAdd() {
emit('add');
}
function onDelete() {
emit('delete');
}
</script>
<template>
<div :class="classes" data-test-id="canvas-edge-toolbar">
<N8nIconButton
v-if="isAddButtonVisible"
class="canvas-edge-toolbar-button"
data-test-id="add-connection-button"
type="tertiary"
size="small"
icon="plus"
:title="i18n.baseText('node.add')"
@click="onAdd"
/>
<N8nIconButton
data-test-id="delete-connection-button"
class="canvas-edge-toolbar-button"
type="tertiary"
size="small"
icon="trash"
:title="i18n.baseText('node.delete')"
@click="onDelete"
/>
</div>
</template>
<style lang="scss" module>
.canvasEdgeToolbar {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2xs);
pointer-events: all;
}
</style>
<style lang="scss">
@mixin dark-button-styles {
--button-background-color: var(--color-background-base);
--button-hover-background-color: var(--color-background-light);
}
@media (prefers-color-scheme: dark) {
body:not([data-theme]) .canvas-edge-toolbar-button {
@include dark-button-styles();
}
}
[data-theme='dark'] .canvas-edge-toolbar-button {
@include dark-button-styles();
}
.canvas-edge-toolbar-button {
border-width: 2px;
}
</style>

View File

@@ -0,0 +1,66 @@
import type { EdgeProps } from '@vue-flow/core';
import { getBezierPath, getSmoothStepPath, Position } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
const EDGE_PADDING_BOTTOM = 130;
const EDGE_PADDING_X = 40;
const EDGE_BORDER_RADIUS = 16;
const HANDLE_SIZE = 20; // Required to avoid connection line glitching when initially interacting with the handle
const isRightOfSourceHandle = (sourceX: number, targetX: number) => sourceX - HANDLE_SIZE > targetX;
export function getEdgeRenderData(
props: Pick<
EdgeProps,
'sourceX' | 'sourceY' | 'sourcePosition' | 'targetX' | 'targetY' | 'targetPosition'
>,
{
connectionType = NodeConnectionType.Main,
}: {
connectionType?: NodeConnectionType;
} = {},
) {
const { targetX, targetY, sourceX, sourceY, sourcePosition, targetPosition } = props;
const isConnectorStraight = sourceY === targetY;
if (!isRightOfSourceHandle(sourceX, targetX) || connectionType !== NodeConnectionType.Main) {
const segment = getBezierPath(props);
return {
segments: [segment],
labelPosition: [segment[1], segment[2]],
isConnectorStraight,
};
}
// Connection is backwards and the source is on the right side
// -> We need to avoid overlapping the source node
const firstSegmentTargetX = (sourceX + targetX) / 2;
const firstSegmentTargetY = sourceY + EDGE_PADDING_BOTTOM;
const firstSegment = getSmoothStepPath({
sourceX,
sourceY,
targetX: firstSegmentTargetX,
targetY: firstSegmentTargetY,
sourcePosition,
targetPosition: Position.Right,
borderRadius: EDGE_BORDER_RADIUS,
offset: EDGE_PADDING_X,
});
const secondSegment = getSmoothStepPath({
sourceX: firstSegmentTargetX,
sourceY: firstSegmentTargetY,
targetX,
targetY,
sourcePosition: Position.Left,
targetPosition,
borderRadius: EDGE_BORDER_RADIUS,
offset: EDGE_PADDING_X,
});
return {
segments: [firstSegment, secondSegment],
labelPosition: [firstSegmentTargetX, firstSegmentTargetY],
isConnectorStraight,
};
}

View File

@@ -0,0 +1 @@
export * from './getEdgeRenderData';

View File

@@ -0,0 +1,101 @@
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(CanvasHandleRenderer);
const Handle = {
template: '<div><slot /></div>',
};
describe('CanvasHandleRenderer', () => {
it('should render the main input handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
position: 'left',
offset: { left: '10px', top: '10px' },
label: 'Main Input',
},
global: {
stubs: {
Handle,
},
},
});
expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.inputs.main')).toBeInTheDocument();
});
it('should render the main output handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
position: 'right',
offset: { right: '10px', bottom: '10px' },
label: 'Main Output',
},
global: {
stubs: {
Handle,
},
},
});
expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.outputs.main')).toBeInTheDocument();
});
it('should render the non-main handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.AiTool,
index: 0,
position: 'top',
offset: { top: '10px', left: '5px' },
label: 'AI Tool Input',
},
global: {
stubs: {
Handle,
},
},
});
expect(container.querySelector('.handle')).toBeInTheDocument();
expect(container.querySelector('.inputs.ai_tool')).toBeInTheDocument();
});
it('should provide the label correctly', async () => {
const label = 'Test Label';
const { getByText } = renderComponent({
props: {
mode: 'input',
type: NodeConnectionType.AiTool,
index: 0,
position: 'top',
offset: { top: '10px', left: '5px' },
label,
},
global: {
provide: {
[String(CanvasNodeHandleKey)]: { label: ref(label) },
},
stubs: {
Handle,
},
},
});
expect(getByText(label)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,211 @@
<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 CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue';
import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue';
import { CanvasNodeHandleKey } from '@/constants';
import { useCanvasNode } from '@/composables/useCanvasNode';
const props = defineProps<
CanvasElementPortWithRenderData & {
type: CanvasConnectionPort['type'];
required?: CanvasConnectionPort['required'];
maxConnections?: CanvasConnectionPort['maxConnections'];
index: CanvasConnectionPort['index'];
label?: CanvasConnectionPort['label'];
handleId: CanvasElementPortWithRenderData['handleId'];
connectionsCount: CanvasElementPortWithRenderData['connectionsCount'];
isConnecting: CanvasElementPortWithRenderData['isConnecting'];
position: CanvasElementPortWithRenderData['position'];
offset?: CanvasElementPortWithRenderData['offset'];
mode: CanvasConnectionMode;
isReadOnly?: boolean;
isValidConnection: ValidConnectionFunc;
}
>();
const emit = defineEmits<{
add: [handle: string];
}>();
defineOptions({
inheritAttrs: false,
});
const style = useCssModule();
const handleType = computed(() =>
props.mode === CanvasConnectionMode.Input ? 'target' : 'source',
);
const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]);
/**
* Connectable
*/
const connectionsLimitReached = computed(() => {
return props.maxConnections && props.connectionsCount >= props.maxConnections;
});
const isConnectableStart = computed(() => {
if (connectionsLimitReached.value) return false;
return props.mode === CanvasConnectionMode.Output || props.type !== NodeConnectionType.Main;
});
const isConnectableEnd = computed(() => {
if (connectionsLimitReached.value) return false;
return props.mode === CanvasConnectionMode.Input || props.type !== NodeConnectionType.Main;
});
const isConnected = computed(() => props.connectionsCount > 0);
/**
* Run data
*/
const { runDataOutputMap } = useCanvasNode();
const runData = computed(() =>
props.mode === CanvasConnectionMode.Output
? runDataOutputMap.value[props.type]?.[props.index]
: undefined,
);
/**
* Render additional elements
*/
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 {
Component = CanvasHandleNonMainOutput;
}
} else {
if (props.type === NodeConnectionType.Main) {
Component = CanvasHandleMainInput;
} else {
Component = CanvasHandleNonMainInput;
}
}
return Component ? h(Component) : null;
};
/**
* Event bindings
*/
function onAdd() {
emit('add', props.handleId);
}
/**
* Provide
*/
const label = toRef(props, 'label');
const isConnecting = toRef(props, 'isConnecting');
const isReadOnly = toRef(props, 'isReadOnly');
const mode = toRef(props, 'mode');
const type = toRef(props, 'type');
const index = toRef(props, 'index');
const isRequired = toRef(props, 'required');
const maxConnections = toRef(props, 'maxConnections');
provide(CanvasNodeHandleKey, {
label,
mode,
type,
index,
runData,
isRequired,
isConnected,
isConnecting,
isReadOnly,
maxConnections,
});
</script>
<template>
<Handle
v-bind="$attrs"
:id="handleId"
:class="handleClasses"
:type="handleType"
:position="position"
:style="offset"
:connectable-start="isConnectableStart"
:connectable-end="isConnectableEnd"
:is-valid-connection="isValidConnection"
>
<RenderType
:class="renderTypeClasses"
:is-connected="isConnected"
:max-connections="maxConnections"
:style="offset"
:label="label"
@add="onAdd"
/>
</Handle>
</template>
<style lang="scss" module>
.handle {
--handle--indicator--width: 16px;
--handle--indicator--height: 16px;
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
display: inline-flex;
justify-content: center;
align-items: center;
border: 0;
z-index: 1;
background: transparent;
border-radius: 0;
&.inputs {
&.main {
--handle--indicator--width: 8px;
}
}
}
.renderType {
&.top {
margin-bottom: calc(-1 * var(--handle--indicator--height));
transform: translate(0%, -50%);
}
&.right {
margin-left: calc(-1 * var(--handle--indicator--width));
transform: translate(50%, 0%);
}
&.left {
margin-right: calc(-1 * var(--handle--indicator--width));
transform: translate(-50%, 0%);
}
&.bottom {
margin-top: calc(-1 * var(--handle--indicator--height));
transform: translate(0%, 50%);
}
}
</style>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { computed, useCssModule } from 'vue';
const $style = useCssModule();
const { label, isRequired } = useCanvasNodeHandle();
const classes = computed(() => ({
'canvas-node-handle-main-input': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
const handleClasses = 'target';
</script>
<template>
<div :class="classes">
<div :class="[$style.label]">{{ label }}</div>
<CanvasHandleRectangle :handle-classes="handleClasses" />
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.label {
position: absolute;
top: 50%;
left: calc(var(--spacing-xs) * -1);
transform: translate(-100%, -50%);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-canvas-label-background);
z-index: 1;
text-align: center;
white-space: nowrap;
}
.required .label::after {
content: '*';
color: var(--color-danger);
}
</style>

View File

@@ -0,0 +1,98 @@
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasHandleProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasHandleMainOutput);
describe('CanvasHandleMainOutput', () => {
it('should render correctly', async () => {
const label = 'Test Label';
const { container, getByText, getByTestId } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label }),
},
},
});
expect(container.querySelector('.canvas-node-handle-main-output')).toBeInTheDocument();
expect(getByTestId('canvas-handle-plus')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
});
it('should not render CanvasHandlePlus when isReadOnly', () => {
const { queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ isReadOnly: true }),
},
},
});
expect(queryByTestId('canvas-handle-plus')).not.toBeInTheDocument();
});
it('should render CanvasHandlePlus with success state when runData.total > 1', () => {
const { queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({
runData: {
total: 2,
iterations: 1,
},
}),
},
},
});
expect(queryByTestId('canvas-handle-plus-wrapper')).toHaveClass('success');
});
it('should render run data label', async () => {
const runData = {
total: 1,
iterations: 1,
};
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label: '', runData }),
},
},
});
expect(getByText('1 item')).toBeInTheDocument();
});
it('should render run data label even if output label is available', async () => {
const runData = {
total: 1,
iterations: 1,
};
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label: 'Output', runData }),
},
},
});
expect(getByText('1 item')).toBeInTheDocument();
expect(getByText('Output')).toBeInTheDocument();
});
it('should not render run data label if handle is connected', async () => {
const runData = {
total: 1,
iterations: 1,
};
const { queryByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label: '', runData, isConnected: true }),
},
},
});
expect(queryByText('1 item')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { computed, ref, useCssModule } from 'vue';
import type { CanvasNodeDefaultRender } from '@/types';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
add: [];
}>();
const $style = useCssModule();
const i18n = useI18n();
const { render } = useCanvasNode();
const { label, isConnected, isConnecting, isReadOnly, isRequired, runData } = useCanvasNodeHandle();
const handleClasses = 'source';
const classes = computed(() => ({
'canvas-node-handle-main-output': true,
[$style.handle]: true,
[$style.connected]: isConnected.value,
[$style.required]: isRequired.value,
}));
const isHovered = ref(false);
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const runDataTotal = computed(() => runData.value?.total ?? 0);
const runDataLabel = computed(() =>
!isConnected.value && runData.value && runData.value.total > 0
? i18n.baseText('ndv.output.items', {
adjustToNumber: runData.value.total,
interpolate: { count: String(runData.value.total) },
})
: '',
);
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
const plusType = computed(() => (runDataTotal.value > 0 ? 'success' : 'default'));
const plusLineSize = computed(
() =>
({
small: 46,
medium: 66,
large: 80,
})[(runDataTotal.value > 0 ? 'large' : renderOptions.value.outputs?.labelSize) ?? 'small'],
);
const outputLabelClasses = computed(() => ({
[$style.label]: true,
[$style.outputLabel]: true,
}));
const runDataLabelClasses = computed(() => ({
[$style.label]: true,
[$style.runDataLabel]: true,
}));
function onMouseEnter() {
isHovered.value = true;
}
function onMouseLeave() {
isHovered.value = false;
}
function onClickAdd() {
emit('add');
}
</script>
<template>
<div :class="classes">
<div v-if="label" :class="outputLabelClasses">{{ label }}</div>
<div v-if="runData" :class="runDataLabelClasses">{{ runDataLabel }}</div>
<CanvasHandleDot :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-main-output">
<CanvasHandlePlus
v-if="!isConnected && !isReadOnly"
v-show="isHandlePlusVisible"
:data-plus-type="plusType"
:line-size="plusLineSize"
:handle-classes="handleClasses"
:type="plusType"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click:plus="onClickAdd"
/>
</Transition>
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
&.connected .label {
max-width: 96px;
}
}
.label {
position: absolute;
background: var(--color-canvas-label-background);
z-index: 1;
max-width: calc(100% - var(--spacing-m) - 24px);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.required .label::after {
content: '*';
color: var(--color-danger);
}
.outputLabel {
top: 50%;
left: var(--spacing-m);
transform: translate(0, -50%);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
}
.runDataLabel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -150%);
font-size: var(--font-size-xs);
color: var(--color-text-base);
}
</style>
<style lang="scss">
.canvas-node-handle-main-output-enter-active,
.canvas-node-handle-main-output-leave-active {
transform-origin: 0 center;
transition-property: transform, opacity;
transition-duration: 0.2s;
transition-timing-function: ease;
}
.canvas-node-handle-main-output-enter-from,
.canvas-node-handle-main-output-leave-to {
transform: scale(0);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,21 @@
import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasHandleProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasHandleNonMainInput);
describe('CanvasHandleNonMainInput', () => {
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-input')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { computed, ref, useCssModule } from 'vue';
const emit = defineEmits<{
add: [];
}>();
const $style = useCssModule();
const { label, isConnected, isConnecting, isRequired, maxConnections } = useCanvasNodeHandle();
const handleClasses = 'target';
const classes = computed(() => ({
'canvas-node-handle-non-main-input': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
const isHandlePlusAvailable = computed(
() => !isConnected.value || !maxConnections.value || maxConnections.value > 1,
);
const isHandlePlusVisible = computed(
() => !isConnecting.value || isHovered.value || !maxConnections.value || maxConnections.value > 1,
);
const isHovered = ref(false);
function onMouseEnter() {
isHovered.value = true;
}
function onMouseLeave() {
isHovered.value = false;
}
function onClickAdd() {
emit('add');
}
</script>
<template>
<div :class="classes">
<div :class="[$style.label]">{{ label }}</div>
<CanvasHandleDiamond :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-non-main-input">
<CanvasHandlePlus
v-if="isHandlePlusAvailable"
v-show="isHandlePlusVisible"
:handle-classes="handleClasses"
type="secondary"
position="bottom"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click:plus="onClickAdd"
/>
</Transition>
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.label {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, 0);
font-size: var(--font-size-2xs);
color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background);
z-index: 1;
text-align: center;
white-space: nowrap;
}
.required .label::after {
content: '*';
color: var(--color-danger);
}
</style>
<style lang="scss">
.canvas-node-handle-non-main-input-enter-active,
.canvas-node-handle-non-main-input-leave-active {
transform-origin: center 0;
transition-property: transform, opacity;
transition-duration: 0.2s;
transition-timing-function: ease;
}
.canvas-node-handle-non-main-input-enter-from,
.canvas-node-handle-non-main-input-leave-to {
transform: scale(0);
opacity: 0;
}
</style>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { computed, useCssModule } from 'vue';
const $style = useCssModule();
const { label, isRequired } = useCanvasNodeHandle();
const handleClasses = 'source';
const classes = computed(() => ({
'canvas-node-handle-non-main-output': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
</script>
<template>
<div :class="classes">
<div :class="$style.label">{{ label }}</div>
<CanvasHandleDiamond :handle-classes="handleClasses" />
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.label {
position: absolute;
top: -20px;
left: 50%;
transform: translate(-50%, 0);
font-size: var(--font-size-2xs);
color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background);
z-index: 0;
white-space: nowrap;
}
.required .label::after {
content: '*';
color: var(--color-danger);
}
:global(.vue-flow__handle:not(.connectionindicator)) .plus {
display: none;
position: absolute;
}
</style>

View File

@@ -0,0 +1,21 @@
import CanvasHandleDiamond from './CanvasHandleDiamond.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasHandleDiamond, {});
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();
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.diamond, handleClasses]" />
</template>
<style lang="scss" module>
.diamond {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
background: var(--node-type-supplemental-color);
transform: rotate(45deg) scale(0.8);
&:hover {
background: var(--color-primary);
}
}
</style>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.dot, handleClasses]" />
</template>
<style lang="scss" module>
.dot {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
border-radius: 50%;
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
</style>

View File

@@ -0,0 +1,64 @@
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('should emit click:plus event when plus icon is clicked', async () => {
const { container, emitted } = renderComponent();
const plusIcon = container.querySelector('.plus');
if (!plusIcon) throw new Error('Plus icon not found');
await fireEvent.click(plusIcon);
expect(emitted()).toHaveProperty('click:plus');
});
it('should apply 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('should apply correct classes based on status', () => {
const { container } = renderComponent({
props: { type: 'success' },
});
expect(container.firstChild).toHaveClass('success');
});
it('should render SVG elements correctly', () => {
const { container } = renderComponent();
const svg = container.querySelector('svg');
expect(svg).toBeTruthy();
expect(svg?.getAttribute('viewBox')).toBe('0 0 70 24');
const lineSvg = container.querySelector('line');
expect(lineSvg).toBeTruthy();
const plusSvg = container.querySelector('.plus');
expect(plusSvg).toBeTruthy();
});
});

View File

@@ -0,0 +1,180 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
const props = withDefaults(
defineProps<{
position?: 'top' | 'right' | 'bottom' | 'left';
handleClasses?: string;
plusSize?: number;
lineSize?: number;
type?: 'success' | 'secondary' | 'default';
}>(),
{
position: 'right',
handleClasses: undefined,
plusSize: 24,
lineSize: 46,
type: 'default',
},
);
const emit = defineEmits<{
'click:plus': [event: MouseEvent];
}>();
const style = useCssModule();
const classes = computed(() => [
style.wrapper,
style[props.position],
style[props.type],
props.handleClasses,
]);
const viewBox = computed(() => {
switch (props.position) {
case 'bottom':
case 'top':
return {
width: props.plusSize,
height: props.lineSize + props.plusSize,
};
default:
return {
width: props.lineSize + props.plusSize,
height: props.plusSize,
};
}
});
const styles = computed(() => ({
width: `${viewBox.value.width}px`,
height: `${viewBox.value.height}px`,
}));
const linePosition = computed(() => {
switch (props.position) {
case 'top':
return [
[viewBox.value.width / 2, viewBox.value.height - props.lineSize + 1],
[viewBox.value.width / 2, viewBox.value.height],
];
case 'bottom':
return [
[viewBox.value.width / 2, 0],
[viewBox.value.width / 2, props.lineSize + 1],
];
case 'left':
return [
[viewBox.value.width - props.lineSize - 1, viewBox.value.height / 2],
[viewBox.value.width, viewBox.value.height / 2],
];
default:
return [
[0, viewBox.value.height / 2],
[props.lineSize + 1, viewBox.value.height / 2],
];
}
});
const plusPosition = computed(() => {
switch (props.position) {
case 'bottom':
return [0, viewBox.value.height - props.plusSize];
case 'top':
return [0, 0];
case 'left':
return [0, 0];
default:
return [viewBox.value.width - props.plusSize, 0];
}
});
function onClick(event: MouseEvent) {
emit('click:plus', event);
}
</script>
<template>
<svg
data-test-id="canvas-handle-plus-wrapper"
:class="classes"
:viewBox="`0 0 ${viewBox.width} ${viewBox.height}`"
:style="styles"
>
<line
:class="[handleClasses, $style.line]"
:x1="linePosition[0][0]"
:y1="linePosition[0][1]"
:x2="linePosition[1][0]"
:y2="linePosition[1][1]"
stroke="var(--color-foreground-xdark)"
stroke-width="2"
/>
<g
data-test-id="canvas-handle-plus"
:class="[$style.plus, handleClasses, 'clickable']"
:transform="`translate(${plusPosition[0]}, ${plusPosition[1]})`"
@click.stop="onClick"
>
<rect
:class="[handleClasses, 'clickable']"
x="2"
y="2"
width="20"
height="20"
stroke="var(--color-foreground-xdark)"
stroke-width="2"
rx="4"
fill="var(--color-foreground-xlight)"
/>
<path
:class="[handleClasses, 'clickable']"
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>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
&.secondary {
.line {
stroke: var(--node-type-supplemental-color);
}
.plus {
path {
fill: var(--node-type-supplemental-color);
}
rect {
stroke: var(--node-type-supplemental-color);
}
}
}
&.success {
.line {
stroke: var(--color-success);
}
}
.plus {
&:hover {
cursor: pointer;
path {
fill: var(--color-primary);
}
rect {
stroke: var(--color-primary);
}
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
import CanvasHandleRectangle from './CanvasHandleRectangle.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasHandleRectangle, {});
describe('CanvasHandleRectangle', () => {
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();
});
});

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.rectangle, handleClasses]" />
</template>
<style lang="scss" module>
.rectangle {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
</style>

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleDiamond > should render with default props 1`] = `"<div class="diamond"></div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleDot > should render with default props 1`] = `"<div class="dot"></div>"`;

View File

@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandlePlus > should render with default props 1`] = `
"<svg data-test-id="canvas-handle-plus-wrapper" class="wrapper right default" viewBox="0 0 70 24" style="width: 70px; height: 24px;">
<line class="line" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
<g data-test-id="canvas-handle-plus" class="plus clickable" transform="translate(46, 0)">
<rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="var(--color-foreground-xlight)"></rect>
<path class="clickable" 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>"
`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleRectangle > should render with default props 1`] = `"<div class="rectangle"></div>"`;

View File

@@ -0,0 +1,180 @@
import CanvasNode from '@/components/canvas/elements/nodes/CanvasNode.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createPinia, setActivePinia } from 'pinia';
import { NodeConnectionType } from 'n8n-workflow';
import { fireEvent } from '@testing-library/vue';
import { createCanvasNodeData, createCanvasNodeProps, createCanvasProvide } from '@/__tests__/data';
import { CanvasNodeRenderType } from '@/types';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeType: vi.fn(() => ({
name: 'test',
description: 'Test Node Description',
})),
})),
}));
let renderComponent: ReturnType<typeof createComponentRenderer>;
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
renderComponent = createComponentRenderer(CanvasNode, {
pinia,
global: {
provide: {
...createCanvasProvide(),
},
},
});
});
describe('CanvasNode', () => {
it('should render node correctly', async () => {
const { getByTestId, getByText } = renderComponent({
props: {
...createCanvasNodeProps(),
},
});
expect(getByText('Test Node')).toBeInTheDocument();
expect(getByTestId('canvas-node')).toBeInTheDocument();
});
describe('classes', () => {
it('should apply selected class when node is selected', async () => {
const { getByText } = renderComponent({
props: {
...createCanvasNodeProps({ selected: true }),
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
});
});
describe('handles', () => {
it('should render correct number of input and output handles', async () => {
const { getAllByTestId } = renderComponent({
props: {
...createCanvasNodeProps({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
outputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
},
}),
},
global: {
stubs: {
CanvasHandleRenderer: true,
},
},
});
const inputHandles = getAllByTestId('canvas-node-input-handle');
const outputHandles = getAllByTestId('canvas-node-output-handle');
expect(inputHandles.length).toBe(3);
expect(outputHandles.length).toBe(2);
});
it('should insert spacers after required non-main input handle', () => {
const { getAllByTestId } = renderComponent({
props: {
...createCanvasNodeProps({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0, required: true },
{ type: NodeConnectionType.AiTool, index: 0 },
],
outputs: [],
},
}),
},
global: {
stubs: {
Handle: true,
},
},
});
const inputHandles = getAllByTestId('canvas-node-input-handle');
expect(inputHandles[1]).toHaveStyle('left: 20%');
expect(inputHandles[2]).toHaveStyle('left: 80%');
});
});
describe('toolbar', () => {
it('should render toolbar when node is hovered', async () => {
const { getByTestId } = renderComponent({
props: {
...createCanvasNodeProps(),
},
});
const node = getByTestId('canvas-node');
await fireEvent.mouseOver(node);
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
expect(getByTestId('execute-node-button')).toBeInTheDocument();
expect(getByTestId('disable-node-button')).toBeInTheDocument();
expect(getByTestId('delete-node-button')).toBeInTheDocument();
expect(getByTestId('overflow-node-button')).toBeInTheDocument();
});
it('should contain only context menu when node is disabled', async () => {
const { getByTestId } = renderComponent({
props: {
...createCanvasNodeProps({
readOnly: true,
}),
},
});
const node = getByTestId('canvas-node');
await fireEvent.mouseOver(node);
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
expect(() => getByTestId('execute-node-button')).toThrow();
expect(() => getByTestId('disable-node-button')).toThrow();
expect(() => getByTestId('delete-node-button')).toThrow();
expect(getByTestId('overflow-node-button')).toBeInTheDocument();
});
});
describe('execute workflow button', () => {
const triggerNodeData = createCanvasNodeData({
name: 'foo',
render: {
type: CanvasNodeRenderType.Default,
options: { trigger: true },
},
});
it('should render execute workflow button if the node is a trigger node and is not read only', () => {
const { queryByTestId } = renderComponent({
props: createCanvasNodeProps({ readOnly: false, data: triggerNodeData }),
});
expect(queryByTestId('execute-workflow-button-foo')).toBeInTheDocument();
});
it('should not render execute workflow button if the node is a trigger node and is read only', () => {
const { queryByTestId } = renderComponent({
props: createCanvasNodeProps({ readOnly: true, data: triggerNodeData }),
});
expect(queryByTestId('execute-workflow-button-foo')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,457 @@
<script lang="ts" setup>
import {
computed,
onBeforeUnmount,
onMounted,
provide,
ref,
toRef,
useCssModule,
watch,
} from 'vue';
import type {
CanvasConnectionPort,
CanvasElementPortWithRenderData,
CanvasNodeData,
CanvasNodeEventBusEvents,
CanvasEventBusEvents,
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHandleRenderer.vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useContextMenu } from '@/composables/useContextMenu';
import type { NodeProps, XYPosition } from '@vue-flow/core';
import { Position } from '@vue-flow/core';
import { useCanvas } from '@/composables/useCanvas';
import {
createCanvasConnectionHandleString,
insertSpacersBetweenEndpoints,
} from '@/utils/canvasUtils';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { isEqual } from 'lodash-es';
import CanvasNodeTrigger from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeTrigger.vue';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
hovered?: boolean;
nearbyHovered?: boolean;
};
const slots = defineSlots<{
toolbar?: (props: {
inputs: (typeof mainInputs)['value'];
outputs: (typeof mainOutputs)['value'];
data: CanvasNodeData;
}) => void;
}>();
const emit = defineEmits<{
add: [id: string, handle: string];
delete: [id: string];
run: [id: string];
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
deactivate: [id: string];
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
update: [id: string, parameters: Record<string, unknown>];
'update:inputs': [id: string];
'update:outputs': [id: string];
move: [id: string, position: XYPosition];
}>();
const style = useCssModule();
const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore();
const contextMenu = useContextMenu();
const { connectingHandle } = useCanvas();
/*
Toolbar slot classes
*/
const nodeClasses = ref<string[]>([]);
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections);
const {
mainInputs,
nonMainInputs,
requiredNonMainInputs,
mainOutputs,
nonMainOutputs,
isValidConnection,
} = useNodeConnections({
inputs,
outputs,
connections,
});
const isDisabled = computed(() => props.data.disabled);
const nodeTypeDescription = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
const classes = computed(() => ({
[style.canvasNode]: true,
[style.showToolbar]: showToolbar.value,
hovered: props.hovered,
selected: props.selected,
...Object.fromEntries([...nodeClasses.value].map((c) => [c, true])),
}));
const renderType = computed<CanvasNodeRenderType>(() => props.data.render.type);
const dataTestId = computed(() =>
[CanvasNodeRenderType.StickyNote, CanvasNodeRenderType.AddNodes].includes(renderType.value)
? undefined
: 'canvas-node',
);
/**
* Event bus
*/
const canvasNodeEventBus = ref(createEventBus<CanvasNodeEventBusEvents>());
function emitCanvasNodeEvent(event: CanvasEventBusEvents['nodes:action']) {
if (event.ids.includes(props.id) && canvasNodeEventBus.value) {
canvasNodeEventBus.value.emit(event.action, event.payload);
}
}
/**
* Inputs
*/
const nonMainInputsWithSpacer = computed(() =>
insertSpacersBetweenEndpoints(nonMainInputs.value, requiredNonMainInputs.value.length),
);
const mappedInputs = computed(() => {
return [
...mainInputs.value.map(mainInputsMappingFn),
...nonMainInputsWithSpacer.value.map(nonMainInputsMappingFn),
].filter((endpoint) => !!endpoint);
});
/**
* Outputs
*/
const mappedOutputs = computed(() => {
return [
...mainOutputs.value.map(mainOutputsMappingFn),
...nonMainOutputs.value.map(nonMainOutputsMappingFn),
].filter((endpoint) => !!endpoint);
});
/**
* Node icon
*/
const nodeIconSize = computed(() =>
'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40,
);
/**
* Endpoints
*/
const createEndpointMappingFn =
({
mode,
position,
offsetAxis,
}: {
mode: CanvasConnectionMode;
position: Position;
offsetAxis: 'top' | 'left';
}) =>
(
endpoint: CanvasConnectionPort | null,
index: number,
endpoints: Array<CanvasConnectionPort | null>,
): CanvasElementPortWithRenderData | undefined => {
if (!endpoint) {
return;
}
const handleId = createCanvasConnectionHandleString({
mode,
type: endpoint.type,
index: endpoint.index,
});
const handleType = mode === CanvasConnectionMode.Input ? 'target' : 'source';
const connectionsCount = connections.value[mode][endpoint.type]?.[endpoint.index]?.length ?? 0;
const isConnecting =
connectingHandle.value?.nodeId === props.id &&
connectingHandle.value?.handleType === handleType &&
connectingHandle.value?.handleId === handleId;
return {
...endpoint,
handleId,
connectionsCount,
isConnecting,
position,
offset: {
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
},
};
};
const mainInputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Left,
offsetAxis: 'top',
});
const nonMainInputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Input,
position: Position.Bottom,
offsetAxis: 'left',
});
const mainOutputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Right,
offsetAxis: 'top',
});
const nonMainOutputsMappingFn = createEndpointMappingFn({
mode: CanvasConnectionMode.Output,
position: Position.Top,
offsetAxis: 'left',
});
/**
* Events
*/
function onAdd(handle: string) {
emit('add', props.id, handle);
}
function onDelete() {
emit('delete', props.id);
}
function onRun() {
emit('run', props.id);
}
function onDisabledToggle() {
emit('toggle', props.id);
}
function onActivate() {
emit('activate', props.id);
}
function onDeactivate() {
emit('deactivate', props.id);
}
function onOpenContextMenuFromToolbar(event: MouseEvent) {
emit('open:contextmenu', props.id, event, 'node-button');
}
function onOpenContextMenuFromNode(event: MouseEvent) {
emit('open:contextmenu', props.id, event, 'node-right-click');
}
function onUpdate(parameters: Record<string, unknown>) {
emit('update', props.id, parameters);
}
function onMove(position: XYPosition) {
emit('move', props.id, position);
}
function onUpdateClass({ className, add = true }: CanvasNodeEventBusEvents['update:node:class']) {
nodeClasses.value = add
? [...new Set([...nodeClasses.value, className])]
: nodeClasses.value.filter((c) => c !== className);
}
/**
* Provide
*/
const id = toRef(props, 'id');
const data = toRef(props, 'data');
const label = toRef(props, 'label');
const selected = toRef(props, 'selected');
const readOnly = toRef(props, 'readOnly');
provide(CanvasNodeKey, {
id,
data,
label,
selected,
readOnly,
eventBus: canvasNodeEventBus,
});
const showToolbar = computed(() => {
const target = contextMenu.target.value;
return contextMenu.isOpen && target?.source === 'node-button' && target.nodeId === id.value;
});
/**
* Lifecycle
*/
watch(
() => props.selected,
(value) => {
emit('select', props.id, value);
},
);
watch(inputs, (newValue, oldValue) => {
if (!isEqual(newValue, oldValue)) {
emit('update:inputs', props.id);
}
});
watch(outputs, (newValue, oldValue) => {
if (!isEqual(newValue, oldValue)) {
emit('update:outputs', props.id);
}
});
onMounted(() => {
props.eventBus?.on('nodes:action', emitCanvasNodeEvent);
canvasNodeEventBus.value?.on('update:node:class', onUpdateClass);
});
onBeforeUnmount(() => {
props.eventBus?.off('nodes:action', emitCanvasNodeEvent);
canvasNodeEventBus.value?.off('update:node:class', onUpdateClass);
});
</script>
<template>
<div
:class="classes"
:data-test-id="dataTestId"
:data-node-name="data.name"
:data-node-type="data.type"
>
<template
v-for="source in mappedOutputs"
:key="`${source.handleId}(${source.index + 1}/${mappedOutputs.length})`"
>
<CanvasHandleRenderer
v-bind="source"
:mode="CanvasConnectionMode.Output"
:is-read-only="readOnly"
:is-valid-connection="isValidConnection"
:data-node-name="data.name"
data-test-id="canvas-node-output-handle"
:data-index="source.index"
:data-connection-type="source.type"
@add="onAdd"
/>
</template>
<template
v-for="target in mappedInputs"
:key="`${target.handleId}(${target.index + 1}/${mappedInputs.length})`"
>
<CanvasHandleRenderer
v-bind="target"
:mode="CanvasConnectionMode.Input"
:is-read-only="readOnly"
:is-valid-connection="isValidConnection"
data-test-id="canvas-node-input-handle"
:data-index="target.index"
:data-connection-type="target.type"
:data-node-name="data.name"
@add="onAdd"
/>
</template>
<template v-if="slots.toolbar">
<slot name="toolbar" :inputs="mainInputs" :outputs="mainOutputs" :data="data" />
</template>
<CanvasNodeToolbar
v-else-if="nodeTypeDescription"
data-test-id="canvas-node-toolbar"
:read-only="readOnly"
:class="$style.canvasNodeToolbar"
@delete="onDelete"
@toggle="onDisabledToggle"
@run="onRun"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromToolbar"
/>
<CanvasNodeRenderer
@activate="onActivate"
@deactivate="onDeactivate"
@move="onMove"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode"
>
<NodeIcon
:node-type="nodeTypeDescription"
:size="nodeIconSize"
:shrink="false"
:disabled="isDisabled"
/>
<!-- @TODO :color-default="iconColorDefault"-->
</CanvasNodeRenderer>
<CanvasNodeTrigger
v-if="
props.data.render.type === CanvasNodeRenderType.Default && props.data.render.options.trigger
"
:name="data.name"
:type="data.type"
:hovered="nearbyHovered"
:disabled="isDisabled"
:read-only="readOnly"
:class="$style.trigger"
/>
</div>
</template>
<style lang="scss" module>
.canvasNode {
&:hover:not(:has(> .trigger:hover)), // exclude .trigger which has extended hit zone
&:focus-within,
&.showToolbar {
.canvasNodeToolbar {
opacity: 1;
}
}
}
.canvasNodeToolbar {
transition: opacity 0.1s ease-in;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -100%);
opacity: 0;
z-index: 1;
&:focus-within,
&:hover {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,68 @@
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeRenderer', () => {
it('should render default node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('canvas-default-node')).toBeInTheDocument();
});
it('should render configuration node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configuration-node')).toBeInTheDocument();
});
it('should render configurable node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configurable: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configurable-node')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
import { CanvasNodeKey } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
const node = inject(CanvasNodeKey);
const slots = defineSlots<{
default?: () => unknown;
}>();
const Render = () => {
const renderType = node?.data.value.render.type ?? CanvasNodeRenderType.Default;
let Component;
switch (renderType) {
case CanvasNodeRenderType.StickyNote:
Component = CanvasNodeStickyNote;
break;
case CanvasNodeRenderType.AddNodes:
Component = CanvasNodeAddNodes;
break;
default:
Component = CanvasNodeDefault;
}
return h(
Component,
{
'data-canvas-node-render-type': renderType,
},
slots.default,
);
};
</script>
<template>
<Render />
</template>

View File

@@ -0,0 +1,181 @@
import { fireEvent, waitFor } from '@testing-library/vue';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
describe('CanvasNodeToolbar', () => {
it('should render execute node button when renderType is not configuration', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
expect(getByTestId('execute-node-button')).toBeInTheDocument();
});
it('should render disabled execute node button when canvas is executing', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide({
isExecuting: true,
}),
},
},
});
expect(getByTestId('execute-node-button')).toBeDisabled();
});
it('should not render execute node button when renderType is configuration', async () => {
const { queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configuration: true },
},
},
}),
...createCanvasProvide(),
},
},
});
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
});
it('should emit "run" when execute node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
await fireEvent.click(getByTestId('execute-node-button'));
expect(emitted('run')[0]).toEqual([]);
});
it('should emit "toggle" when disable node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
await fireEvent.click(getByTestId('disable-node-button'));
expect(emitted('toggle')[0]).toEqual([]);
});
it('should emit "delete" when delete node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
await fireEvent.click(getByTestId('delete-node-button'));
expect(emitted('delete')[0]).toEqual([]);
});
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
await fireEvent.click(getByTestId('overflow-node-button'));
expect(emitted('open:contextmenu')[0]).toEqual([expect.any(MouseEvent)]);
});
it('should emit "update" when sticky note color is changed', async () => {
const { getAllByTestId, getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.StickyNote,
options: { color: 3 },
},
},
}),
...createCanvasProvide(),
},
},
});
await fireEvent.click(getByTestId('change-sticky-color'));
await fireEvent.click(getAllByTestId('color')[0]);
expect(emitted('update')[0]).toEqual([{ color: 1 }]);
});
it('should have "forceVisible" class when hovered', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
...createCanvasProvide(),
},
},
});
const toolbar = getByTestId('canvas-node-toolbar');
await fireEvent.mouseEnter(toolbar);
expect(toolbar).toHaveClass('forceVisible');
});
it('should have "forceVisible" class when sticky color picker is visible', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.StickyNote,
options: { color: 3 },
},
},
}),
...createCanvasProvide(),
},
},
});
const toolbar = getByTestId('canvas-node-toolbar');
await fireEvent.click(getByTestId('change-sticky-color'));
await waitFor(() => expect(toolbar).toHaveClass('forceVisible'));
});
});

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { CanvasNodeRenderType } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
const emit = defineEmits<{
delete: [];
toggle: [];
run: [];
update: [parameters: Record<string, unknown>];
'open:contextmenu': [event: MouseEvent];
}>();
const props = defineProps<{
readOnly?: boolean;
}>();
const $style = useCssModule();
const i18n = useI18n();
const { isExecuting } = useCanvas();
const { isDisabled, render } = useCanvasNode();
const nodeDisabledTitle = computed(() => {
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
});
const isStickyColorSelectorOpen = ref(false);
const isHovered = ref(false);
const classes = computed(() => ({
[$style.canvasNodeToolbar]: true,
[$style.readOnly]: props.readOnly,
[$style.forceVisible]: isHovered.value || isStickyColorSelectorOpen.value,
}));
const isExecuteNodeVisible = computed(() => {
return (
!props.readOnly &&
render.value.type === CanvasNodeRenderType.Default &&
'configuration' in render.value.options &&
!render.value.options.configuration
);
});
const isDisableNodeVisible = computed(() => {
return !props.readOnly && render.value.type === CanvasNodeRenderType.Default;
});
const isDeleteNodeVisible = computed(() => !props.readOnly);
const isStickyNoteChangeColorVisible = computed(
() => !props.readOnly && render.value.type === CanvasNodeRenderType.StickyNote,
);
function executeNode() {
emit('run');
}
function onToggleNode() {
emit('toggle');
}
function onDeleteNode() {
emit('delete');
}
function onChangeStickyColor(color: number) {
emit('update', {
color,
});
}
function onOpenContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
function onMouseEnter() {
isHovered.value = true;
}
function onMouseLeave() {
isHovered.value = false;
}
</script>
<template>
<div
data-test-id="canvas-node-toolbar"
:class="classes"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div :class="$style.canvasNodeToolbarItems">
<N8nIconButton
v-if="isExecuteNodeVisible"
data-test-id="execute-node-button"
type="tertiary"
text
size="small"
icon="play"
:disabled="isExecuting"
:title="i18n.baseText('node.testStep')"
@click="executeNode"
/>
<N8nIconButton
v-if="isDisableNodeVisible"
data-test-id="disable-node-button"
type="tertiary"
text
size="small"
icon="power-off"
:title="nodeDisabledTitle"
@click="onToggleNode"
/>
<N8nIconButton
v-if="isDeleteNodeVisible"
data-test-id="delete-node-button"
type="tertiary"
size="small"
text
icon="trash"
:title="i18n.baseText('node.delete')"
@click="onDeleteNode"
/>
<CanvasNodeStickyColorSelector
v-if="isStickyNoteChangeColorVisible"
v-model:visible="isStickyColorSelectorOpen"
@update="onChangeStickyColor"
/>
<N8nIconButton
data-test-id="overflow-node-button"
type="tertiary"
size="small"
text
icon="ellipsis-h"
@click="onOpenContextMenu"
/>
</div>
</div>
</template>
<style lang="scss" module>
.canvasNodeToolbar {
padding-bottom: var(--spacing-xs);
display: flex;
justify-content: flex-end;
width: 100%;
}
.canvasNodeToolbarItems {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-canvas-background);
border-radius: var(--border-radius-base);
:global(.button) {
--button-font-color: var(--color-text-light);
}
}
.forceVisible {
opacity: 1 !important;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { NODE_CREATOR_OPEN_SOURCES } from '@/constants';
import { nodeViewEventBus } from '@/event-bus';
import { useI18n } from '@/composables/useI18n';
const nodeCreatorStore = useNodeCreatorStore();
const i18n = useI18n();
const isTooltipVisible = ref(false);
onMounted(() => {
nodeViewEventBus.on('runWorkflowButton:mouseenter', onShowTooltip);
nodeViewEventBus.on('runWorkflowButton:mouseleave', onHideTooltip);
});
onBeforeUnmount(() => {
nodeViewEventBus.off('runWorkflowButton:mouseenter', onShowTooltip);
nodeViewEventBus.off('runWorkflowButton:mouseleave', onHideTooltip);
});
function onShowTooltip() {
isTooltipVisible.value = true;
}
function onHideTooltip() {
isTooltipVisible.value = false;
}
function onClick() {
nodeCreatorStore.openNodeCreatorForTriggerNodes(
NODE_CREATOR_OPEN_SOURCES.TRIGGER_PLACEHOLDER_BUTTON,
);
}
</script>
<template>
<div ref="container" :class="$style.addNodes" data-test-id="canvas-add-button">
<N8nTooltip
placement="top"
:visible="isTooltipVisible"
:disabled="nodeCreatorStore.showScrim"
:popper-class="$style.tooltip"
:show-after="700"
>
<button :class="$style.button" data-test-id="canvas-plus-button" @click.stop="onClick">
<FontAwesomeIcon icon="plus" size="lg" />
</button>
<template #content>
{{ i18n.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting') }}
</template>
</N8nTooltip>
<p :class="$style.label" v-text="i18n.baseText('nodeView.canvasAddButton.addFirstStep')" />
</div>
</template>
<style lang="scss" module>
.addNodes {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
&:hover .button svg path {
fill: var(--color-primary);
}
}
.button {
background: var(--color-foreground-xlight);
border: 2px dashed var(--color-foreground-xdark);
border-radius: 8px;
padding: 0;
min-width: 100px;
min-height: 100px;
cursor: pointer;
svg {
width: 26px !important;
height: 40px;
path {
fill: var(--color-foreground-xdark);
}
}
}
.label {
width: max-content;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);
color: var(--color-text-dark);
margin-top: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,351 @@
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { fireEvent } from '@testing-library/vue';
const renderComponent = createComponentRenderer(CanvasNodeDefault, {
global: {
provide: {
...createCanvasProvide(),
},
},
});
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeDefault', () => {
it('should render node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('canvas-default-node')).toMatchSnapshot();
});
describe('inputs', () => {
it('should adjust height css variable based on the number of inputs (1 input)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [{ type: NodeConnectionType.Main, index: 0 }],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-input-count': '1' }); // height calculation based on the number of inputs
});
it('should adjust height css variable based on the number of inputs (multiple inputs)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-input-count': '3' }); // height calculation based on the number of inputs
});
});
describe('outputs', () => {
it('should adjust height css variable based on the number of outputs (1 output)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
outputs: [{ type: NodeConnectionType.Main, index: 0 }],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '1' }); // height calculation based on the number of outputs
});
it('should adjust height css variable based on the number of outputs (multiple outputs)', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
outputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '3' }); // height calculation based on the number of outputs
});
});
describe('selected', () => {
it('should apply selected class when node is selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({ selected: true }),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
});
it('should not apply selected class when node is not selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
it('should render strike-through when node is disabled and has node input and output handles', () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
inputs: [{ type: NodeConnectionType.Main, index: 0 }],
outputs: [{ type: NodeConnectionType.Main, index: 0 }],
connections: {
[CanvasConnectionMode.Input]: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
[CanvasConnectionMode.Output]: {
[NodeConnectionType.Main]: [
[{ node: 'node', type: NodeConnectionType.Main, index: 0 }],
],
},
},
},
}),
},
},
});
expect(container.querySelector('.disabledStrikeThrough')).toBeVisible();
});
});
describe('waiting', () => {
it('should apply waiting class when node is waiting', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({ data: { execution: { running: true, waiting: '123' } } }),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('waiting');
});
});
describe('running', () => {
it('should apply running class when node is running', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({ data: { execution: { running: true } } }),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('running');
});
});
describe('configurable', () => {
it('should render configurable node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configurable: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configurable-node')).toMatchSnapshot();
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiTool, index: 0 },
{ type: NodeConnectionType.AiDocument, index: 0, required: true },
{ type: NodeConnectionType.AiMemory, index: 0, required: true },
],
render: {
type: CanvasNodeRenderType.Default,
options: {
configurable: true,
},
},
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--configurable-node--input-count': '3' });
});
});
});
describe('configuration', () => {
it('should render configuration node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configuration-node')).toMatchSnapshot();
});
it('should render configurable configuration node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { configurable: true, configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configurable-node')).toMatchSnapshot();
});
});
describe('trigger', () => {
it('should render trigger node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: CanvasNodeRenderType.Default,
options: { trigger: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-trigger-node')).toMatchSnapshot();
});
});
it('should emit "activate" on double click', async () => {
const { getByText, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
await fireEvent.dblClick(getByText('Test Node'));
expect(emitted()).toHaveProperty('activate');
});
});

View File

@@ -0,0 +1,337 @@
<script lang="ts" setup>
import { computed, ref, useCssModule, watch } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import type { CanvasNodeDefaultRender } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
const $style = useCssModule();
const i18n = useI18n();
const emit = defineEmits<{
'open:contextmenu': [event: MouseEvent];
activate: [id: string];
}>();
const { initialized, viewport } = useCanvas();
const {
id,
label,
subtitle,
inputs,
outputs,
connections,
isDisabled,
isSelected,
hasPinnedData,
executionStatus,
executionWaiting,
executionRunning,
hasRunData,
hasIssues,
render,
} = useCanvasNode();
const {
mainOutputs,
mainOutputConnections,
mainInputs,
mainInputConnections,
nonMainInputs,
requiredNonMainInputs,
} = useNodeConnections({
inputs,
outputs,
connections,
});
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.success]: hasRunData.value,
[$style.error]: hasIssues.value,
[$style.pinned]: hasPinnedData.value,
[$style.waiting]: executionWaiting.value ?? executionStatus.value === 'waiting',
[$style.running]: executionRunning.value,
[$style.configurable]: renderOptions.value.configurable,
[$style.configuration]: renderOptions.value.configuration,
[$style.trigger]: renderOptions.value.trigger,
[$style.warning]: renderOptions.value.dirtiness !== undefined,
};
});
const styles = computed(() => {
const stylesObject: Record<string, string | number> = {};
if (renderOptions.value.configurable) {
let spacerCount = 0;
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS && requiredNonMainInputs.value.length > 0) {
const requiredNonMainInputsCount = requiredNonMainInputs.value.length;
const optionalNonMainInputsCount = nonMainInputs.value.length - requiredNonMainInputsCount;
spacerCount = requiredNonMainInputsCount > 0 && optionalNonMainInputsCount > 0 ? 1 : 0;
}
stylesObject['--configurable-node--input-count'] = nonMainInputs.value.length + spacerCount;
}
stylesObject['--canvas-node--main-input-count'] = mainInputs.value.length;
stylesObject['--canvas-node--main-output-count'] = mainOutputs.value.length;
return stylesObject;
});
const dataTestId = computed(() => {
let type = 'default';
if (renderOptions.value.configurable) {
type = 'configurable';
} else if (renderOptions.value.configuration) {
type = 'configuration';
} else if (renderOptions.value.trigger) {
type = 'trigger';
}
return `canvas-${type}-node`;
});
const isStrikethroughVisible = computed(() => {
const isSingleMainInputNode =
mainInputs.value.length === 1 && mainInputConnections.value.length <= 1;
const isSingleMainOutputNode =
mainOutputs.value.length === 1 && mainOutputConnections.value.length <= 1;
return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode;
});
const showTooltip = ref(false);
watch(initialized, () => {
if (initialized.value) {
showTooltip.value = true;
}
});
watch(viewport, () => {
showTooltip.value = false;
setTimeout(() => {
showTooltip.value = true;
}, 0);
});
function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
function onActivate() {
emit('activate', id.value);
}
</script>
<template>
<div
:class="classes"
:style="styles"
:data-test-id="dataTestId"
@contextmenu="openContextMenu"
@dblclick.stop="onActivate"
>
<CanvasNodeTooltip v-if="renderOptions.tooltip" :visible="showTooltip" />
<slot />
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
<div :class="$style.description">
<div v-if="label" :class="$style.label">
{{ label }}
</div>
<div v-if="isDisabled" :class="$style.disabledLabel">
({{ i18n.baseText('node.disabled') }})
</div>
<div v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
--canvas-node--max-vertical-handles: max(
var(--canvas-node--main-input-count),
var(--canvas-node--main-output-count),
1
);
--canvas-node--height: calc(100px + max(0, var(--canvas-node--max-vertical-handles) - 3) * 42px);
--canvas-node--width: 100px;
--canvas-node-border-width: 2px;
--configurable-node--min-input-count: 4;
--configurable-node--input-width: 64px;
--configurable-node--icon-offset: 30px;
--configurable-node--icon-size: 30px;
--trigger-node--border-radius: 36px;
--canvas-node--status-icons-offset: var(--spacing-3xs);
position: relative;
height: var(--canvas-node--height);
width: var(--canvas-node--width);
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--color-node-background));
border: var(--canvas-node-border-width) solid
var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
&.trigger {
border-radius: var(--trigger-node--border-radius) var(--border-radius-large)
var(--border-radius-large) var(--trigger-node--border-radius);
}
/**
* Node types
*/
&.configuration {
--canvas-node--width: 80px;
--canvas-node--height: 80px;
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: var(--canvas-node-border-width) solid
var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50px;
.statusIcons {
right: unset;
}
}
&.configurable {
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node--input-count, 4), var(--configurable-node--min-input-count)) *
var(--configurable-node--input-width)
);
justify-content: flex-start;
:global(.n8n-node-icon) {
margin-left: var(--configurable-node--icon-offset);
}
.description {
top: unset;
position: relative;
margin-top: 0;
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
width: auto;
min-width: unset;
max-width: calc(
var(--canvas-node--width) - var(--configurable-node--icon-offset) - var(
--configurable-node--icon-size
) - 2 * var(--spacing-s)
);
}
.label {
text-align: left;
}
.subtitle {
text-align: left;
}
&.configuration {
--canvas-node--height: 75px;
.statusIcons {
right: calc(-1 * var(--spacing-2xs));
bottom: 0;
}
}
}
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.selected {
box-shadow: 0 0 0 8px var(--color-canvas-selected-transparent);
}
&.success {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
}
&.warning {
border-color: var(--color-warning);
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.pinned {
border-color: var(--color-canvas-node-pinned-border-color, var(--color-node-pinned-border));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border-color, var(--color-foreground-base));
}
&.running {
background-color: var(--color-node-executing-background);
border-color: var(--color-canvas-node-running-border-color, var(--color-node-running-border));
}
&.waiting {
border-color: var(--color-canvas-node-waiting-border-color, var(--color-secondary));
}
}
.description {
top: 100%;
position: absolute;
width: 100%;
min-width: calc(var(--canvas-node--width) * 2);
margin-top: var(--spacing-2xs);
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
align-items: center;
}
.label,
.disabledLabel {
font-size: var(--font-size-m);
text-align: center;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
}
.subtitle {
width: 100%;
text-align: center;
color: var(--color-text-light);
font-size: var(--font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: var(--font-line-height-compact);
font-weight: 400;
}
.statusIcons {
position: absolute;
bottom: var(--canvas-node--status-icons-offset);
right: var(--canvas-node--status-icons-offset);
}
</style>

View File

@@ -0,0 +1,115 @@
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { fireEvent } from '@testing-library/vue';
const renderComponent = createComponentRenderer(CanvasNodeStickyNote);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeStickyNote', () => {
it('should render node correctly', () => {
const { html } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
}),
},
},
});
expect(html()).toMatchSnapshot();
});
it('should disable resizing when node is readonly', () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
readOnly: true,
}),
},
},
});
const resizeControls = container.querySelectorAll('.vue-flow__resize-control');
expect(resizeControls).toHaveLength(0);
});
it('should disable sticky options when in edit mode', async () => {
const { container } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
readOnly: false,
}),
},
},
});
const stickyTextarea = container.querySelector('.sticky-textarea');
if (!stickyTextarea) return;
await fireEvent.dblClick(stickyTextarea);
const stickyOptions = container.querySelector('.sticky-options');
if (!stickyOptions) return;
expect(getComputedStyle(stickyOptions).display).toBe('none');
});
it('should emit "activate" on double click', async () => {
const { container, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
}),
},
},
});
const sticky = container.querySelector('.sticky-textarea');
if (!sticky) throw new Error('Sticky not found');
await fireEvent.dblClick(sticky);
expect(emitted()).toHaveProperty('activate');
});
it('should emit "deactivate" on blur', async () => {
const { container, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
}),
},
},
});
const sticky = container.querySelector('.sticky-textarea');
if (!sticky) throw new Error('Sticky not found');
await fireEvent.dblClick(sticky);
const stickyTextarea = container.querySelector('.sticky-textarea textarea');
if (!stickyTextarea) throw new Error('Textarea not found');
await fireEvent.blur(stickyTextarea);
expect(emitted()).toHaveProperty('deactivate');
});
});

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/* eslint-disable vue/no-multiple-template-root */
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeStickyNoteRender } from '@/types';
import { ref, computed, useCssModule, onMounted, onBeforeUnmount } from 'vue';
import { NodeResizer } from '@vue-flow/node-resizer';
import type { OnResize } from '@vue-flow/node-resizer';
import type { XYPosition } from '@vue-flow/core';
defineOptions({
inheritAttrs: false,
});
const emit = defineEmits<{
update: [parameters: Record<string, unknown>];
move: [position: XYPosition];
activate: [id: string];
deactivate: [id: string];
'open:contextmenu': [event: MouseEvent];
}>();
const $style = useCssModule();
const { id, isSelected, isReadOnly, render, eventBus } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
const classes = computed(() => ({
[$style.sticky]: true,
[$style.selected]: isSelected.value,
['sticky--active']: isActive.value, // Used to increase the z-index of the sticky note when editing
}));
/**
* Resizing
*/
function onResize(event: OnResize) {
emit('move', {
x: event.params.x,
y: event.params.y,
});
emit('update', {
...(event.params.width ? { width: event.params.width } : {}),
...(event.params.height ? { height: event.params.height } : {}),
});
}
/**
* Content change
*/
const isActive = ref(false);
function onInputChange(value: string) {
emit('update', {
content: value,
});
}
function onSetActive(value: boolean) {
if (isActive.value === value) return;
isActive.value = value;
if (value) {
emit('activate', id.value);
} else {
emit('deactivate', id.value);
}
}
function onActivate() {
onSetActive(true);
}
/**
* Context menu
*/
function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
/**
* Lifecycle
*/
onMounted(() => {
eventBus.value?.on('update:node:activated', onActivate);
});
onBeforeUnmount(() => {
eventBus.value?.off('update:node:activated', onActivate);
});
</script>
<template>
<NodeResizer
:min-height="80"
:min-width="150"
:height="renderOptions.height"
:width="renderOptions.width"
:is-visible="!isReadOnly"
@resize="onResize"
/>
<N8nSticky
v-bind="$attrs"
:id="id"
:class="classes"
data-test-id="sticky"
:height="renderOptions.height"
:width="renderOptions.width"
:model-value="renderOptions.content"
:background-color="renderOptions.color"
:edit-mode="isActive"
:read-only="isReadOnly"
@edit="onSetActive"
@dblclick.stop="onActivate"
@update:model-value="onInputChange"
@contextmenu="openContextMenu"
/>
</template>
<style lang="scss" module>
.sticky {
position: relative;
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
}
</style>

View File

@@ -0,0 +1,146 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasNodeDefault > configurable > should render configurable node correctly 1`] = `
<div
class="node configurable"
data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="description"
>
<div
class="label"
>
Test Node
</div>
<!--v-if-->
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
exports[`CanvasNodeDefault > configuration > should render configurable configuration node correctly 1`] = `
<div
class="node configurable configuration"
data-test-id="canvas-configurable-node"
style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="description"
>
<div
class="label"
>
Test Node
</div>
<!--v-if-->
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
exports[`CanvasNodeDefault > configuration > should render configuration node correctly 1`] = `
<div
class="node configuration"
data-test-id="canvas-configuration-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="description"
>
<div
class="label"
>
Test Node
</div>
<!--v-if-->
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
exports[`CanvasNodeDefault > should render node correctly 1`] = `
<div
class="node"
data-test-id="canvas-default-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="description"
>
<div
class="label"
>
Test Node
</div>
<!--v-if-->
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;
exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`] = `
<div
class="node trigger"
data-test-id="canvas-trigger-node"
style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="description"
>
<div
class="label"
>
Test Node
</div>
<!--v-if-->
<div
class="subtitle"
>
Test Node Subtitle
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasNodeStickyNote > should render node correctly 1`] = `
"<div class="vue-flow__resize-control nodrag top line"></div>
<div class="vue-flow__resize-control nodrag right line"></div>
<div class="vue-flow__resize-control nodrag bottom line"></div>
<div class="vue-flow__resize-control nodrag left line"></div>
<div class="vue-flow__resize-control nodrag top left handle"></div>
<div class="vue-flow__resize-control nodrag top right handle"></div>
<div class="vue-flow__resize-control nodrag bottom left handle"></div>
<div class="vue-flow__resize-control nodrag bottom right handle"></div>
<div class="n8n-sticky sticky clickable color-1 sticky" style="height: 180px; width: 240px;" data-test-id="sticky">
<div class="wrapper">
<div class="n8n-markdown">
<div class="sticky"></div>
</div>
</div>
<div class="sticky-textarea" style="display: none;">
<div class="el-textarea el-input--large n8n-input">
<!-- input -->
<!-- textarea --><textarea class="el-textarea__inner" name="sticky-input" rows="5" title="" tabindex="0" autocomplete="off" placeholder=""></textarea>
<!--v-if-->
</div>
</div>
<!--v-if-->
</div>"
`;

View File

@@ -0,0 +1,12 @@
import CanvasNodeDisabledStrikeThrough from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
describe('CanvasNodeDisabledStrikeThrough', () => {
it('should render node correctly', () => {
const { container } = renderComponent();
expect(container.firstChild).toHaveClass('disabledStrikeThrough');
});
});

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { CanvasNodeRenderType } from '@/types';
const $style = useCssModule();
const { hasRunData, render } = useCanvasNode();
const classes = computed(() => {
return {
[$style.disabledStrikeThrough]: true,
[$style.success]: hasRunData.value,
[$style.warning]:
render.value.type === CanvasNodeRenderType.Default &&
render.value.options.dirtiness !== undefined,
};
});
</script>
<template>
<div :class="classes"></div>
</template>
<style lang="scss" module>
.disabledStrikeThrough {
border: 1px solid var(--color-foreground-dark);
position: absolute;
top: calc(var(--canvas-node--height) / 2 - 1px);
left: -4px;
width: calc(100% + 12px);
pointer-events: none;
}
.success {
border-color: var(--color-success-light);
}
.warning {
border-color: var(--color-warning-tint-1);
}
</style>

View File

@@ -0,0 +1,73 @@
import CanvasNodeStatusIcons from './CanvasNodeStatusIcons.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '@/types';
const renderComponent = createComponentRenderer(CanvasNodeStatusIcons, {
pinia: createTestingPinia(),
});
describe('CanvasNodeStatusIcons', () => {
it('should render correctly for a pinned node', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({ data: { pinnedData: { count: 5, visible: true } } }),
},
});
expect(getByTestId('canvas-node-status-pinned')).toBeInTheDocument();
});
it('should not render pinned icon when disabled', () => {
const { queryByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({
data: { disabled: true, pinnedData: { count: 5, visible: true } },
}),
},
});
expect(queryByTestId('canvas-node-status-pinned')).not.toBeInTheDocument();
});
it('should render correctly for a running node', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({ data: { execution: { running: true } } }),
},
});
expect(getByTestId('canvas-node-status-running')).toBeInTheDocument();
});
it('should render correctly for a node that ran successfully', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({
data: { runData: { outputMap: {}, iterations: 15, visible: true } },
}),
},
});
expect(getByTestId('canvas-node-status-success')).toHaveTextContent('15');
});
it('should render correctly for a dirty node that has run successfully', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({
data: {
runData: { outputMap: {}, iterations: 15, visible: true },
render: {
type: CanvasNodeRenderType.Default,
options: { dirtiness: CanvasNodeDirtiness.PARAMETERS_UPDATED },
},
},
}),
},
});
expect(getByTestId('canvas-node-status-warning')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed } from 'vue';
import TitledList from '@/components/TitledList.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { useI18n } from '@/composables/useI18n';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '@/types';
import { N8nTooltip } from '@n8n/design-system';
const nodeHelpers = useNodeHelpers();
const i18n = useI18n();
const {
hasPinnedData,
issues,
hasIssues,
executionStatus,
executionWaiting,
executionRunningThrottled,
hasRunData,
runDataIterations,
isDisabled,
render,
} = useCanvasNode();
const hideNodeIssues = computed(() => false); // @TODO Implement this
const dirtiness = computed(() =>
render.value.type === CanvasNodeRenderType.Default ? render.value.options.dirtiness : undefined,
);
</script>
<template>
<div
v-if="hasIssues && !hideNodeIssues"
:class="[$style.status, $style.issues]"
data-test-id="node-issues"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="issues" />
</template>
<FontAwesomeIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
<div v-else-if="executionWaiting || executionStatus === 'waiting'">
<div :class="[$style.status, $style.waiting]">
<N8nTooltip placement="bottom">
<template #content>
<div v-text="executionWaiting"></div>
</template>
<FontAwesomeIcon icon="clock" />
</N8nTooltip>
</div>
<div :class="[$style.status, $style['node-waiting-spinner']]">
<FontAwesomeIcon icon="sync-alt" spin />
</div>
</div>
<div
v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value && !isDisabled"
data-test-id="canvas-node-status-pinned"
:class="[$style.status, $style.pinnedData]"
>
<FontAwesomeIcon icon="thumbtack" />
</div>
<div v-else-if="executionStatus === 'unknown'">
<!-- Do nothing, unknown means the node never executed -->
</div>
<div
v-else-if="executionRunningThrottled || executionStatus === 'running'"
data-test-id="canvas-node-status-running"
:class="[$style.status, $style.running]"
>
<FontAwesomeIcon icon="sync-alt" spin />
</div>
<div v-else-if="dirtiness !== undefined">
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
{{
i18n.baseText(
dirtiness === CanvasNodeDirtiness.PARAMETERS_UPDATED
? 'node.dirty'
: 'node.subjectToChange',
)
}}
</template>
<div data-test-id="canvas-node-status-warning" :class="[$style.status, $style.warning]">
<FontAwesomeIcon icon="triangle" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div>
</N8nTooltip>
</div>
<div
v-else-if="hasRunData"
data-test-id="canvas-node-status-success"
:class="[$style.status, $style.runData]"
>
<FontAwesomeIcon icon="check" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
</div>
</template>
<style lang="scss" module>
.status {
display: flex;
align-items: center;
gap: var(--spacing-5xs);
font-weight: 600;
}
.runData {
color: var(--color-success);
}
.waiting {
color: var(--color-secondary);
}
.pinnedData {
color: var(--color-secondary);
}
.running {
width: calc(100% - 2 * var(--canvas-node--status-icons-offset));
height: calc(100% - 2 * var(--canvas-node--status-icons-offset));
display: flex;
align-items: center;
justify-content: center;
font-size: 3.75em;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
}
.node-waiting-spinner {
display: flex;
align-items: center;
justify-content: center;
font-size: 3.75em;
color: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.7);
width: 100%;
height: 100%;
position: absolute;
left: -34px;
top: -34px;
}
.issues {
color: var(--color-danger);
cursor: default;
}
.count {
font-size: var(--font-size-s);
}
.warning {
color: var(--color-warning);
}
</style>

View File

@@ -0,0 +1,54 @@
import CanvasNodeTooltip from './CanvasNodeTooltip.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { CanvasNodeDefaultRender } from '@/types';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { waitFor } from '@testing-library/vue';
const renderComponent = createComponentRenderer(CanvasNodeTooltip);
describe('CanvasNodeTooltip', () => {
describe('rendering', () => {
it('should render tooltip when tooltip option is provided', async () => {
const { container, getByText } = renderComponent({
props: {
visible: true,
},
global: {
provide: createCanvasNodeProvide({
data: {
render: {
options: {
tooltip: 'Test tooltip text',
},
} as CanvasNodeDefaultRender,
},
}),
},
});
expect(getByText('Test tooltip text')).toBeInTheDocument();
await waitFor(() => expect(container.querySelector('.el-popper')).toBeVisible());
});
it('should not render tooltip when tooltip option is not provided', () => {
const { container } = renderComponent({
props: {
visible: false,
},
global: {
provide: createCanvasNodeProvide({
data: {
render: {
options: {
tooltip: 'Test tooltip text',
},
} as CanvasNodeDefaultRender,
},
}),
},
});
expect(container.querySelector('.el-popper')).not.toBeVisible();
});
});
});

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { useCanvasNode } from '@/composables/useCanvasNode';
import { computed } from 'vue';
import type { CanvasNodeDefaultRender } from '@/types';
defineProps<{
visible: boolean;
}>();
const { render } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const popperOptions = {
modifiers: [
{ name: 'flip', enabled: false }, // show tooltip always above the node
],
};
</script>
<template>
<N8nTooltip
placement="top"
:show-after="500"
:visible="true"
:teleported="false"
:popper-class="$style.popper"
:popper-options="popperOptions"
>
<template #content>
{{ renderOptions.tooltip }}
</template>
<div :class="$style.tooltipTrigger" />
</N8nTooltip>
</template>
<style lang="scss" module>
.tooltipTrigger {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.popper {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useI18n } from '@/composables/useI18n';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, useCssModule } from 'vue';
import { useRouter } from 'vue-router';
const {
name,
type,
hovered,
disabled,
readOnly,
class: cls,
} = defineProps<{
name: string;
type: string;
hovered?: boolean;
disabled?: boolean;
readOnly?: boolean;
class?: string;
}>();
const style = useCssModule();
const containerClass = computed(() => ({
[cls ?? '']: true,
[style.container]: true,
[style.interactive]: !disabled && !readOnly,
[style.hovered]: !!hovered,
}));
const router = useRouter();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const { runEntireWorkflow } = useRunWorkflow({ router });
const { toggleChatOpen } = useCanvasOperations({ router });
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
const testId = computed(() => `execute-workflow-button-${name}`);
</script>
<template>
<!-- click and mousedown event are suppressed to avoid unwanted selection or dragging of the node -->
<div :class="containerClass" @click.stop.prevent @mousedown.stop.prevent>
<div>
<div :class="$style.bolt">
<FontAwesomeIcon icon="bolt" size="lg" />
</div>
<template v-if="!readOnly">
<N8nButton
v-if="type === CHAT_TRIGGER_NODE_TYPE"
:type="isChatOpen ? 'secondary' : 'primary'"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
@click.capture="toggleChatOpen('node')"
/>
<N8nButton
v-else
type="primary"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
@click.capture="runEntireWorkflow('node', name)"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.container {
z-index: -1;
position: absolute;
display: flex;
align-items: center;
height: 100%;
right: 100%;
top: 0;
pointer-events: none;
& > div {
position: relative;
display: flex;
align-items: center;
}
& button {
margin-right: var(--spacing-s);
opacity: 0;
translate: -12px 0;
transition:
translate 0.1s ease-in,
opacity 0.1s ease-in;
}
&.interactive.hovered button {
opacity: 1;
translate: 0 0;
pointer-events: all;
}
}
.bolt {
position: absolute;
right: 0;
color: var(--color-primary);
padding: var(--spacing-s);
opacity: 1;
translate: 0 0;
transition:
translate 0.1s ease-in,
opacity 0.1s ease-in;
.container.interactive.hovered & {
translate: -12px 0;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,43 @@
import { fireEvent } from '@testing-library/vue';
import CanvasNodeStickyColorSelector from '@/components/canvas/elements/nodes/toolbar/CanvasNodeStickyColorSelector.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeStickyColorSelector);
describe('CanvasNodeStickyColorSelector', () => {
it('should render trigger correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
const colorSelector = getByTestId('change-sticky-color');
expect(colorSelector).toBeVisible();
});
it('should render all colors and apply selected color correctly', async () => {
const { getByTestId, getAllByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
const colorSelector = getByTestId('change-sticky-color');
await fireEvent.click(colorSelector);
const colorOption = getAllByTestId('color');
const selectedIndex = 2;
await fireEvent.click(colorOption[selectedIndex]);
expect(colorOption).toHaveLength(7);
expect(emitted()).toHaveProperty('update');
expect(emitted().update[0]).toEqual([selectedIndex + 1]);
});
});

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeStickyNoteRender } from '@/types';
const emit = defineEmits<{
update: [color: number];
}>();
const i18n = useI18n();
const { render, eventBus } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
const autoHideTimeout = ref<NodeJS.Timeout | null>(null);
const colors = computed(() => Array.from({ length: 7 }).map((_, index) => index + 1));
const isPopoverVisible = defineModel<boolean>('visible');
function hidePopover() {
isPopoverVisible.value = false;
}
function showPopover() {
isPopoverVisible.value = true;
}
function changeColor(index: number) {
emit('update', index);
hidePopover();
}
function onMouseEnter() {
if (autoHideTimeout.value) {
clearTimeout(autoHideTimeout.value);
autoHideTimeout.value = null;
}
}
function onMouseLeave() {
autoHideTimeout.value = setTimeout(() => {
hidePopover();
}, 1000);
}
onMounted(() => {
eventBus.value?.on('update:sticky:color', showPopover);
});
onBeforeUnmount(() => {
eventBus.value?.off('update:sticky:color', showPopover);
});
</script>
<template>
<N8nPopover
v-model:visible="isPopoverVisible"
effect="dark"
trigger="click"
placement="top"
:popper-class="$style.popover"
:popper-style="{ width: '208px' }"
:teleported="true"
@before-enter="onMouseEnter"
@after-leave="onMouseLeave"
>
<template #reference>
<div
:class="$style.option"
data-test-id="change-sticky-color"
:title="i18n.baseText('node.changeColor')"
>
<FontAwesomeIcon icon="palette" />
</div>
</template>
<div :class="$style.content">
<div
v-for="color in colors"
:key="color"
data-test-id="color"
:class="[
$style.color,
$style[`sticky-color-${color}`],
renderOptions.color === color ? $style.selected : '',
]"
@click="changeColor(color)"
></div>
</div>
</N8nPopover>
</template>
<style lang="scss" module>
.popover {
min-width: 208px;
margin-bottom: -8px;
margin-left: -2px;
}
.content {
display: flex;
flex-direction: row;
width: fit-content;
gap: var(--spacing-2xs);
}
.color {
width: 20px;
height: 20px;
border-width: 1px;
border-style: solid;
border-color: var(--color-foreground-xdark);
border-radius: 50%;
background: var(--color-sticky-background);
&:hover {
cursor: pointer;
}
&.selected {
box-shadow: 0 0 0 1px var(--color-sticky-background);
}
&.sticky-color-1 {
--color-sticky-background: var(--color-sticky-background-1);
}
&.sticky-color-2 {
--color-sticky-background: var(--color-sticky-background-2);
}
&.sticky-color-3 {
--color-sticky-background: var(--color-sticky-background-3);
}
&.sticky-color-4 {
--color-sticky-background: var(--color-sticky-background-4);
}
&.sticky-color-5 {
--color-sticky-background: var(--color-sticky-background-5);
}
&.sticky-color-6 {
--color-sticky-background: var(--color-sticky-background-6);
}
&.sticky-color-7 {
--color-sticky-background: var(--color-sticky-background-7);
}
}
.option {
display: inline-block;
padding: var(--spacing-3xs);
color: var(--color-text-light);
svg {
width: var(--font-size-s) !important;
}
&:hover {
color: var(--color-primary);
}
}
</style>