mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
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:
parent
8566301731
commit
70948ec71b
|
@ -45,6 +45,11 @@
|
||||||
"@n8n/chat": "workspace:*",
|
"@n8n/chat": "workspace:*",
|
||||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||||
"@n8n/permissions": "workspace:*",
|
"@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/components": "^10.5.0",
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.5.0",
|
||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
|
|
|
@ -1317,6 +1317,7 @@ export interface UIState {
|
||||||
pendingNotificationsForViews: {
|
pendingNotificationsForViews: {
|
||||||
[key in VIEWS]?: NotificationOptions[];
|
[key in VIEWS]?: NotificationOptions[];
|
||||||
};
|
};
|
||||||
|
isCreateNodeActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IFakeDoor = {
|
export type IFakeDoor = {
|
||||||
|
@ -1898,7 +1899,7 @@ export type AddedNodesAndConnections = {
|
||||||
export type ToggleNodeCreatorOptions = {
|
export type ToggleNodeCreatorOptions = {
|
||||||
createNodeActive: boolean;
|
createNodeActive: boolean;
|
||||||
source?: NodeCreatorOpenSource;
|
source?: NodeCreatorOpenSource;
|
||||||
nodeCreatorView?: string;
|
nodeCreatorView?: NodeFilterType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppliedThemeOption = 'light' | 'dark';
|
export type AppliedThemeOption = 'light' | 'dark';
|
||||||
|
|
87
packages/editor-ui/src/__tests__/data/canvas.ts
Normal file
87
packages/editor-ui/src/__tests__/data/canvas.ts
Normal 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}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
1
packages/editor-ui/src/__tests__/data/index.ts
Normal file
1
packages/editor-ui/src/__tests__/data/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './canvas';
|
|
@ -1,5 +1,10 @@
|
||||||
import type { INodeTypeData, INodeTypeDescription, IN8nUISettings } from 'n8n-workflow';
|
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 nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
|
||||||
import aiNodeTypesJson from '../../../@n8n/nodes-langchain/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),
|
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[SET_NODE_TYPE]: {
|
||||||
|
sourcePath: '',
|
||||||
|
type: {
|
||||||
|
description: findNodeWithName(SET_NODE_TYPE),
|
||||||
|
},
|
||||||
|
},
|
||||||
[CHAT_TRIGGER_NODE_TYPE]: {
|
[CHAT_TRIGGER_NODE_TYPE]: {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
|
@ -32,6 +43,7 @@ export const testingNodeTypes: INodeTypeData = {
|
||||||
|
|
||||||
export const defaultMockNodeTypes: INodeTypeData = {
|
export const defaultMockNodeTypes: INodeTypeData = {
|
||||||
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
|
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
|
||||||
|
[SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
|
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
|
||||||
|
|
|
@ -17,22 +17,21 @@ import type { ProjectSharingData } from '@/features/projects/projects.types';
|
||||||
import type { RouteLocationNormalized } from 'vue-router';
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
|
|
||||||
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
||||||
const getResolvedKey = (key: string) => {
|
|
||||||
const resolvedKeyParts = key.split(/[\/.]/);
|
|
||||||
return resolvedKeyParts[resolvedKeyParts.length - 1];
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
...defaultMockNodeTypes,
|
...defaultMockNodeTypes,
|
||||||
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
|
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
|
||||||
acc[getResolvedKey(key)] = data[key];
|
acc[key] = data[key];
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getKnownTypes(): IDataObject {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function getByName(nodeType: string): INodeType | IVersionedNodeType {
|
function getByName(nodeType: string): INodeType | IVersionedNodeType {
|
||||||
return nodeTypes[getResolvedKey(nodeType)].type;
|
return nodeTypes[nodeType].type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||||
|
@ -40,6 +39,7 @@ export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getKnownTypes,
|
||||||
getByName,
|
getByName,
|
||||||
getByNameAndVersion,
|
getByNameAndVersion,
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,8 @@ export type RenderOptions = Parameters<typeof render>[1] & {
|
||||||
const TelemetryPlugin: Plugin<{}> = {
|
const TelemetryPlugin: Plugin<{}> = {
|
||||||
install(app) {
|
install(app) {
|
||||||
app.config.globalProperties.$telemetry = {
|
app.config.globalProperties.$telemetry = {
|
||||||
track(event: string, properties?: object) {},
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
track(..._: unknown[]) {},
|
||||||
} as Telemetry;
|
} as Telemetry;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,6 +32,7 @@ export const retry = async (
|
||||||
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
|
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
|
||||||
|
|
||||||
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
||||||
|
initialized: true,
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
promptsData: {
|
promptsData: {
|
||||||
message: '',
|
message: '',
|
||||||
|
@ -62,14 +63,13 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
||||||
loginLabel: '',
|
loginLabel: '',
|
||||||
loginEnabled: false,
|
loginEnabled: false,
|
||||||
},
|
},
|
||||||
|
mfa: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
onboardingCallPromptEnabled: false,
|
onboardingCallPromptEnabled: false,
|
||||||
saveDataErrorExecution: 'all',
|
saveDataErrorExecution: 'all',
|
||||||
saveDataSuccessExecution: 'all',
|
saveDataSuccessExecution: 'all',
|
||||||
saveManualExecutions: false,
|
saveManualExecutions: false,
|
||||||
initialized: false,
|
|
||||||
mfa: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {
|
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {
|
||||||
|
|
112
packages/editor-ui/src/components/canvas/Canvas.spec.ts
Normal file
112
packages/editor-ui/src/components/canvas/Canvas.spec.ts
Normal 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 }]]);
|
||||||
|
});
|
||||||
|
});
|
117
packages/editor-ui/src/components/canvas/Canvas.vue
Normal file
117
packages/editor-ui/src/components/canvas/Canvas.vue
Normal 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>
|
72
packages/editor-ui/src/components/canvas/WorkflowCanvas.vue
Normal file
72
packages/editor-ui/src/components/canvas/WorkflowCanvas.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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(--color-foreground-xdark)" />-->
|
||||||
|
<!-- <rect-->
|
||||||
|
<!-- x="46"-->
|
||||||
|
<!-- y="2"-->
|
||||||
|
<!-- width="20"-->
|
||||||
|
<!-- height="20"-->
|
||||||
|
<!-- stroke="var(--color-foreground-xdark)"-->
|
||||||
|
<!-- stroke-width="2"-->
|
||||||
|
<!-- rx="4"-->
|
||||||
|
<!-- fill="#ffffff"-->
|
||||||
|
<!-- />-->
|
||||||
|
<!-- <g transform="translate(44, 0)">-->
|
||||||
|
<!-- <path-->
|
||||||
|
<!-- fill="var(--color-foreground-xdark)"-->
|
||||||
|
<!-- d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"-->
|
||||||
|
<!-- ></path>-->
|
||||||
|
<!-- </g>-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
</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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
213
packages/editor-ui/src/composables/useCanvasMapping.spec.ts
Normal file
213
packages/editor-ui/src/composables/useCanvasMapping.spec.ts
Normal 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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
150
packages/editor-ui/src/composables/useCanvasMapping.ts
Normal file
150
packages/editor-ui/src/composables/useCanvasMapping.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
47
packages/editor-ui/src/composables/useNodeConnections.ts
Normal file
47
packages/editor-ui/src/composables/useNodeConnections.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -4,6 +4,8 @@ import type {
|
||||||
NodeCreatorOpenSource,
|
NodeCreatorOpenSource,
|
||||||
} from './Interface';
|
} from './Interface';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
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_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
|
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',
|
VARIABLES = 'VariablesView',
|
||||||
NEW_WORKFLOW = 'NodeViewNew',
|
NEW_WORKFLOW = 'NodeViewNew',
|
||||||
WORKFLOW = 'NodeViewExisting',
|
WORKFLOW = 'NodeViewExisting',
|
||||||
|
WORKFLOW_V2 = 'NodeViewV2',
|
||||||
DEMO = 'WorkflowDemo',
|
DEMO = 'WorkflowDemo',
|
||||||
TEMPLATE_IMPORT = 'WorkflowTemplate',
|
TEMPLATE_IMPORT = 'WorkflowTemplate',
|
||||||
WORKFLOW_ONBOARDING = 'WorkflowOnboarding',
|
WORKFLOW_ONBOARDING = 'WorkflowOnboarding',
|
||||||
|
@ -483,7 +486,12 @@ export const enum VIEWS {
|
||||||
PROJECT_SETTINGS = 'ProjectSettings',
|
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 {
|
export const enum FAKE_DOOR_FEATURES {
|
||||||
ENVIRONMENTS = 'environments',
|
ENVIRONMENTS = 'environments',
|
||||||
|
@ -611,6 +619,7 @@ export const enum STORES {
|
||||||
UI = 'ui',
|
UI = 'ui',
|
||||||
USERS = 'users',
|
USERS = 'users',
|
||||||
WORKFLOWS = 'workflows',
|
WORKFLOWS = 'workflows',
|
||||||
|
WORKFLOWS_V2 = 'workflowsV2',
|
||||||
WORKFLOWS_EE = 'workflowsEE',
|
WORKFLOWS_EE = 'workflowsEE',
|
||||||
EXECUTIONS = 'executions',
|
EXECUTIONS = 'executions',
|
||||||
NDV = 'ndv',
|
NDV = 'ndv',
|
||||||
|
@ -828,3 +837,11 @@ export const AI_ASSISTANT_EXPERIMENT_URLS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AI_ASSISTANT_LOCAL_STORAGE_KEY = 'N8N_AI_ASSISTANT_EXPERIMENT';
|
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>;
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import { createApp } from 'vue';
|
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 'vue-json-pretty/lib/styles.css';
|
||||||
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
|
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
|
||||||
import 'n8n-design-system/css/index.scss';
|
import 'n8n-design-system/css/index.scss';
|
||||||
|
|
|
@ -10,12 +10,12 @@ import { WorkflowDataProxy } from 'n8n-workflow';
|
||||||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
|
||||||
const nodeTypes: INodeTypeData = {
|
const nodeTypes: INodeTypeData = {
|
||||||
'test.set': {
|
'n8n-nodes-base.set': {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
description: {
|
description: {
|
||||||
displayName: 'Set',
|
displayName: 'Set',
|
||||||
name: 'set',
|
name: 'n8n-nodes-base.set',
|
||||||
group: ['input'],
|
group: ['input'],
|
||||||
version: 1,
|
version: 1,
|
||||||
description: 'Sets a value',
|
description: 'Sets a value',
|
||||||
|
@ -47,7 +47,7 @@ const nodeTypes: INodeTypeData = {
|
||||||
const nodes: INode[] = [
|
const nodes: INode[] = [
|
||||||
{
|
{
|
||||||
name: 'Start',
|
name: 'Start',
|
||||||
type: 'test.set',
|
type: 'n8n-nodes-base.set',
|
||||||
parameters: {},
|
parameters: {},
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
id: 'uuid-1',
|
id: 'uuid-1',
|
||||||
|
@ -55,7 +55,7 @@ const nodes: INode[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Function',
|
name: 'Function',
|
||||||
type: 'test.set',
|
type: 'n8n-nodes-base.set',
|
||||||
parameters: {
|
parameters: {
|
||||||
functionCode:
|
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}));',
|
'// 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',
|
name: 'Rename',
|
||||||
type: 'test.set',
|
type: 'n8n-nodes-base.set',
|
||||||
parameters: {
|
parameters: {
|
||||||
value1: 'data',
|
value1: 'data',
|
||||||
value2: 'initialName',
|
value2: 'initialName',
|
||||||
|
@ -77,7 +77,7 @@ const nodes: INode[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'End',
|
name: 'End',
|
||||||
type: 'test.set',
|
type: 'n8n-nodes-base.set',
|
||||||
parameters: {},
|
parameters: {},
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
id: 'uuid-4',
|
id: 'uuid-4',
|
||||||
|
|
|
@ -25,6 +25,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const NodeView = async () => await import('@/views/NodeView.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 WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
||||||
|
@ -357,6 +358,23 @@ export const routes = [
|
||||||
path: '/workflow',
|
path: '/workflow',
|
||||||
redirect: '/workflow/new',
|
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',
|
path: '/signin',
|
||||||
name: VIEWS.SIGNIN,
|
name: VIEWS.SIGNIN,
|
||||||
|
|
|
@ -195,6 +195,7 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
// This enables us to set a queue of notifications form outside (another component)
|
// This enables us to set a queue of notifications form outside (another component)
|
||||||
// and then show them when the view is initialized
|
// and then show them when the view is initialized
|
||||||
pendingNotificationsForViews: {},
|
pendingNotificationsForViews: {},
|
||||||
|
isCreateNodeActive: false,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
appliedTheme(): AppliedThemeOption {
|
appliedTheme(): AppliedThemeOption {
|
||||||
|
|
|
@ -305,6 +305,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
return workflow.value.nodes.map((node) => ({ ...node }));
|
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 {
|
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
|
||||||
const nodeTypes = getNodeTypes();
|
const nodeTypes = getNodeTypes();
|
||||||
let cachedWorkflowId: string | undefined = workflowId.value;
|
let cachedWorkflowId: string | undefined = workflowId.value;
|
||||||
|
@ -1588,5 +1595,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
resetChatMessages,
|
resetChatMessages,
|
||||||
appendChatMessage,
|
appendChatMessage,
|
||||||
checkIfNodeHasChatParent,
|
checkIfNodeHasChatParent,
|
||||||
|
setNodePosition,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
61
packages/editor-ui/src/types/canvas.ts
Normal file
61
packages/editor-ui/src/types/canvas.ts
Normal 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>;
|
||||||
|
}
|
|
@ -1,2 +1,3 @@
|
||||||
|
export * from './canvas';
|
||||||
export * from './externalHooks';
|
export * from './externalHooks';
|
||||||
export * from './pushConnection';
|
export * from './pushConnection';
|
||||||
|
|
530
packages/editor-ui/src/utils/canvasUtilsV2.spec.ts
Normal file
530
packages/editor-ui/src/utils/canvasUtilsV2.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
91
packages/editor-ui/src/utils/canvasUtilsV2.ts
Normal file
91
packages/editor-ui/src/utils/canvasUtilsV2.ts
Normal 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()}`;
|
||||||
|
}
|
971
packages/editor-ui/src/views/NodeView.v2.vue
Normal file
971
packages/editor-ui/src/views/NodeView.v2.vue
Normal 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>
|
134
pnpm-lock.yaml
134
pnpm-lock.yaml
|
@ -1143,6 +1143,21 @@ importers:
|
||||||
'@n8n/permissions':
|
'@n8n/permissions':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../@n8n/permissions
|
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':
|
'@vueuse/components':
|
||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0(vue@3.4.21)
|
version: 10.5.0(vue@3.4.21)
|
||||||
|
@ -10593,6 +10608,60 @@ packages:
|
||||||
path-browserify: 1.0.1
|
path-browserify: 1.0.1
|
||||||
dev: true
|
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:
|
/@vue/compiler-core@3.4.21:
|
||||||
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
|
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -13086,6 +13155,24 @@ packages:
|
||||||
yauzl: 2.10.0
|
yauzl: 2.10.0
|
||||||
dev: true
|
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:
|
/d3-dsv@2.0.0:
|
||||||
resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==}
|
resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
@ -13095,6 +13182,53 @@ packages:
|
||||||
rw: 1.3.3
|
rw: 1.3.3
|
||||||
dev: false
|
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:
|
/d@1.0.1:
|
||||||
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
|
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Reference in a new issue