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/codemirror-lang-sql": "^1.0.2",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@vue-flow/background": "^1.3.0",
|
||||
"@vue-flow/controls": "^1.1.1",
|
||||
"@vue-flow/core": "^1.33.5",
|
||||
"@vue-flow/minimap": "^1.4.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.0",
|
||||
"@vueuse/components": "^10.5.0",
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"axios": "1.6.7",
|
||||
|
|
|
@ -1317,6 +1317,7 @@ export interface UIState {
|
|||
pendingNotificationsForViews: {
|
||||
[key in VIEWS]?: NotificationOptions[];
|
||||
};
|
||||
isCreateNodeActive: boolean;
|
||||
}
|
||||
|
||||
export type IFakeDoor = {
|
||||
|
@ -1898,7 +1899,7 @@ export type AddedNodesAndConnections = {
|
|||
export type ToggleNodeCreatorOptions = {
|
||||
createNodeActive: boolean;
|
||||
source?: NodeCreatorOpenSource;
|
||||
nodeCreatorView?: string;
|
||||
nodeCreatorView?: NodeFilterType;
|
||||
};
|
||||
|
||||
export type AppliedThemeOption = 'light' | 'dark';
|
||||
|
|
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 { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import {
|
||||
AGENT_NODE_TYPE,
|
||||
SET_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
|
||||
import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json';
|
||||
|
||||
|
@ -16,6 +21,12 @@ export const testingNodeTypes: INodeTypeData = {
|
|||
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
|
||||
},
|
||||
},
|
||||
[SET_NODE_TYPE]: {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
description: findNodeWithName(SET_NODE_TYPE),
|
||||
},
|
||||
},
|
||||
[CHAT_TRIGGER_NODE_TYPE]: {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
|
@ -32,6 +43,7 @@ export const testingNodeTypes: INodeTypeData = {
|
|||
|
||||
export const defaultMockNodeTypes: INodeTypeData = {
|
||||
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
|
||||
[SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE],
|
||||
};
|
||||
|
||||
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
|
||||
|
|
|
@ -17,22 +17,21 @@ import type { ProjectSharingData } from '@/features/projects/projects.types';
|
|||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
||||
const getResolvedKey = (key: string) => {
|
||||
const resolvedKeyParts = key.split(/[\/.]/);
|
||||
return resolvedKeyParts[resolvedKeyParts.length - 1];
|
||||
};
|
||||
|
||||
const nodeTypes = {
|
||||
...defaultMockNodeTypes,
|
||||
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
|
||||
acc[getResolvedKey(key)] = data[key];
|
||||
acc[key] = data[key];
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
function getKnownTypes(): IDataObject {
|
||||
return {};
|
||||
}
|
||||
|
||||
function getByName(nodeType: string): INodeType | IVersionedNodeType {
|
||||
return nodeTypes[getResolvedKey(nodeType)].type;
|
||||
return nodeTypes[nodeType].type;
|
||||
}
|
||||
|
||||
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||
|
@ -40,6 +39,7 @@ export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
|
|||
}
|
||||
|
||||
return {
|
||||
getKnownTypes,
|
||||
getByName,
|
||||
getByNameAndVersion,
|
||||
};
|
||||
|
|
|
@ -19,7 +19,8 @@ export type RenderOptions = Parameters<typeof render>[1] & {
|
|||
const TelemetryPlugin: Plugin<{}> = {
|
||||
install(app) {
|
||||
app.config.globalProperties.$telemetry = {
|
||||
track(event: string, properties?: object) {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
track(..._: unknown[]) {},
|
||||
} as Telemetry;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -32,6 +32,7 @@ export const retry = async (
|
|||
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
||||
initialized: true,
|
||||
settings: defaultSettings,
|
||||
promptsData: {
|
||||
message: '',
|
||||
|
@ -62,14 +63,13 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
|||
loginLabel: '',
|
||||
loginEnabled: false,
|
||||
},
|
||||
mfa: {
|
||||
enabled: false,
|
||||
},
|
||||
onboardingCallPromptEnabled: false,
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
saveManualExecutions: false,
|
||||
initialized: false,
|
||||
mfa: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {
|
||||
|
|
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,
|
||||
} from './Interface';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { CanvasNodeHandleInjectionData, CanvasNodeInjectionData } from '@/types';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
|
||||
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
|
||||
|
@ -450,6 +452,7 @@ export const enum VIEWS {
|
|||
VARIABLES = 'VariablesView',
|
||||
NEW_WORKFLOW = 'NodeViewNew',
|
||||
WORKFLOW = 'NodeViewExisting',
|
||||
WORKFLOW_V2 = 'NodeViewV2',
|
||||
DEMO = 'WorkflowDemo',
|
||||
TEMPLATE_IMPORT = 'WorkflowTemplate',
|
||||
WORKFLOW_ONBOARDING = 'WorkflowOnboarding',
|
||||
|
@ -483,7 +486,12 @@ export const enum VIEWS {
|
|||
PROJECT_SETTINGS = 'ProjectSettings',
|
||||
}
|
||||
|
||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
export const EDITABLE_CANVAS_VIEWS = [
|
||||
VIEWS.WORKFLOW,
|
||||
VIEWS.NEW_WORKFLOW,
|
||||
VIEWS.EXECUTION_DEBUG,
|
||||
VIEWS.WORKFLOW_V2,
|
||||
];
|
||||
|
||||
export const enum FAKE_DOOR_FEATURES {
|
||||
ENVIRONMENTS = 'environments',
|
||||
|
@ -611,6 +619,7 @@ export const enum STORES {
|
|||
UI = 'ui',
|
||||
USERS = 'users',
|
||||
WORKFLOWS = 'workflows',
|
||||
WORKFLOWS_V2 = 'workflowsV2',
|
||||
WORKFLOWS_EE = 'workflowsEE',
|
||||
EXECUTIONS = 'executions',
|
||||
NDV = 'ndv',
|
||||
|
@ -828,3 +837,11 @@ export const AI_ASSISTANT_EXPERIMENT_URLS = {
|
|||
};
|
||||
|
||||
export const AI_ASSISTANT_LOCAL_STORAGE_KEY = 'N8N_AI_ASSISTANT_EXPERIMENT';
|
||||
|
||||
/**
|
||||
* Injection Keys
|
||||
*/
|
||||
|
||||
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
|
||||
export const CanvasNodeHandleKey =
|
||||
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { createApp } from 'vue';
|
||||
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
import '@vue-flow/core/dist/theme-default.css';
|
||||
import '@vue-flow/controls/dist/style.css';
|
||||
import '@vue-flow/minimap/dist/style.css';
|
||||
|
||||
import 'vue-json-pretty/lib/styles.css';
|
||||
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';
|
||||
import 'n8n-design-system/css/index.scss';
|
||||
|
|
|
@ -10,12 +10,12 @@ import { WorkflowDataProxy } from 'n8n-workflow';
|
|||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
|
||||
const nodeTypes: INodeTypeData = {
|
||||
'test.set': {
|
||||
'n8n-nodes-base.set': {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
description: {
|
||||
displayName: 'Set',
|
||||
name: 'set',
|
||||
name: 'n8n-nodes-base.set',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Sets a value',
|
||||
|
@ -47,7 +47,7 @@ const nodeTypes: INodeTypeData = {
|
|||
const nodes: INode[] = [
|
||||
{
|
||||
name: 'Start',
|
||||
type: 'test.set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
id: 'uuid-1',
|
||||
|
@ -55,7 +55,7 @@ const nodes: INode[] = [
|
|||
},
|
||||
{
|
||||
name: 'Function',
|
||||
type: 'test.set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
parameters: {
|
||||
functionCode:
|
||||
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
|
||||
|
@ -66,7 +66,7 @@ const nodes: INode[] = [
|
|||
},
|
||||
{
|
||||
name: 'Rename',
|
||||
type: 'test.set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
parameters: {
|
||||
value1: 'data',
|
||||
value2: 'initialName',
|
||||
|
@ -77,7 +77,7 @@ const nodes: INode[] = [
|
|||
},
|
||||
{
|
||||
name: 'End',
|
||||
type: 'test.set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
id: 'uuid-4',
|
||||
|
|
|
@ -25,6 +25,7 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
|||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const NodeView = async () => await import('@/views/NodeView.vue');
|
||||
const NodeViewV2 = async () => await import('@/views/NodeView.v2.vue');
|
||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||
const WorkflowExecutionsLandingPage = async () =>
|
||||
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
||||
|
@ -357,6 +358,23 @@ export const routes = [
|
|||
path: '/workflow',
|
||||
redirect: '/workflow/new',
|
||||
},
|
||||
{
|
||||
path: '/workflow-v2/:workflowId',
|
||||
name: VIEWS.WORKFLOW_V2,
|
||||
components: {
|
||||
default: NodeViewV2,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
return !!localStorage.getItem('features.NodeViewV2');
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/signin',
|
||||
name: VIEWS.SIGNIN,
|
||||
|
|
|
@ -195,6 +195,7 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
// This enables us to set a queue of notifications form outside (another component)
|
||||
// and then show them when the view is initialized
|
||||
pendingNotificationsForViews: {},
|
||||
isCreateNodeActive: false,
|
||||
}),
|
||||
getters: {
|
||||
appliedTheme(): AppliedThemeOption {
|
||||
|
|
|
@ -305,6 +305,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
return workflow.value.nodes.map((node) => ({ ...node }));
|
||||
}
|
||||
|
||||
function setNodePosition(id: string, position: INodeUi['position']): void {
|
||||
const node = workflow.value.nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
|
||||
setNodeValue({ name: node.name, key: 'position', value: position });
|
||||
}
|
||||
|
||||
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
|
||||
const nodeTypes = getNodeTypes();
|
||||
let cachedWorkflowId: string | undefined = workflowId.value;
|
||||
|
@ -1588,5 +1595,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
resetChatMessages,
|
||||
appendChatMessage,
|
||||
checkIfNodeHasChatParent,
|
||||
setNodePosition,
|
||||
};
|
||||
});
|
||||
|
|
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 './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':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/permissions
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21)
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21)
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.33.5
|
||||
version: 1.33.5(vue@3.4.21)
|
||||
'@vue-flow/minimap':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21)
|
||||
'@vue-flow/node-toolbar':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@vue-flow/core@1.33.5)
|
||||
'@vueuse/components':
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(vue@3.4.21)
|
||||
|
@ -10593,6 +10608,60 @@ packages:
|
|||
path-browserify: 1.0.1
|
||||
dev: true
|
||||
|
||||
/@vue-flow/background@1.3.0(@vue-flow/core@1.33.5)(vue@3.4.21):
|
||||
resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.33.5(vue@3.4.21)
|
||||
vue: 3.4.21(typescript@5.4.2)
|
||||
dev: false
|
||||
|
||||
/@vue-flow/controls@1.1.1(@vue-flow/core@1.33.5)(vue@3.4.21):
|
||||
resolution: {integrity: sha512-TCoRD5aYZQsM/N7QlPJcIILg1Gxm0O/zoUikxaeadcom1OlKFHutY72agsySJEWM6fTlyb7w8DYCbB4T8YbFoQ==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.33.5(vue@3.4.21)
|
||||
vue: 3.4.21(typescript@5.4.2)
|
||||
dev: false
|
||||
|
||||
/@vue-flow/core@1.33.5(vue@3.4.21):
|
||||
resolution: {integrity: sha512-Obo+KHmcww/NYGARMqVH1dhd42QeFzV+TNwytrjVgYCoMVCNjs/blCh437TYTsNy4vgX1NKpNwTbQrS+keurgA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
dependencies:
|
||||
'@vueuse/core': 10.5.0(vue@3.4.21)
|
||||
d3-drag: 3.0.0
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.4.21(typescript@5.4.2)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
dev: false
|
||||
|
||||
/@vue-flow/minimap@1.4.0(@vue-flow/core@1.33.5)(vue@3.4.21):
|
||||
resolution: {integrity: sha512-GetmN8uOQxIx4ja85VDnIwpZO5SnGIOfYSqUw8MRMbc8CdACun2M2e3FRaMZRaWapclU2ssXms4xHMWrA3YWpw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.33.5(vue@3.4.21)
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.4.21(typescript@5.4.2)
|
||||
dev: false
|
||||
|
||||
/@vue-flow/node-toolbar@1.1.0(@vue-flow/core@1.33.5):
|
||||
resolution: {integrity: sha512-6RVDHgY+x8m1cXPaEkqPa/RMR90AC1hPHYBK/QVh8k6lJnFPgwJ9PSiYoC4amsUiDK0mF0Py+PlztLJY1ty+4A==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.12.2
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.33.5(vue@3.4.21)
|
||||
dev: false
|
||||
|
||||
/@vue/compiler-core@3.4.21:
|
||||
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
|
||||
dependencies:
|
||||
|
@ -13086,6 +13155,24 @@ packages:
|
|||
yauzl: 2.10.0
|
||||
dev: true
|
||||
|
||||
/d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
dev: false
|
||||
|
||||
/d3-dsv@2.0.0:
|
||||
resolution: {integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==}
|
||||
hasBin: true
|
||||
|
@ -13095,6 +13182,53 @@ packages:
|
|||
rw: 1.3.3
|
||||
dev: false
|
||||
|
||||
/d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
dev: false
|
||||
|
||||
/d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
dev: false
|
||||
|
||||
/d@1.0.1:
|
||||
resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue