feat(editor): Add initial code for NodeView and Canvas rewrite (no-changelog) (#9135)

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Alex Grozav 2024-05-23 11:42:10 +03:00 committed by GitHub
parent 8566301731
commit 70948ec71b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 4208 additions and 21 deletions

View file

@ -45,6 +45,11 @@
"@n8n/chat": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"@vue-flow/node-toolbar": "^1.1.0",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"axios": "1.6.7",

View file

@ -1317,6 +1317,7 @@ export interface UIState {
pendingNotificationsForViews: {
[key in VIEWS]?: NotificationOptions[];
};
isCreateNodeActive: boolean;
}
export type IFakeDoor = {
@ -1898,7 +1899,7 @@ export type AddedNodesAndConnections = {
export type ToggleNodeCreatorOptions = {
createNodeActive: boolean;
source?: NodeCreatorOpenSource;
nodeCreatorView?: string;
nodeCreatorView?: NodeFilterType;
};
export type AppliedThemeOption = 'light' | 'dark';

View file

@ -0,0 +1,87 @@
import { CanvasNodeKey } from '@/constants';
import { ref } from 'vue';
import type { CanvasElement, CanvasElementData } from '@/types';
export function createCanvasNodeData({
id = 'node',
type = 'test',
typeVersion = 1,
inputs = [],
outputs = [],
renderType = 'default',
}: Partial<CanvasElementData> = {}): CanvasElementData {
return {
id,
type,
typeVersion,
inputs,
outputs,
renderType,
};
}
export function createCanvasNodeElement({
id = '1',
type = 'node',
label = 'Node',
position = { x: 100, y: 100 },
data,
}: Partial<
Omit<CanvasElement, 'data'> & { data: Partial<CanvasElementData> }
> = {}): CanvasElement {
return {
id,
type,
label,
position,
data: createCanvasNodeData({ id, type, ...data }),
};
}
export function createCanvasNodeProps({
id = 'node',
label = 'Test Node',
selected = false,
data = {},
} = {}) {
return {
id,
label,
selected,
data: createCanvasNodeData(data),
};
}
export function createCanvasNodeProvide({
id = 'node',
label = 'Test Node',
selected = false,
data = {},
} = {}) {
const props = createCanvasNodeProps({ id, label, selected, data });
return {
[`${CanvasNodeKey}`]: {
id: ref(props.id),
label: ref(props.label),
selected: ref(props.selected),
data: ref(props.data),
},
};
}
export function createCanvasConnection(
nodeA: CanvasElement,
nodeB: CanvasElement,
{ sourceIndex = 0, targetIndex = 0 } = {},
) {
const nodeAOutput = nodeA.data?.outputs[sourceIndex];
const nodeBInput = nodeA.data?.inputs[targetIndex];
return {
id: `${nodeA.id}-${nodeB.id}`,
source: nodeA.id,
target: nodeB.id,
...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}),
...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}),
};
}

View file

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

View file

@ -1,5 +1,10 @@
import type { INodeTypeData, INodeTypeDescription, IN8nUISettings } from 'n8n-workflow';
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import {
AGENT_NODE_TYPE,
SET_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json';
@ -16,6 +21,12 @@ export const testingNodeTypes: INodeTypeData = {
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
},
},
[SET_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(SET_NODE_TYPE),
},
},
[CHAT_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
@ -32,6 +43,7 @@ export const testingNodeTypes: INodeTypeData = {
export const defaultMockNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
[SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE],
};
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {

View file

@ -17,22 +17,21 @@ import type { ProjectSharingData } from '@/features/projects/projects.types';
import type { RouteLocationNormalized } from 'vue-router';
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
const getResolvedKey = (key: string) => {
const resolvedKeyParts = key.split(/[\/.]/);
return resolvedKeyParts[resolvedKeyParts.length - 1];
};
const nodeTypes = {
...defaultMockNodeTypes,
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
acc[getResolvedKey(key)] = data[key];
acc[key] = data[key];
return acc;
}, {}),
};
function getKnownTypes(): IDataObject {
return {};
}
function getByName(nodeType: string): INodeType | IVersionedNodeType {
return nodeTypes[getResolvedKey(nodeType)].type;
return nodeTypes[nodeType].type;
}
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
@ -40,6 +39,7 @@ export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
}
return {
getKnownTypes,
getByName,
getByNameAndVersion,
};

View file

