mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
276
packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts
Normal file
276
packages/frontend/editor-ui/src/components/canvas/Canvas.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
886
packages/frontend/editor-ui/src/components/canvas/Canvas.vue
Normal file
886
packages/frontend/editor-ui/src/components/canvas/Canvas.vue
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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-->"
|
||||
`;
|
||||
@@ -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-->"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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%);');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './getEdgeRenderData';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>"`;
|
||||
@@ -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>"`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>"`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user