@ -19,7 +19,8 @@ export type RenderOptions = Parameters<typeof render>[1] & {
const TelemetryPlugin: Plugin<{}> = {
install(app) {
app.config.globalProperties.$telemetry = {
track(event: string, properties?: object) {},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
track(..._: unknown[]) {},
} as Telemetry;
},
};

View file

@ -32,6 +32,7 @@ export const retry = async (
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
initialized: true,
settings: defaultSettings,
promptsData: {
message: '',
@ -62,14 +63,13 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
loginLabel: '',
loginEnabled: false,
},
mfa: {
enabled: false,
},
onboardingCallPromptEnabled: false,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
initialized: false,
mfa: {
enabled: false,
},
};
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {

View file

@ -0,0 +1,112 @@
// @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, CanvasElement } from '@/types';
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
import { NodeConnectionType } from 'n8n-workflow';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
global.window = jsdom.window as unknown as Window & typeof globalThis;
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(Canvas, { pinia });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Canvas', () => {
it('should initialize with default props', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('canvas')).toBeVisible();
expect(getByTestId('canvas-background')).toBeVisible();
expect(getByTestId('canvas-minimap')).toBeVisible();
expect(getByTestId('canvas-controls')).toBeVisible();
});
it('should render nodes and edges', async () => {
const elements: CanvasElement[] = [
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(elements[0], elements[1])];
const { container } = renderComponent({
props: {
elements,
connections,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
expect(container.querySelector(`[data-id="${elements[0].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${elements[1].id}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
});
it('should handle node drag stop event', async () => {
const elements = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({
props: {
elements,
},
});
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
const node = container.querySelector(`[data-id="${elements[0].id}"]`) as Element;
await fireEvent.mouseDown(node, { view: window });
await fireEvent.mouseMove(node, {
view: window,
clientX: 100,
clientY: 100,
});
await fireEvent.mouseUp(node, { view: window });
expect(emitted()['update:node:position']).toEqual([['1', { x: 100, y: 100 }]]);
});
});

View file

@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { CanvasConnection, CanvasElement } from '@/types';
import type { NodeDragEvent, Connection } from '@vue-flow/core';
import { VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import CanvasNode from './elements/nodes/CanvasNode.vue';
import CanvasEdge from './elements/edges/CanvasEdge.vue';
import { useCssModule } from 'vue';
const $style = useCssModule();
const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]];
'update:node:position': [id: string, position: { x: number; y: number }];
'create:connection': [connection: Connection];
}>();
withDefaults(
defineProps<{
id?: string;
elements: CanvasElement[];
connections: CanvasConnection[];
controlsPosition?: PanelPosition;
}>(),
{
id: 'canvas',
elements: () => [],
connections: () => [],
controlsPosition: PanelPosition.BottomLeft,
},
);
function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => {
emit('update:node:position', node.id, node.position);
});
}
function onConnect(...args: unknown[]) {
emit('create:connection', args[0] as Connection);
}
</script>
<template>
<VueFlow
:id="id"
:nodes="elements"
:edges="connections"
:apply-changes="false"
fit-view-on-init
pan-on-scroll
:min-zoom="0.2"
:max-zoom="2"
data-test-id="canvas"
@node-drag-stop="onNodeDragStop"
@connect="onConnect"
>
<template #node-canvas-node="canvasNodeProps">
<CanvasNode v-bind="canvasNodeProps" />
</template>
<template #edge-canvas-edge="canvasEdgeProps">
<CanvasEdge v-bind="canvasEdgeProps" />
</template>
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
<MiniMap data-test-id="canvas-minimap" pannable />
<Controls
data-test-id="canvas-controls"
:class="$style.canvasControls"
:position="controlsPosition"
></Controls>
</VueFlow>
</template>
<style lang="scss" module></style>
<style lang="scss">
.vue-flow__controls {
display: flex;
gap: var(--spacing-2xs);
box-shadow: none;
}
.vue-flow__controls-button {
width: 42px;
height: 42px;
border: var(--border-base);
border-radius: var(--border-radius-base);
padding: 0;
transition-property: transform, background, border, color;
transition-duration: 300ms;
transition-timing-function: ease;
&:hover {
border-color: var(--color-button-secondary-hover-active-border);
background-color: var(--color-button-secondary-active-background);
transform: scale(1.1);
svg {
fill: var(--color-primary);
}
}
svg {
max-height: 16px;
max-width: 16px;
transition-property: fill;
transition-duration: 300ms;
transition-timing-function: ease;
}
}
</style>

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import Canvas from '@/components/canvas/Canvas.vue';
import { toRef, useCssModule } from 'vue';
import type { Workflow } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
const props = defineProps<{
id?: string;
workflow: IWorkflowDb;
workflowObject: Workflow;
}>();
const $style = useCssModule();
const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject');
const { elements, connections } = useCanvasMapping({ workflow, workflowObject });
</script>
<template>
<div :class="$style.wrapper">
<div :class="$style.canvas">
<Canvas v-if="workflow" :elements="elements" :connections="connections" 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;
}
.executionButtons {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-l);
width: auto;
@media (max-width: $breakpoint-2xs) {
bottom: 150px;
}
button {
display: flex;
justify-content: center;
align-items: center;
margin-left: 0.625rem;
&:first-child {
margin: 0;
}
}
}
</style>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
defineEmits(['click']);
const uiStore = useUIStore();
const locale = useI18n();
const workflowRunning = computed(() => uiStore.isActionActive('workflowRunning'));
const runButtonText = computed(() => {
if (!workflowRunning.value) {
return locale.baseText('nodeView.runButtonText.executeWorkflow');
}
return locale.baseText('nodeView.runButtonText.executingWorkflow');
});
</script>
<template>
<KeyboardShortcutTooltip :label="runButtonText" :shortcut="{ metaKey: true, keys: ['↵'] }">
<N8nButton
:loading="workflowRunning"
:label="runButtonText"
size="large"
icon="flask"
type="primary"
data-test-id="execute-workflow-button"
@click.stop="$emit('click', $event)"
/>
</KeyboardShortcutTooltip>
</template>

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
import type { EdgeProps } from '@vue-flow/core';
import { BaseEdge, getBezierPath } from '@vue-flow/core';
import { computed } from 'vue';
const props = defineProps<EdgeProps>();
const edgeStyle = computed(() => ({
strokeWidth: 2,
...props.style,
}));
const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}),
);
</script>
<template>
<BaseEdge
:id="id"
:style="edgeStyle"
:path="path[0]"
:marker-end="markerEnd"
:label="data?.label"
:label-x="path[1]"
:label-y="path[2]"
:label-style="{ fill: 'white' }"
:label-show-bg="true"
:label-bg-style="{ fill: 'red' }"
:label-bg-padding="[2, 4]"
:label-bg-border-radius="2"
/>
</template>

View file

@ -0,0 +1,100 @@
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
import { NodeConnectionType } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import { CanvasNodeHandleKey } from '@/constants';
import { ref } from 'vue';
const renderComponent = createComponentRenderer(HandleRenderer);
const Handle = {
template: '<div><slot /></div>',
};
describe('HandleRenderer', () => {
it('should render the main input handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: '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('.canvas-node-handle-main-input')).toBeInTheDocument();
});
it('should render the main output handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: '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('.canvas-node-handle-main-output')).toBeInTheDocument();
});
it('should render the non-main handle correctly', async () => {
const { container } = renderComponent({
props: {
mode: '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('.canvas-node-handle-non-main')).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: {
[`${CanvasNodeHandleKey}`]: { label: ref(label) },
},
stubs: {
Handle,
},
},
});
expect(getByText(label)).toBeInTheDocument();
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,93 @@
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 { createCanvasNodeProps } from '@/__tests__/data';
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 });
});
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 },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
],
outputs: [{ type: NodeConnectionType.Main }, { type: NodeConnectionType.Main }],
},
}),
},
global: {
stubs: {
HandleRenderer: 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);
});
});
describe('toolbar', () => {
it('should render toolbar when node is hovered', async () => {
const { getByTestId, container } = renderComponent({
props: {
...createCanvasNodeProps(),
},
});
const node = getByTestId('canvas-node');
await fireEvent.mouseOver(node);
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,153 @@
<script lang="ts" setup>
import { Position } from '@vue-flow/core';
import { computed, provide, toRef } from 'vue';
import type {
CanvasElementData,
CanvasConnectionPort,
CanvasElementPortWithPosition,
} 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 HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import type { NodeProps } from '@vue-flow/core';
const props = defineProps<NodeProps<CanvasElementData>>();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const nodeTypesStore = useNodeTypesStore();
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs } = useNodeConnections({
inputs,
outputs,
});
const nodeType = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
/**
* Inputs
*/
const inputsWithPosition = computed(() => {
return [
...mainInputs.value.map(mapEndpointWithPosition(Position.Left, 'top')),
...nonMainInputs.value.map(mapEndpointWithPosition(Position.Bottom, 'left')),
];
});
/**
* Outputs
*/
const outputsWithPosition = computed(() => {
return [
...mainOutputs.value.map(mapEndpointWithPosition(Position.Right, 'top')),
...nonMainOutputs.value.map(mapEndpointWithPosition(Position.Top, 'left')),
];
});
/**
* Endpoints
*/
const mapEndpointWithPosition =
(position: Position, offsetAxis: 'top' | 'left') =>
(
endpoint: CanvasConnectionPort,
index: number,
endpoints: CanvasConnectionPort[],
): CanvasElementPortWithPosition => {
return {
...endpoint,
position,
offset: {
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
},
};
};
/**
* Provide
*/
const id = toRef(props, 'id');
const data = toRef(props, 'data');
const label = toRef(props, 'label');
const selected = toRef(props, 'selected');
provide(CanvasNodeKey, {
id,
data,
label,
selected,
nodeType,
});
</script>
<template>
<div :class="$style.canvasNode" data-test-id="canvas-node">
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`">
<HandleRenderer
mode="output"
data-test-id="canvas-node-output-handle"
:type="source.type"
:label="source.label"
:index="source.index"
:position="source.position"
:offset="source.offset"
/>
</template>
<template v-for="target in inputsWithPosition" :key="`${target.type}/${target.index}`">
<HandleRenderer
mode="input"
data-test-id="canvas-node-input-handle"
:type="target.type"
:label="target.label"
:index="target.index"
:position="target.position"
:offset="target.offset"
/>
</template>
<CanvasNodeToolbar
v-if="nodeType"
data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar"
/>
<CanvasNodeRenderer v-if="nodeType">
<NodeIcon :node-type="nodeType" :size="40" :shrink="false" />
<!-- :color-default="iconColorDefault"-->
<!-- :disabled="data.disabled"-->
</CanvasNodeRenderer>
</div>
</template>
<style lang="scss" module>
.canvasNode {
&:hover {
.canvasNodeToolbar {
display: flex;
opacity: 1;
}
}
}
.canvasNodeToolbar {
display: none;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -100%);
opacity: 0;
transition: opacity 0.3s ease;
}
</style>

View file

@ -0,0 +1,51 @@
import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
describe('CanvasNodeRenderer', () => {
it('should render default node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('canvas-node-default')).toBeInTheDocument();
});
it('should render configuration node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configuration',
},
}),
},
},
});
expect(getByTestId('canvas-node-configuration')).toBeInTheDocument();
});
it('should render configurable node correctly', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configurable',
},
}),
},
},
});
expect(getByTestId('canvas-node-configurable')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,39 @@
<script lang="ts" setup>
import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
import { CanvasNodeKey } from '@/constants';
const node = inject(CanvasNodeKey);
const slots = defineSlots<{
default?: () => unknown;
}>();
const Render = () => {
let Component;
switch (node?.data.value.renderType) {
case 'configurable':
Component = CanvasNodeConfigurable;
break;
case 'configuration':
Component = CanvasNodeConfiguration;
break;
case 'trigger':
Component = CanvasNodeDefault;
break;
default:
Component = CanvasNodeDefault;
}
return h(Component, slots.default);
};
</script>
<template>
<Render />
</template>

View file

@ -0,0 +1,108 @@
import { fireEvent } from '@testing-library/vue';
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
describe('CanvasNodeToolbar', () => {
it('should render execute node button when renderType is not configuration', async () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('execute-node-button')).toBeInTheDocument();
});
it('should not render execute node button when renderType is configuration', async () => {
const { queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configuration',
},
}),
},
},
});
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
});
it('should call executeNode function when execute node button is clicked', async () => {
const executeNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
executeNode,
},
},
});
await fireEvent.click(getByTestId('execute-node-button'));
expect(executeNode).toHaveBeenCalled();
});
it('should call toggleDisableNode function when disable node button is clicked', async () => {
const toggleDisableNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
toggleDisableNode,
},
},
});
await fireEvent.click(getByTestId('disable-node-button'));
expect(toggleDisableNode).toHaveBeenCalled();
});
it('should call deleteNode function when delete node button is clicked', async () => {
const deleteNode = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
deleteNode,
},
},
});
await fireEvent.click(getByTestId('delete-node-button'));
expect(deleteNode).toHaveBeenCalled();
});
it('should call openContextMenu function when overflow node button is clicked', async () => {
const openContextMenu = vi.fn();
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
openContextMenu,
},
},
});
await fireEvent.click(getByTestId('overflow-node-button'));
expect(openContextMenu).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
const node = inject(CanvasNodeKey);
const data = computed(() => node?.data.value);
const $style = useCssModule();
// @TODO
const workflowRunning = false;
// @TODO
const nodeDisabledTitle = 'Test';
// @TODO
function executeNode() {}
// @TODO
function toggleDisableNode() {}
// @TODO
function deleteNode() {}
// @TODO
function openContextMenu(e: MouseEvent, type: string) {}
</script>
<template>
<div :class="$style.canvasNodeToolbar">
<div :class="$style.canvasNodeToolbarItems">
<N8nIconButton
v-if="data?.renderType !== 'configuration'"
data-test-id="execute-node-button"
type="tertiary"
text
size="small"
icon="play"
:disabled="workflowRunning"
:title="$locale.baseText('node.testStep')"
@click="executeNode"
/>
<N8nIconButton
data-test-id="disable-node-button"
type="tertiary"
text
size="small"
icon="power-off"
:title="nodeDisabledTitle"
@click="toggleDisableNode"
/>
<N8nIconButton
data-test-id="delete-node-button"
type="tertiary"
size="small"
text
icon="trash"
:title="$locale.baseText('node.delete')"
@click="deleteNode"
/>
<N8nIconButton
data-test-id="overflow-node-button"
type="tertiary"
size="small"
text
icon="ellipsis-h"
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
/>
</div>
</div>
</template>
<style lang="scss" module>
.canvasNodeToolbar {
padding-bottom: var(--spacing-3xs);
}
.canvasNodeToolbarItems {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View file

@ -0,0 +1,70 @@
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeConfigurable);
describe('CanvasNodeConfigurable', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node')).toBeInTheDocument();
});
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('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 },
{ type: NodeConnectionType.AiTool },
{ type: NodeConnectionType.AiDocument, required: true },
{ type: NodeConnectionType.AiMemory, required: true },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--configurable-node-input-count': '3' });
});
});
});

View file

@ -0,0 +1,85 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
});
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
};
});
const styles = computed(() => {
const stylesObject: {
[key: string]: string | number;
} = {};
if (requiredNonMainInputs.value.length > 0) {
let spacerCount = 0;
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS) {
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;
}
return stylesObject;
});
</script>
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
<slot />
<div :class="$style.label">{{ label }}</div>
</div>
</template>
<style lang="scss" module>
.node {
--configurable-node-min-input-count: 4;
--configurable-node-input-width: 65px;
width: calc(
max(var(--configurable-node-input-count, 5), var(--configurable-node-min-input-count)) *
var(--configurable-node-input-width)
);
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--color-canvas-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
}
.label {
top: 100%;
font-size: var(--font-size-m);
text-align: center;
margin-left: var(--spacing-s);
max-width: calc(
var(--node-width) - var(--configurable-node-icon-offset) - var(--configurable-node-icon-size) -
2 * var(--spacing-s)
);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
</style>

View file

@ -0,0 +1,43 @@
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeConfiguration);
describe('CanvasNodeConfiguration', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node')).toBeInTheDocument();
});
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');
});
});
});

View file

@ -0,0 +1,51 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const label = computed(() => node?.label.value ?? '');
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
};
});
</script>
<template>
<div :class="classes" data-test-id="canvas-node-configuration">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
</div>
</template>
<style lang="scss" module>
.node {
width: 75px;
height: 75px;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50%;
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
.label {
top: 100%;
position: absolute;
font-size: var(--font-size-m);
text-align: center;
width: 100%;
min-width: 200px;
margin-top: var(--spacing-2xs);
}
</style>

View file

@ -0,0 +1,84 @@
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasNodeDefault);
describe('CanvasNodeDefault', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node')).toBeInTheDocument();
});
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 }],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--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 },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--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');
});
});
});

View file

@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed, inject, useCssModule } from 'vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
const node = inject(CanvasNodeKey);
const $style = useCssModule();
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => node?.data.value.inputs ?? []);
const outputs = computed(() => node?.data.value.outputs ?? []);
const { mainOutputs } = useNodeConnections({
inputs,
outputs,
});
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: node?.selected.value,
};
});
const styles = computed(() => {
return {
'--node-main-output-count': mainOutputs.value.length,
};
});
</script>
<template>
<div v-if="node" :class="classes" :style="styles" data-test-id="canvas-node-default">
<slot />
<div v-if="label" :class="$style.label">{{ label }}</div>
</div>
</template>
<style lang="scss" module>
.node {
height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
width: 100px;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--color-canvas-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
}
.label {
top: 100%;
position: absolute;
font-size: var(--font-size-m);
text-align: center;
width: 100%;
min-width: 200px;
margin-top: var(--spacing-2xs);
}
.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
</style>

View file

@ -0,0 +1,213 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
import type { IConnections, Workflow } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { NodeConnectionType } from 'n8n-workflow';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeType: vi.fn(() => ({
name: 'test',
description: 'Test Node Description',
})),
isTriggerNode: vi.fn(),
isConfigNode: vi.fn(),
isConfigurableNode: vi.fn(),
})),
}));
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('useCanvasMapping', () => {
it('should initialize with default props', () => {
const workflow = createTestWorkflow({
id: '1',
name: 'Test Workflow',
nodes: [],
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements, connections } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value).toEqual([]);
expect(connections.value).toEqual([]);
});
describe('elements', () => {
it('should map nodes to canvas elements', () => {
const node = createTestNode({
name: 'Node',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [node],
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value).toEqual([
{
id: node.id,
label: node.name,
type: 'canvas-node',
position: { x: 0, y: 0 },
data: {
id: node.id,
type: node.type,
typeVersion: 1,
inputs: [],
outputs: [],
renderType: 'default',
},
},
]);
});
});
describe('connections', () => {
it('should map connections to canvas connections', () => {
const nodeA = createTestNode({
name: 'Node A',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const nodeB = createTestNode({
name: 'Node B',
type: SET_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [nodeA, nodeB],
connections: {
[nodeA.name]: {
[NodeConnectionType.Main]: [
[{ node: nodeB.name, type: NodeConnectionType.Main, index: 0 }],
],
},
} as IConnections,
});
const workflowObject = createTestWorkflowObject(workflow);
const { connections } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(connections.value).toEqual([
{
data: {
fromNodeName: nodeA.name,
source: {
index: 0,
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: NodeConnectionType.Main,
},
},
id: `[${nodeA.id}/${NodeConnectionType.Main}/0][${nodeB.id}/${NodeConnectionType.Main}/0]`,
label: '',
source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
target: nodeB.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
type: 'canvas-edge',
},
]);
});
it('should map multiple input types to canvas connections', () => {
const nodeA = createTestNode({
name: 'Node A',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const nodeB = createTestNode({
name: 'Node B',
type: SET_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [nodeA, nodeB],
connections: {
'Node A': {
[NodeConnectionType.AiTool]: [
[{ node: nodeB.name, type: NodeConnectionType.AiTool, index: 0 }],
],
[NodeConnectionType.AiDocument]: [
[{ node: nodeB.name, type: NodeConnectionType.AiDocument, index: 1 }],
],
},
},
});
const workflowObject = createTestWorkflowObject(workflow);
const { connections } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(connections.value).toEqual([
{
data: {
fromNodeName: nodeA.name,
source: {
index: 0,
type: NodeConnectionType.AiTool,
},
target: {
index: 0,
type: NodeConnectionType.AiTool,
},
},
id: `[${nodeA.id}/${NodeConnectionType.AiTool}/0][${nodeB.id}/${NodeConnectionType.AiTool}/0]`,
label: '',
source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`,
target: nodeB.id,
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
type: 'canvas-edge',
},
{
data: {
fromNodeName: nodeA.name,
source: {
index: 0,
type: NodeConnectionType.AiDocument,
},
target: {
index: 1,
type: NodeConnectionType.AiDocument,
},
},
id: `[${nodeA.id}/${NodeConnectionType.AiDocument}/0][${nodeB.id}/${NodeConnectionType.AiDocument}/1]`,
label: '',
source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`,
target: nodeB.id,
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
type: 'canvas-edge',
},
]);
});
});
});

View file

@ -0,0 +1,150 @@
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { Ref } from 'vue';
import { computed } from 'vue';
import type {
CanvasConnection,
CanvasConnectionPort,
CanvasElement,
CanvasElementData,
} from '@/types';
import {
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
} from '@/utils/canvasUtilsV2';
import type { Workflow } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
export function useCanvasMapping({
workflow,
workflowObject,
}: {
workflow: Ref<IWorkflowDb>;
workflowObject: Ref<Workflow>;
}) {
const locale = useI18n();
const nodeTypesStore = useNodeTypesStore();
const renderTypeByNodeType = computed(
() =>
workflow.value.nodes.reduce<Record<string, CanvasElementData['renderType']>>((acc, node) => {
let renderType: CanvasElementData['renderType'] = 'default';
switch (true) {
case nodeTypesStore.isTriggerNode(node.type):
renderType = 'trigger';
break;
case nodeTypesStore.isConfigNode(workflowObject.value, node, node.type):
renderType = 'configuration';
break;
case nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type):
renderType = 'configurable';
break;
}
acc[node.type] = renderType;
return acc;
}, {}) ?? {},
);
const nodeInputsById = computed(() =>
workflow.value.nodes.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] =
workflowObjectNode && nodeTypeDescription
? mapLegacyEndpointsToCanvasConnectionPort(
NodeHelpers.getNodeInputs(
workflowObject.value,
workflowObjectNode,
nodeTypeDescription,
),
)
: [];
return acc;
}, {}),
);
const nodeOutputsById = computed(() =>
workflow.value.nodes.reduce<Record<string, CanvasConnectionPort[]>>((acc, node) => {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
const workflowObjectNode = workflowObject.value.getNode(node.name);
acc[node.id] =
workflowObjectNode && nodeTypeDescription
? mapLegacyEndpointsToCanvasConnectionPort(
NodeHelpers.getNodeOutputs(
workflowObject.value,
workflowObjectNode,
nodeTypeDescription,
),
)
: [];
return acc;
}, {}),
);
const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => {
const data: CanvasElementData = {
id: node.id,
type: node.type,
typeVersion: node.typeVersion,
inputs: nodeInputsById.value[node.id] ?? [],
outputs: nodeOutputsById.value[node.id] ?? [],
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
};
return {
id: node.id,
label: node.name,
type: 'canvas-node',
position: { x: node.position[0], y: node.position[1] },
data,
};
}),
]);
const connections = computed<CanvasConnection[]>(() => {
const mappedConnections = mapLegacyConnectionsToCanvasConnections(
workflow.value.connections ?? [],
workflow.value.nodes ?? [],
);
return mappedConnections.map((connection) => {
const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
return {
...connection,
type,
label,
};
});
});
function getConnectionType(_: CanvasConnection): string {
return 'canvas-edge';
}
function getConnectionLabel(connection: CanvasConnection): string {
const pinData = workflow.value.pinData?.[connection.data?.fromNodeName ?? ''];
if (pinData?.length) {
return locale.baseText('ndv.output.items', {
adjustToNumber: pinData.length,
interpolate: { count: String(pinData.length) },
});
}
return '';
}
return {
connections,
elements,
};
}

View file

@ -0,0 +1,88 @@
import { ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
import { useNodeConnections } from '@/composables/useNodeConnections';
import type { CanvasElementData } from '@/types';
describe('useNodeConnections', () => {
describe('mainInputs', () => {
it('should return main inputs when provided with main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 1 },
{ type: NodeConnectionType.Main, index: 2 },
{ type: NodeConnectionType.AiAgent, index: 0 },
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { mainInputs } = useNodeConnections({ inputs, outputs });
expect(mainInputs.value.length).toBe(3);
expect(mainInputs.value).toEqual(inputs.value.slice(0, 3));
});
});
describe('nonMainInputs', () => {
it('should return non-main inputs when provided with non-main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 1 },
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { nonMainInputs } = useNodeConnections({ inputs, outputs });
expect(nonMainInputs.value.length).toBe(2);
expect(nonMainInputs.value).toEqual(inputs.value.slice(1));
});
});
describe('requiredNonMainInputs', () => {
it('should return required non-main inputs when provided with required non-main inputs', () => {
const inputs = ref<CanvasElementData['inputs']>([
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, required: true, index: 0 },
{ type: NodeConnectionType.AiAgent, required: false, index: 1 },
]);
const outputs = ref<CanvasElementData['outputs']>([]);
const { requiredNonMainInputs } = useNodeConnections({ inputs, outputs });
expect(requiredNonMainInputs.value.length).toBe(1);
expect(requiredNonMainInputs.value).toEqual([inputs.value[1]]);
});
});
describe('mainOutputs', () => {
it('should return main outputs when provided with main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 1 },
{ type: NodeConnectionType.Main, index: 2 },
{ type: NodeConnectionType.AiAgent, index: 0 },
]);
const { mainOutputs } = useNodeConnections({ inputs, outputs });
expect(mainOutputs.value.length).toBe(3);
expect(mainOutputs.value).toEqual(outputs.value.slice(0, 3));
});
});
describe('nonMainOutputs', () => {
it('should return non-main outputs when provided with non-main outputs', () => {
const inputs = ref<CanvasElementData['inputs']>([]);
const outputs = ref<CanvasElementData['outputs']>([
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 0 },
{ type: NodeConnectionType.AiAgent, index: 1 },
]);
const { nonMainOutputs } = useNodeConnections({ inputs, outputs });
expect(nonMainOutputs.value.length).toBe(2);
expect(nonMainOutputs.value).toEqual(outputs.value.slice(1));
});
});
});

View file

@ -0,0 +1,47 @@
import type { CanvasElementData } from '@/types';
import type { MaybeRef } from 'vue';
import { computed, unref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
export function useNodeConnections({
inputs,
outputs,
}: {
inputs: MaybeRef<CanvasElementData['inputs']>;
outputs: MaybeRef<CanvasElementData['outputs']>;
}) {
/**
* Inputs
*/
const mainInputs = computed(() =>
unref(inputs).filter((input) => input.type === NodeConnectionType.Main),
);
const nonMainInputs = computed(() =>
unref(inputs).filter((input) => input.type !== NodeConnectionType.Main),
);
const requiredNonMainInputs = computed(() =>
nonMainInputs.value.filter((input) => input.required),
);
/**
* Outputs
*/
const mainOutputs = computed(() =>
unref(outputs).filter((output) => output.type === NodeConnectionType.Main),
);
const nonMainOutputs = computed(() =>
unref(outputs).filter((output) => output.type !== NodeConnectionType.Main),
);
return {
mainInputs,
nonMainInputs,
requiredNonMainInputs,
mainOutputs,
nonMainOutputs,
};
}

View file

@ -4,6 +4,8 @@ import type {
NodeCreatorOpenSource,
} from './Interface';
import { NodeConnectionType } from 'n8n-workflow';
import type { CanvasNodeHandleInjectionData, CanvasNodeInjectionData } from '@/types';
import type { InjectionKey } from 'vue';
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
@ -450,6 +452,7 @@ export const enum VIEWS {
VARIABLES = 'VariablesView',
NEW_WORKFLOW = 'NodeViewNew',
WORKFLOW = 'NodeViewExisting',
WORKFLOW_V2 = 'NodeViewV2',
DEMO = 'WorkflowDemo',
TEMPLATE_IMPORT = 'WorkflowTemplate',
WORKFLOW_ONBOARDING = 'WorkflowOnboarding',
@ -483,7 +486,12 @@ export const enum VIEWS {
PROJECT_SETTINGS = 'ProjectSettings',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
export const EDITABLE_CANVAS_VIEWS = [
VIEWS.WORKFLOW,
VIEWS.NEW_WORKFLOW,
VIEWS.EXECUTION_DEBUG,
VIEWS.WORKFLOW_V2,
];
export const enum FAKE_DOOR_FEATURES {
ENVIRONMENTS = 'environments',
@ -611,6 +619,7 @@ export const enum STORES {
UI = 'ui',
USERS = 'users',
WORKFLOWS = 'workflows',
WORKFLOWS_V2 = 'workflowsV2',
WORKFLOWS_EE = 'workflowsEE',
EXECUTIONS = 'executions',
NDV = 'ndv',
@ -828,3 +837,11 @@ export const AI_ASSISTANT_EXPERIMENT_URLS = {
};
export const AI_ASSISTANT_LOCAL_STORAGE_KEY = 'N8N_AI_ASSISTANT_EXPERIMENT';
/**
* Injection Keys
*/
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
export const CanvasNodeHandleKey =
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;

View file

@ -1,5 +1,10 @@
import { createApp } from 'vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
import 'vue-json-pretty/lib/styles.css';
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
import 'n8n-design-system/css/index.scss';

View file

@ -10,12 +10,12 @@ import { WorkflowDataProxy } from 'n8n-workflow';
import { createTestWorkflowObject } from '@/__tests__/mocks';
const nodeTypes: INodeTypeData = {
'test.set': {
'n8n-nodes-base.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
name: 'n8n-nodes-base.set',
group: ['input'],
version: 1,
description: 'Sets a value',
@ -47,7 +47,7 @@ const nodeTypes: INodeTypeData = {
const nodes: INode[] = [
{
name: 'Start',
type: 'test.set',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
id: 'uuid-1',
@ -55,7 +55,7 @@ const nodes: INode[] = [
},
{
name: 'Function',
type: 'test.set',
type: 'n8n-nodes-base.set',
parameters: {
functionCode:
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
@ -66,7 +66,7 @@ const nodes: INode[] = [
},
{
name: 'Rename',
type: 'test.set',
type: 'n8n-nodes-base.set',
parameters: {
value1: 'data',
value2: 'initialName',
@ -77,7 +77,7 @@ const nodes: INode[] = [
},
{
name: 'End',
type: 'test.set',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
id: 'uuid-4',

View file

@ -25,6 +25,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const NodeView = async () => await import('@/views/NodeView.vue');
const NodeViewV2 = async () => await import('@/views/NodeView.v2.vue');
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
const WorkflowExecutionsLandingPage = async () =>
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
@ -357,6 +358,23 @@ export const routes = [
path: '/workflow',
redirect: '/workflow/new',
},
{
path: '/workflow-v2/:workflowId',
name: VIEWS.WORKFLOW_V2,
components: {
default: NodeViewV2,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
return !!localStorage.getItem('features.NodeViewV2');
},
},
},
},
{
path: '/signin',
name: VIEWS.SIGNIN,

View file

@ -195,6 +195,7 @@ export const useUIStore = defineStore(STORES.UI, {
// This enables us to set a queue of notifications form outside (another component)
// and then show them when the view is initialized
pendingNotificationsForViews: {},
isCreateNodeActive: false,
}),
getters: {
appliedTheme(): AppliedThemeOption {

View file

@ -305,6 +305,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.nodes.map((node) => ({ ...node }));
}
function setNodePosition(id: string, position: INodeUi['position']): void {
const node = workflow.value.nodes.find((n) => n.id === id);
if (!node) return;
setNodeValue({ name: node.name, key: 'position', value: position });
}
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = getNodeTypes();
let cachedWorkflowId: string | undefined = workflowId.value;
@ -1588,5 +1595,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
resetChatMessages,
appendChatMessage,
checkIfNodeHasChatParent,
setNodePosition,
};
});

View file

@ -0,0 +1,61 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
import type { ConnectionTypes, INodeTypeDescription } from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue';
export type CanvasElementType = 'node' | 'note';
export type CanvasConnectionPortType = ConnectionTypes;
export type CanvasConnectionPort = {
type: CanvasConnectionPortType;
required?: boolean;
index: number;
label?: string;
};
export interface CanvasElementPortWithPosition extends CanvasConnectionPort {
position: Position;
offset?: { top?: string; left?: string };
}
export interface CanvasElementData {
id: INodeUi['id'];
type: INodeUi['type'];
typeVersion: INodeUi['typeVersion'];
inputs: CanvasConnectionPort[];
outputs: CanvasConnectionPort[];
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
}
export type CanvasElement = Node<CanvasElementData>;
export interface CanvasConnectionData {
source: CanvasConnectionPort;
target: CanvasConnectionPort;
fromNodeName?: string;
}
export type CanvasConnection = DefaultEdge<CanvasConnectionData>;
export interface CanvasPluginContext {
instance: BrowserJsPlumbInstance;
}
export interface CanvasPlugin {
(ctx: CanvasPluginContext): void;
}
export interface CanvasNodeInjectionData {
id: Ref<string>;
data: Ref<CanvasElementData>;
label: Ref<NodeProps['label']>;
selected: Ref<NodeProps['selected']>;
nodeType: ComputedRef<INodeTypeDescription | null>;
}
export interface CanvasNodeHandleInjectionData {
label: Ref<string | undefined>;
}

View file

@ -1,2 +1,3 @@
export * from './canvas';
export * from './externalHooks';
export * from './pushConnection';

View file

@ -0,0 +1,530 @@
import {
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
getUniqueNodeName,
} from '@/utils/canvasUtilsV2';
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { CanvasConnection } from '@/types';
import type { INodeUi } from '@/Interface';
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid'),
}));
describe('mapLegacyConnectionsToCanvasConnections', () => {
it('should map legacy connections to canvas connections', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
},
};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [200, 200],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
]);
});
it('should return empty array when no matching nodes found', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
},
};
const nodes: INodeUi[] = [];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([]);
});
it('should return empty array when no legacy connections provided', () => {
const legacyConnections: IConnections = {};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [200, 200],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([]);
});
it('should map multiple connections between the same nodes', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Node B', type: 'main', index: 0 }],
[{ node: 'Node B', type: 'main', index: 1 }],
],
},
};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [200, 200],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
{
id: '[1/main/1][2/main/1]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/1',
data: {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
},
target: {
index: 1,
type: 'main',
},
},
},
]);
});
it('should map multiple connections from one node to different nodes', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Node B', type: 'main', index: 0 }],
[{ node: 'Node C', type: 'main', index: 0 }],
],
},
};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [200, 200],
parameters: {},
},
{
id: '3',
name: 'Node C',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [300, 300],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
{
id: '[1/main/1][3/main/0]',
source: '1',
target: '3',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
]);
});
it('should map complex node setup with mixed inputs and outputs', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
other: [[{ node: 'Node C', type: 'other', index: 1 }]],
},
'Node B': {
main: [[{ node: 'Node C', type: 'main', index: 0 }]],
},
};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
typeVersion: 1,
type: 'n8n-nodes-base.node',
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
typeVersion: 1,
type: 'n8n-nodes-base.node',
position: [200, 200],
parameters: {},
},
{
id: '3',
name: 'Node C',
typeVersion: 1,
type: 'n8n-nodes-base.node',
position: [300, 300],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
{
id: '[1/other/0][3/other/1]',
source: '1',
target: '3',
sourceHandle: 'outputs/other/0',
targetHandle: 'inputs/other/1',
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'other',
},
target: {
index: 1,
type: 'other',
},
},
},
{
id: '[2/main/0][3/main/0]',
source: '2',
target: '3',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node B',
source: {
index: 0,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
]);
});
it('should handle edge cases with invalid data gracefully', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Nonexistent Node', type: 'main', index: 0 }],
[{ node: 'Node B', type: 'main', index: 0 }],
],
},
};
const nodes: INodeUi[] = [
{
id: '1',
name: 'Node A',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [100, 100],
parameters: {},
},
{
id: '2',
name: 'Node B',
type: 'n8n-nodes-base.node',
typeVersion: 1,
position: [200, 200],
parameters: {},
},
];
const result: CanvasConnection[] = mapLegacyConnectionsToCanvasConnections(
legacyConnections,
nodes,
);
expect(result).toEqual([
{
id: '[1/main/1][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/0',
data: {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
},
target: {
index: 0,
type: 'main',
},
},
},
]);
});
});
describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
it('should return an empty array and log a warning when inputs is a string', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const endpoints: INodeTypeDescription['inputs'] = 'some code';
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Node endpoints have not been evaluated',
'some code',
);
consoleWarnSpy.mockRestore();
});
it('should map string endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = ['main', 'ai_tool'];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: undefined },
{ type: 'ai_tool', index: 0, label: undefined },
]);
});
it('should map object endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = [
{ type: 'main', displayName: 'Main Input' },
{ type: 'ai_tool', displayName: 'AI Tool', required: true },
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: 'Main Input' },
{ type: 'ai_tool', index: 0, label: 'AI Tool', required: true },
]);
});
it('should map mixed string and object endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = [
'main',
{ type: 'ai_tool', displayName: 'AI Tool' },
'main',
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: undefined },
{ type: 'ai_tool', index: 0, label: 'AI Tool' },
{ type: 'main', index: 1, label: undefined },
]);
});
it('should handle multiple same type object endpoints', () => {
const endpoints: INodeTypeDescription['inputs'] = [
{ type: 'main', displayName: 'Main Input' },
{ type: 'main', displayName: 'Secondary Main Input' },
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: 'Main Input' },
{ type: 'main', index: 1, label: 'Secondary Main Input' },
]);
});
it('should map required and non-required endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = [
{ type: 'main', displayName: 'Main Input', required: true },
{ type: 'ai_tool', displayName: 'Optional Tool', required: false },
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: 'Main Input', required: true },
{ type: 'ai_tool', index: 0, label: 'Optional Tool' },
]);
});
});
describe('getUniqueNodeName', () => {
it('should return the original name if it is unique', () => {
const name = 'Node A';
const existingNames = new Set(['Node B', 'Node C']);
const result = getUniqueNodeName(name, existingNames);
expect(result).toBe(name);
});
it('should append a number to the name if it already exists', () => {
const name = 'Node A';
const existingNames = new Set(['Node A', 'Node B']);
const result = getUniqueNodeName(name, existingNames);
expect(result).toBe('Node A 1');
});
it('should find the next available number for the name', () => {
const name = 'Node A';
const existingNames = new Set(['Node A', 'Node A 1', 'Node A 2']);
const result = getUniqueNodeName(name, existingNames);
expect(result).toBe('Node A 3');
});
it('should use UUID if more than 99 variations exist', () => {
const name = 'Node A';
const existingNames = new Set([...Array(100).keys()].map((i) => `Node A ${i}`).concat([name]));
const result = getUniqueNodeName(name, existingNames);
expect(result).toBe('Node A mock-uuid');
});
});

View file

@ -0,0 +1,91 @@
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
import { v4 as uuid } from 'uuid';
export function mapLegacyConnectionsToCanvasConnections(
legacyConnections: IConnections,
nodes: INodeUi[],
): CanvasConnection[] {
const mappedConnections: CanvasConnection[] = [];
Object.keys(legacyConnections).forEach((fromNodeName) => {
const fromId = nodes.find((node) => node.name === fromNodeName)?.id;
const fromConnectionTypes = Object.keys(legacyConnections[fromNodeName]);
fromConnectionTypes.forEach((fromConnectionType) => {
const fromPorts = legacyConnections[fromNodeName][fromConnectionType];
fromPorts.forEach((toPorts, fromIndex) => {
toPorts.forEach((toPort) => {
const toId = nodes.find((node) => node.name === toPort.node)?.id;
const toConnectionType = toPort.type;
const toIndex = toPort.index;
if (fromId && toId) {
mappedConnections.push({
id: `[${fromId}/${fromConnectionType}/${fromIndex}][${toId}/${toConnectionType}/${toIndex}]`,
source: fromId,
target: toId,
sourceHandle: `outputs/${fromConnectionType}/${fromIndex}`,
targetHandle: `inputs/${toConnectionType}/${toIndex}`,
data: {
fromNodeName,
source: {
index: fromIndex,
type: fromConnectionType as CanvasConnectionPortType,
},
target: {
index: toIndex,
type: toConnectionType as CanvasConnectionPortType,
},
},
});
}
});
});
});
});
return mappedConnections;
}
export function mapLegacyEndpointsToCanvasConnectionPort(
endpoints: INodeTypeDescription['inputs'],
): CanvasConnectionPort[] {
if (typeof endpoints === 'string') {
console.warn('Node endpoints have not been evaluated', endpoints);
return [];
}
return endpoints.map((endpoint, endpointIndex) => {
const type = typeof endpoint === 'string' ? endpoint : endpoint.type;
const label = typeof endpoint === 'string' ? undefined : endpoint.displayName;
const index =
endpoints
.slice(0, endpointIndex + 1)
.filter((e) => (typeof e === 'string' ? e : e.type) === type).length - 1;
const required = typeof endpoint === 'string' ? false : endpoint.required;
return {
type,
index,
label,
...(required ? { required } : {}),
};
});
}
export function getUniqueNodeName(name: string, existingNames: Set<string>): string {
if (!existingNames.has(name)) {
return name;
}
for (let i = 1; i < 100; i++) {
const newName = `${name} ${i}`;
if (!existingNames.has(newName)) {
return newName;
}
}
return `${name} ${uuid()}`;
}

View file

@ -0,0 +1,971 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import CanvasExecuteWorkflowButton from '@/components/canvas/elements/buttons/CanvasExecuteWorkflowButton.vue';
import { useI18n } from '@/composables/useI18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import type {
AddedNodesAndConnections,
INodeUi,
ITag,
ToggleNodeCreatorOptions,
XYPosition,
} from '@/Interface';
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
import { useTagsStore } from '@/stores/tags.store';
import type { Connection } from '@vue-flow/core';
import type { CanvasElement } from '@/types';
import {
EnterpriseEditionFeature,
AI_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
VIEWS,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { v4 as uuid } from 'uuid';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useCollaborationStore } from '@/stores/collaboration.store';
import { getUniqueNodeName } from '@/utils/canvasUtilsV2';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
);
const $style = useCssModule();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const toast = useToast();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const tagsStore = useTagsStore();
const sourceControlStore = useSourceControlStore();
const nodeCreatorStore = useNodeCreatorStore();
const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const environmentsStore = useEnvironmentsStore();
const externalSecretsStore = useExternalSecretsStore();
const rootStore = useRootStore();
const collaborationStore = useCollaborationStore();
const { runWorkflow } = useRunWorkflow({ router });
const isLoading = ref(true);
const readOnlyNotification = ref<null | { visible: boolean }>(null);
const workflowId = computed<string>(() => route.params.workflowId as string);
const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]);
const editableWorkflow = computed(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true);
const isReadOnlyEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly;
});
const triggerNodes = computed<INodeUi[]>(() => {
return workflowsStore.workflowTriggerNodes;
});
const isCanvasAddButtonVisible = computed(() => {
return (
triggerNodes.value.length > 0 &&
!isLoading.value &&
!isDemoRoute.value &&
!isReadOnlyEnvironment.value
);
});
onMounted(() => {
void initialize();
});
async function initialize() {
isLoading.value = true;
const loadPromises: Array<Promise<unknown>> = [
nodeTypesStore.getNodeTypes(),
workflowsStore.fetchWorkflow(workflowId.value),
];
if (!settingsStore.isPreviewMode && !isDemoRoute.value) {
loadPromises.push(
workflowsStore.fetchActiveWorkflows(),
credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(true),
);
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
loadPromises.push(environmentsStore.fetchAllVariables());
}
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) {
loadPromises.push(externalSecretsStore.fetchAllSecrets());
}
}
try {
await Promise.all(loadPromises);
} catch (error) {
return toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
}
initializeEditableWorkflow(workflowId.value);
if (window.parent) {
window.parent.postMessage(
JSON.stringify({ command: 'n8nReady', version: rootStore.versionCli }),
'*',
);
}
isLoading.value = false;
}
// @TODO Maybe move this to the store
function initializeEditableWorkflow(id: string) {
const targetWorkflow = workflowsStore.workflowsById[id];
workflowsStore.addWorkflow(targetWorkflow);
workflowsStore.setWorkflow(targetWorkflow);
workflowsStore.setActive(targetWorkflow.active || false);
workflowsStore.setWorkflowId(targetWorkflow.id);
workflowsStore.setWorkflowName({ newName: targetWorkflow.name, setStateDirty: false });
workflowsStore.setWorkflowSettings(targetWorkflow.settings ?? {});
workflowsStore.setWorkflowPinData(targetWorkflow.pinData ?? {});
workflowsStore.setWorkflowVersionId(targetWorkflow.versionId);
workflowsStore.setWorkflowMetadata(targetWorkflow.meta);
if (targetWorkflow.usedCredentials) {
workflowsStore.setUsedCredentials(targetWorkflow.usedCredentials);
}
const tags = (targetWorkflow.tags ?? []) as ITag[];
const tagIds = tags.map((tag) => tag.id);
workflowsStore.setWorkflowTagIds(tagIds || []);
tagsStore.upsertTags(tags);
// @TODO Figure out a better way to handle this. Maybe show a message on why the state becomes dirty
// if (!this.credentialsUpdated) {
// this.uiStore.stateIsDirty = false;
// }
void externalHooks.run('workflow.open', {
workflowId: workflow.value.id,
workflowName: workflow.value.name,
});
// @TODO Figure out a better way to handle this
// if (selectedExecution?.workflowId !== workflow.id) {
// this.executionsStore.activeExecution = null;
// workflowsStore.currentWorkflowExecutions = [];
// } else {
// this.executionsStore.activeExecution = selectedExecution;
// }
collaborationStore.notifyWorkflowOpened(workflow.value.id);
}
async function onRunWorkflow() {
await runWorkflow({});
}
/**
* Map new node position format to the old one and update the store
*
* @param id
* @param position
*/
function onNodePositionUpdate(id: string, position: CanvasElement['position']) {
workflowsStore.setNodePosition(id, [position.x, position.y]);
}
/**
* Map new node connection format to the old one and add it to the store
*
* @param connection
*/
function onCreateNodeConnection(connection: Connection) {
// Output
const sourceNodeId = connection.source;
const sourceNode = workflowsStore.getNodeById(sourceNodeId);
const sourceNodeName = sourceNode?.name ?? '';
const [, sourceType, sourceIndex] = (connection.sourceHandle ?? '').split('/');
// Input
const targetNodeId = connection.target;
const targetNode = workflowsStore.getNodeById(targetNodeId);
const targetNodeName = targetNode?.name ?? '';
const [, targetType, targetIndex] = (connection.targetHandle ?? '').split('/');
if (sourceNode && targetNode && !checkIfNodeConnectionIsAllowed(sourceNode, targetNode)) {
return;
}
workflowsStore.addConnection({
connection: [
{
node: sourceNodeName,
type: sourceType,
index: parseInt(sourceIndex, 10),
},
{
node: targetNodeName,
type: targetType,
index: parseInt(targetIndex, 10),
},
],
});
uiStore.stateIsDirty = true;
}
// @TODO Figure out a way to improve this
function checkIfNodeConnectionIsAllowed(sourceNode: INodeUi, targetNode: INodeUi): boolean {
// const targetNodeType = nodeTypesStore.getNodeType(
// targetNode.type,
// targetNode.typeVersion,
// );
//
// if (targetNodeType?.inputs?.length) {
// const workflow = this.workflowHelpers.getCurrentWorkflow();
// const workflowNode = workflow.getNode(targetNode.name);
// let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
// if (targetNodeType) {
// inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType);
// }
//
// for (const input of inputs || []) {
// if (typeof input === 'string' || input.type !== targetInfoType || !input.filter) {
// // No filters defined or wrong connection type
// continue;
// }
//
// if (input.filter.nodes.length) {
// if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
// this.showToast({
// title: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.title'),
// message: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.message', {
// interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
// }),
// type: 'error',
// duration: 5000,
// });
// return false;
// }
// }
// }
// }
return true;
}
function onToggleNodeCreator({
source,
createNodeActive,
nodeCreatorView,
}: ToggleNodeCreatorOptions) {
if (createNodeActive === uiStore.isCreateNodeActive) {
return;
}
if (!nodeCreatorView) {
nodeCreatorView =
triggerNodes.value.length > 0 ? REGULAR_NODE_CREATOR_VIEW : TRIGGER_NODE_CREATOR_VIEW;
}
// Default to the trigger tab in node creator if there's no trigger node yet
nodeCreatorStore.setSelectedView(nodeCreatorView);
let mode;
switch (nodeCreatorStore.selectedView) {
case AI_NODE_CREATOR_VIEW:
mode = 'ai';
break;
case REGULAR_NODE_CREATOR_VIEW:
mode = 'regular';
break;
default:
mode = 'regular';
}
uiStore.isCreateNodeActive = createNodeActive;
if (createNodeActive && source) {
nodeCreatorStore.setOpenSource(source);
}
void externalHooks.run('nodeView.createNodeActiveChanged', {
source,
mode,
createNodeActive,
});
telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', {
source,
mode,
createNodeActive,
workflow_id: workflowId.value,
});
}
async function onAddNodes(
{ nodes, connections }: AddedNodesAndConnections,
dragAndDrop = false,
position?: XYPosition,
) {
let currentPosition = position;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
const node = await addNode(
{
name,
type,
position: nodePosition ?? currentPosition,
},
{
dragAndDrop,
openNDV: openDetail ?? false,
trackHistory: true,
isAutoAdd,
},
);
const lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
currentPosition = [
lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE,
lastAddedNode.position[1],
];
}
const newNodesOffset = editableWorkflow.value.nodes.length - nodes.length;
for (const { from, to } of connections) {
const fromNode = editableWorkflow.value.nodes[newNodesOffset + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[newNodesOffset + to.nodeIndex];
onCreateNodeConnection({
source: fromNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/${from.outputIndex ?? 0}`,
target: toNode.id,
targetHandle: `inputs/${NodeConnectionType.Main}/${to.inputIndex ?? 0}`,
});
}
// @TODO Implement this
// const lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
// const workflow = editableWorkflowObject.value;
// const lastNodeInputs = workflow.getParentNodesByDepth(lastAddedNode.name, 1);
//
// // If the last added node has multiple inputs, move them down
// if (lastNodeInputs.length > 1) {
// lastNodeInputs.slice(1).forEach((node, index) => {
// const nodeUi = workflowsStore.getNodeByName(node.name);
// if (!nodeUi) return;
//
// // onMoveNode({
// // nodeName: nodeUi.name,
// // position: [nodeUi.position[0], nodeUi.position[1] + 100 * (index + 1)],
// // });
// });
// }
}
type AddNodeData = {
name?: string;
type: string;
position?: XYPosition;
};
type AddNodeOptions = {
dragAndDrop?: boolean;
openNDV?: boolean;
trackHistory?: boolean;
isAutoAdd?: boolean;
};
async function addNode(node: AddNodeData, options: AddNodeOptions): Promise<INodeUi | undefined> {
if (!checkIfEditingIsAllowed()) {
return;
}
const newNodeData = await createNodeWithDefaultCredentials(node);
if (!newNodeData) {
return;
}
/**
* @TODO Check if maximum node type limit reached
*/
newNodeData.name = getUniqueNodeName(newNodeData.name, workflowsStore.canvasNames);
workflowsStore.addNode(newNodeData);
// @TODO Figure out why this is needed and if we can do better...
// this.matchCredentials(node);
// const lastSelectedNode = uiStore.getLastSelectedNode;
// const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex;
// const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid;
// const lastSelectedConnection = canvasStore.lastSelectedConnection;
//
// historyStore.startRecordingUndo();
//
// const newNodeData = await injectNode(
// nodeTypeName,
// options,
// showDetail,
// trackHistory,
// isAutoAdd,
// );
// if (!newNodeData) {
// return;
// }
//
// const outputIndex = lastSelectedNodeOutputIndex || 0;
// const targetEndpoint = lastSelectedNodeEndpointUuid || '';
//
// // Handle connection of scoped_endpoint types
// if (lastSelectedNodeEndpointUuid && !isAutoAdd) {
// const lastSelectedEndpoint = this.instance.getEndpoint(lastSelectedNodeEndpointUuid);
// if (
// lastSelectedEndpoint &&
// this.checkNodeConnectionAllowed(
// lastSelectedNode,
// newNodeData,
// lastSelectedEndpoint.scope as NodeConnectionType,
// )
// ) {
// const connectionType = lastSelectedEndpoint.scope as ConnectionTypes;
// const newNodeElement = this.instance.getManagedElement(newNodeData.id);
// const newNodeConnections = this.instance.getEndpoints(newNodeElement);
// const viableConnection = newNodeConnections.find((conn) => {
// return (
// conn.scope === connectionType &&
// lastSelectedEndpoint.parameters.connection !== conn.parameters.connection
// );
// });
//
// this.instance?.connect({
// uuids: [targetEndpoint, viableConnection?.uuid || ''],
// detachable: !this.isReadOnlyRoute && !this.readOnlyEnv,
// });
// this.historyStore.stopRecordingUndo();
// return;
// }
// }
// If a node is last selected then connect between the active and its child ones
// if (lastSelectedNode && !isAutoAdd) {
// await this.$nextTick();
//
// if (lastSelectedConnection?.__meta) {
// this.__deleteJSPlumbConnection(lastSelectedConnection, trackHistory);
//
// const targetNodeName = lastSelectedConnection.__meta.targetNodeName;
// const targetOutputIndex = lastSelectedConnection.__meta.targetOutputIndex;
// this.connectTwoNodes(
// newNodeData.name,
// 0,
// targetNodeName,
// targetOutputIndex,
// NodeConnectionType.Main,
// );
// }
//
// // Connect active node to the newly created one
// this.connectTwoNodes(
// lastSelectedNode.name,
// outputIndex,
// newNodeData.name,
// 0,
// NodeConnectionType.Main,
// );
// }
// this.historyStore.stopRecordingUndo();
return newNodeData;
}
async function createNodeWithDefaultCredentials(node: Partial<INodeUi>) {
const nodeTypeDescription = nodeTypesStore.getNodeType(
node.type as string,
) as INodeTypeDescription;
let nodeVersion = nodeTypeDescription.defaultVersion;
if (nodeVersion === undefined) {
nodeVersion = Array.isArray(nodeTypeDescription.version)
? nodeTypeDescription.version.slice(-1)[0]
: nodeTypeDescription.version;
}
const newNodeData: INodeUi = {
id: uuid(),
name: node.name ?? (nodeTypeDescription.defaults.name as string),
type: nodeTypeDescription.name,
typeVersion: nodeVersion,
position: node.position ?? [0, 0],
parameters: {},
};
/**
* @TODO Implement this
*/
// // Load the default parameter values because only values which differ
// // from the defaults get saved
// if (nodeType !== null) {
// let nodeParameters = null;
// try {
// nodeParameters = NodeHelpers.getNodeParameters(
// nodeType.properties,
// node.parameters,
// true,
// false,
// node,
// );
// } catch (e) {
// console.error(
// this.$locale.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') +
// `: "${node.name}"`,
// );
// console.error(e);
// }
// node.parameters = nodeParameters !== null ? nodeParameters : {};
//
// // if it's a webhook and the path is empty set the UUID as the default path
// if (
// [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(node.type) &&
// node.parameters.path === ''
// ) {
// node.parameters.path = node.webhookId as string;
// }
// }
// const credentialPerType = nodeTypeData.credentials
// ?.map((type) => credentialsStore.getUsableCredentialByType(type.name))
// .flat();
//
// if (credentialPerType && credentialPerType.length === 1) {
// const defaultCredential = credentialPerType[0];
//
// const selectedCredentials = credentialsStore.getCredentialById(defaultCredential.id);
// const selected = { id: selectedCredentials.id, name: selectedCredentials.name };
// const credentials = {
// [defaultCredential.type]: selected,
// };
//
// await loadNodesProperties(
// [newNodeData].map((node) => ({ name: node.type, version: node.typeVersion })),
// );
//
// const nodeType = nodeTypesStore.getNodeType(newNodeData.type, newNodeData.typeVersion);
// const nodeParameters = NodeHelpers.getNodeParameters(
// nodeType?.properties ?? [],
// {},
// true,
// false,
// newNodeData,
// );
//
// if (nodeTypeData.credentials) {
// const authentication = nodeTypeData.credentials.find(
// (type) => type.name === defaultCredential.type,
// );
// if (authentication?.displayOptions?.hide) {
// return newNodeData;
// }
//
// const authDisplayOptions = authentication?.displayOptions?.show;
// if (!authDisplayOptions) {
// newNodeData.credentials = credentials;
// return newNodeData;
// }
//
// if (Object.keys(authDisplayOptions).length === 1 && authDisplayOptions.authentication) {
// // ignore complex case when there's multiple dependencies
// newNodeData.credentials = credentials;
//
// let parameters: { [key: string]: string } = {};
// for (const displayOption of Object.keys(authDisplayOptions)) {
// if (nodeParameters && !nodeParameters[displayOption]) {
// parameters = {};
// newNodeData.credentials = undefined;
// break;
// }
// const optionValue = authDisplayOptions[displayOption]?.[0];
// if (optionValue && typeof optionValue === 'string') {
// parameters[displayOption] = optionValue;
// }
// newNodeData.parameters = {
// ...newNodeData.parameters,
// ...parameters,
// };
// }
// }
// }
// }
return newNodeData;
}
/**
* @TODO Implement if needed
*/
// async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
// const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
//
// const nodesToBeFetched: INodeTypeNameVersion[] = [];
// allNodes.forEach((node) => {
// const nodeVersions = Array.isArray(node.version) ? node.version : [node.version];
// if (
// !!nodeInfos.find((n) => n.name === node.name && nodeVersions.includes(n.version)) &&
// !node.hasOwnProperty('properties')
// ) {
// nodesToBeFetched.push({
// name: node.name,
// version: Array.isArray(node.version) ? node.version.slice(-1)[0] : node.version,
// });
// }
// });
//
// if (nodesToBeFetched.length > 0) {
// // Only call API if node information is actually missing
// this.canvasStore.startLoading();
// await this.nodeTypesStore.getNodesInformation(nodesToBeFetched);
// this.canvasStore.stopLoading();
// }
// }
/**
* @TODO Probably not needed and can be merged into addNode
*/
async function injectNode(
nodeTypeName: string,
options: AddNodeOptions = {},
showDetail = true,
trackHistory = false,
isAutoAdd = false,
) {
// const nodeTypeData: INodeTypeDescription | null =
// this.nodeTypesStore.getNodeType(nodeTypeName);
//
// if (nodeTypeData === null) {
// this.showMessage({
// title: this.$locale.baseText('nodeView.showMessage.addNodeButton.title'),
// message: this.$locale.baseText('nodeView.showMessage.addNodeButton.message', {
// interpolate: { nodeTypeName },
// }),
// type: 'error',
// });
// return;
// }
//
// if (
// nodeTypeData.maxNodes !== undefined &&
// this.workflowHelpers.getNodeTypeCount(nodeTypeName) >= nodeTypeData.maxNodes
// ) {
// this.showMaxNodeTypeError(nodeTypeData);
// return;
// }
//
// const newNodeData = await this.getNewNodeWithDefaultCredential(nodeTypeData, {
// name: options.name,
// });
//
// // when pulling new connection from node or injecting into a connection
// const lastSelectedNode = this.lastSelectedNode;
//
// if (options.position) {
// newNodeData.position = NodeViewUtils.getNewNodePosition(
// this.canvasStore.getNodesWithPlaceholderNode(),
// options.position,
// );
// } else if (lastSelectedNode) {
// const lastSelectedConnection = this.canvasStore.lastSelectedConnection;
// if (lastSelectedConnection) {
// // set when injecting into a connection
// const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection);
// if (diffX <= NodeViewUtils.MAX_X_TO_PUSH_DOWNSTREAM_NODES) {
// this.pushDownstreamNodes(
// lastSelectedNode.name,
// NodeViewUtils.PUSH_NODES_OFFSET,
// trackHistory,
// );
// }
// }
//
// // set when pulling connections
// if (this.canvasStore.newNodeInsertPosition) {
// newNodeData.position = NodeViewUtils.getNewNodePosition(this.nodes, [
// this.canvasStore.newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE,
// this.canvasStore.newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2,
// ]);
// this.canvasStore.newNodeInsertPosition = null;
// } else {
// let yOffset = 0;
// const workflow = this.workflowHelpers.getCurrentWorkflow();
//
// if (lastSelectedConnection) {
// const sourceNodeType = this.nodeTypesStore.getNodeType(
// lastSelectedNode.type,
// lastSelectedNode.typeVersion,
// );
//
// if (sourceNodeType) {
// const offsets = [
// [-100, 100],
// [-140, 0, 140],
// [-240, -100, 100, 240],
// ];
//
// const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
// workflow,
// lastSelectedNode,
// sourceNodeType,
// );
// const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs);
//
// const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter(
// (output) => output === NodeConnectionType.Main,
// );
//
// if (sourceNodeOutputMainOutputs.length > 1) {
// const offset = offsets[sourceNodeOutputMainOutputs.length - 2];
// const sourceOutputIndex = lastSelectedConnection.__meta
// ? lastSelectedConnection.__meta.sourceOutputIndex
// : 0;
// yOffset = offset[sourceOutputIndex];
// }
// }
// }
//
// let outputs: Array<ConnectionTypes | INodeOutputConfiguration> = [];
// try {
// // It fails when the outputs are an expression. As those nodes have
// // normally no outputs by default and the only reason we need the
// // outputs here is to calculate the position, it is fine to assume
// // that they have no outputs and are so treated as a regular node
// // with only "main" outputs.
// outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeData);
// } catch (e) {}
// const outputTypes = NodeHelpers.getConnectionTypes(outputs);
// const lastSelectedNodeType = this.nodeTypesStore.getNodeType(
// lastSelectedNode.type,
// lastSelectedNode.typeVersion,
// );
//
// // If node has only scoped outputs, position it below the last selected node
// if (
// outputTypes.length > 0 &&
// outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
// ) {
// const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name);
// const lastSelectedInputs = NodeHelpers.getNodeInputs(
// workflow,
// lastSelectedNodeWorkflow,
// lastSelectedNodeType,
// );
// const lastSelectedInputTypes = NodeHelpers.getConnectionTypes(lastSelectedInputs);
//
// const scopedConnectionIndex = (lastSelectedInputTypes || [])
// .filter((input) => input !== NodeConnectionType.Main)
// .findIndex((inputType) => outputs[0] === inputType);
//
// newNodeData.position = NodeViewUtils.getNewNodePosition(
// this.nodes,
// [
// lastSelectedNode.position[0] +
// (NodeViewUtils.NODE_SIZE /
// (Math.max(lastSelectedNodeType?.inputs?.length ?? 1), 1)) *
// scopedConnectionIndex,
// lastSelectedNode.position[1] + NodeViewUtils.PUSH_NODES_OFFSET,
// ],
// [100, 0],
// );
// } else {
// // Has only main outputs or no outputs at all
// const inputs = NodeHelpers.getNodeInputs(
// workflow,
// lastSelectedNode,
// lastSelectedNodeType,
// );
// const inputsTypes = NodeHelpers.getConnectionTypes(inputs);
//
// let pushOffset = NodeViewUtils.PUSH_NODES_OFFSET;
// if (!!inputsTypes.find((input) => input !== NodeConnectionType.Main)) {
// // If the node has scoped inputs, push it down a bit more
// pushOffset += 150;
// }
//
// // If a node is active then add the new node directly after the current one
// newNodeData.position = NodeViewUtils.getNewNodePosition(
// this.nodes,
// [lastSelectedNode.position[0] + pushOffset, lastSelectedNode.position[1] + yOffset],
// [100, 0],
// );
// }
// }
// } else {
// // If added node is a trigger and it's the first one added to the canvas
// // we place it at canvasAddButtonPosition to replace the canvas add button
// const position =
// this.nodeTypesStore.isTriggerNode(nodeTypeName) && !this.containsTrigger
// ? this.canvasStore.canvasAddButtonPosition
// : // If no node is active find a free spot
// (this.lastClickPosition as XYPosition);
//
// newNodeData.position = NodeViewUtils.getNewNodePosition(this.nodes, position);
// }
//
// const localizedName = this.locale.localizeNodeName(newNodeData.name, newNodeData.type);
//
// newNodeData.name = this.uniqueNodeName(localizedName);
//
// if (nodeTypeData.webhooks?.length) {
// newNodeData.webhookId = uuid();
// }
//
// await this.addNodes([newNodeData], undefined, trackHistory);
// this.workflowsStore.setNodePristine(newNodeData.name, true);
//
// this.uiStore.stateIsDirty = true;
//
// if (nodeTypeName === STICKY_NODE_TYPE) {
// this.$telemetry.trackNodesPanel('nodeView.addSticky', {
// workflow_id: this.workflowsStore.workflowId,
// });
// } else {
// void this.externalHooks.run('nodeView.addNodeButton', { nodeTypeName });
// useSegment().trackAddedTrigger(nodeTypeName);
// const trackProperties: ITelemetryTrackProperties = {
// node_type: nodeTypeName,
// node_version: newNodeData.typeVersion,
// is_auto_add: isAutoAdd,
// workflow_id: this.workflowsStore.workflowId,
// drag_and_drop: options.dragAndDrop,
// };
//
// if (lastSelectedNode) {
// trackProperties.input_node_type = lastSelectedNode.type;
// }
//
// this.$telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
// }
//
// // Automatically deselect all nodes and select the current one and also active
// // current node. But only if it's added manually by the user (not by undo/redo mechanism)
// if (trackHistory) {
// this.deselectAllNodes();
// setTimeout(() => {
// this.nodeSelectedByName(
// newNodeData.name,
// showDetail && nodeTypeName !== STICKY_NODE_TYPE,
// );
// });
// }
//
// return newNodeData;
}
function checkIfEditingIsAllowed(): boolean {
if (readOnlyNotification.value?.visible) {
return false;
}
if (isReadOnlyRoute.value || isReadOnlyEnvironment.value) {
const messageContext = isReadOnlyRoute.value ? 'executions' : 'workflows';
readOnlyNotification.value = toast.showMessage({
title: i18n.baseText(
isReadOnlyEnvironment.value
? `readOnlyEnv.showMessage.${messageContext}.title`
: 'readOnly.showMessage.executions.title',
),
message: i18n.baseText(
isReadOnlyEnvironment.value
? `readOnlyEnv.showMessage.${messageContext}.message`
: 'readOnly.showMessage.executions.message',
),
type: 'info',
dangerouslyUseHTMLString: true,
}) as unknown as { visible: boolean };
return false;
}
return true;
}
</script>
<template>
<WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject"
:workflow="editableWorkflow"
:workflow-object="editableWorkflowObject"
@update:node:position="onNodePositionUpdate"
@create:connection="onCreateNodeConnection"
>
<div :class="$style.executionButtons">
<CanvasExecuteWorkflowButton @click="onRunWorkflow" />
</div>
<Suspense>
<NodeCreation
v-if="!isReadOnlyRoute && !isReadOnlyEnvironment"
:create-node-active="uiStore.isCreateNodeActive"
:node-view-scale="1"
@toggle-node-creator="onToggleNodeCreator"
@add-nodes="onAddNodes"
/>
</Suspense>
</WorkflowCanvas>
</template>
<style lang="scss" module>
.executionButtons {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-l);
width: auto;
@media (max-width: $breakpoint-2xs) {
bottom: 150px;
}
button {
display: flex;
justify-content: center;
align-items: center;
margin-left: 0.625rem;
&:first-child {
margin: 0;
}
}
}
</style>

View file

@ -1143,6 +1143,21 @@ importers:
'@n8n/permissions':
specifier: workspace:*
version: link:../@n8n/permissions
'@vue-flow/background':
specifier: ^1.3.0
version: 1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21)
'@vue-flow/controls':
specifier: ^1.1.1
version: 1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21)
'@vue-flow/core':
specifier: ^1.33.5
version: 1.33.5(vue@3.4.21)
'@vue-flow/minimap':
specifier: ^1.4.0
version: 1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21)
'@vue-flow/node-toolbar':
specifier: ^1.1.0
version: 1.1.0(@vue-flow/core@1.33.5)
'@vueuse/components':
specifier: ^10.5.0
version: 10.5.0(vue@3.4.21)
@ -10593,6 +10608,60 @@ packages:
path-browserify: 1.0.1
dev: true
/@vue-flow/background@1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21):
resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
dependencies:
'@vue-flow/core': 1.33.5(vue@3.4.21)
vue: 3.4.21(typescript@5.4.2)
dev: false
/@vue-flow/controls@1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21):
resolution: {integrity: sha512-TCoRD5aYZQsM/N7QlPJcIILg1Gxm0O/zoUikxaeadcom1OlKFHutY72agsySJEWM6fTlyb7w8DYCbB4T8YbFoQ==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
dependencies:
'@vue-flow/core': 1.33.5(vue@3.4.21)
vue: 3.4.21(typescript@5.4.2)
dev: false
/@vue-flow/core@1.33.5(vue@3.4.21):
resolution: {integrity: sha512-Obo+KHmcww/NYGARMqVH1dhd42QeFzV+TNwytrjVgYCoMVCNjs/blCh437TYTsNy4vgX1NKpNwTbQrS+keurgA==}
peerDependencies:
vue: ^3.3.0
dependencies:
'@vueuse/core': 10.5.0(vue@3.4.21)
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.4.21(typescript@5.4.2)
transitivePeerDependencies:
- '@vue/composition-api'
dev: false
/@vue-flow/minimap@1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21):
resolution: {integrity: sha512-GetmN8uOQxIx4ja85VDnIwpZO5SnGIOfYSqUw8MRMbc8CdACun2M2e3FRaMZRaWapclU2ssXms4xHMWrA3YWpw==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
dependencies:
'@vue-flow/core': 1.33.5(vue@3.4.21)
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.4.21(typescript@5.4.2)
dev: false
/@vue-flow/node-toolbar@1.1.0(@vue-flow/core@1.33.5):
resolution: {integrity: sha512-6RVDHgY+x8m1cXPaEkqPa/RMR90AC1hPHYBK/QVh8k6lJnFPgwJ9PSiYoC4amsUiDK0mF0Py+PlztLJY1ty+4A==}
peerDependencies:
'@vue-flow/core': ^1.12.2
dependencies:
'@vue-flow/core': 1.33.5(vue@3.4.21)
dev: false
/@vue/compiler-core@3.4.21:
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
dependencies:
@ -13086,6 +13155,24 @@ packages:
yauzl: 2.10.0
dev: true
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
dev: false
/d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
dev: false
/d3-dsv@2.0.0:
resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==}
hasBin: true
@ -13095,6 +13182,53 @@ packages:
rw: 1.3.3
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/d3-transition@3.0.1(d3-selection@3.0.0):
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
dev: false
/d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dev: false
/d@1.0.1:
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
dependencies: