1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-09-11 13:15:28 +03:00

refactor(editor): Fix NodeView/Canvas related TS errors (#9581)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg 2024-06-03 16:33:20 +02:00 committed by GitHub
parent 3298914bc4
commit 68420ca6be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 587 additions and 389 deletions

View File

@ -136,6 +136,8 @@ export type EndpointStyle = {
export type EndpointMeta = {
__meta?: {
nodeName: string;
nodeId: string;
index: number;
totalEndpoints: number;
endpointLabelLength: number;
@ -247,7 +249,7 @@ export interface IWorkflowData {
export interface IWorkflowDataUpdate {
id?: string;
name?: string;
nodes?: Array<INode | IWorkflowTemplateNode>;
nodes?: INode[];
connections?: IConnections;
settings?: IWorkflowSettings;
active?: boolean;
@ -268,7 +270,10 @@ export interface NewWorkflowResponse {
}
export interface IWorkflowTemplateNode
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
extends Pick<
INodeUi,
'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId' | 'id' | 'disabled'
> {
// The credentials in a template workflow have a different type than in a regular workflow
credentials?: IWorkflowTemplateNodeCredentials;
}
@ -1926,13 +1931,13 @@ export type NewConnectionInfo = {
index: number;
eventSource: NodeCreatorOpenSource;
connection?: Connection;
nodeCreatorView?: string;
nodeCreatorView?: NodeFilterType;
outputType?: NodeConnectionType;
endpointUuid?: string;
};
export type AIAssistantConnectionInfo = NewConnectionInfo & {
stepName: string;
stepName?: string;
};
export type EnterpriseEditionFeatureKey =

View File

@ -5,12 +5,11 @@ import ChatComponent from '@n8n/chat/components/Chat.vue';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { Ref } from 'vue';
import { computed, provide, ref } from 'vue';
import { computed, provide, ref, onMounted, onBeforeUnmount } from 'vue';
import QuickReplies from './QuickReplies.vue';
import { DateTime } from 'luxon';
import { useAIStore } from '@/stores/ai.store';
import { chatEventBus } from '@n8n/chat/event-buses';
import { onMounted } from 'vue';
import {
AI_ASSISTANT_EXPERIMENT_URLS,
AI_ASSISTANT_LOCAL_STORAGE_KEY,
@ -19,7 +18,6 @@ import {
import { useStorage } from '@/composables/useStorage';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { onBeforeUnmount } from 'vue';
const locale = useI18n();
const telemetry = useTelemetry();
@ -93,7 +91,7 @@ const thanksResponses: ChatMessage[] = [
];
const initialMessageText = computed(() => {
if (latestConnectionInfo.value) {
if (latestConnectionInfo.value?.stepName) {
return locale.baseText('aiAssistantChat.initialMessage.nextStep', {
interpolate: { currentAction: latestConnectionInfo.value.stepName },
});

View File

@ -54,7 +54,6 @@ import type {
} from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import type { MessageBoxInputData } from 'element-plus';
import type { BaseTextKey } from '../../plugins/i18n';
const props = defineProps<{
@ -385,7 +384,7 @@ async function handleFileImport(): Promise<void> {
}
}
async function onWorkflowMenuSelect(action: string): Promise<void> {
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({
@ -427,7 +426,7 @@ async function onWorkflowMenuSelect(action: string): Promise<void> {
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
try {
const promptResponse = (await message.prompt(
const promptResponse = await message.prompt(
locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
{
@ -436,9 +435,9 @@ async function onWorkflowMenuSelect(action: string): Promise<void> {
inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: /^http[s]?:\/\/.*\.json$/i,
},
)) as MessageBoxInputData;
);
if ((promptResponse as unknown as string) === 'cancel') {
if (promptResponse.action === 'cancel') {
return;
}

View File

@ -204,8 +204,8 @@ export default function useCanvasMouseSelect() {
uiStore.lastSelectedNode = null;
uiStore.lastSelectedNodeOutputIndex = null;
canvasStore.lastSelectedConnection = null;
canvasStore.newNodeInsertPosition = null;
canvasStore.setLastSelectedConnection(undefined);
}
const instance = computed(() => canvasStore.jsPlumbInstance);

View File

@ -70,7 +70,7 @@ export function useCanvasPanning(
/**
* Ends the panning process and removes the mousemove event listener
*/
function onMouseUp(_: MouseEvent) {
function onMouseUp() {
if (!uiStore.nodeViewMoveInProgress) {
// If it is not active return directly.
// Else normal node dragging will not work.
@ -89,7 +89,7 @@ export function useCanvasPanning(
* Handles the actual movement of the canvas during a mouse drag,
* updating the position based on the current mouse position
*/
function onMouseMove(e: MouseEvent) {
function onMouseMove(e: MouseEvent | TouchEvent) {
const element = unref(elementRef);
if (e.target && !(element === e.target || element?.contains(e.target as Node))) {
return;
@ -100,11 +100,11 @@ export function useCanvasPanning(
}
// Signal that moving canvas is active if middle button is pressed and mouse is moved
if (e.buttons === MOUSE_EVENT_BUTTONS.MIDDLE) {
if (e instanceof MouseEvent && e.buttons === MOUSE_EVENT_BUTTONS.MIDDLE) {
uiStore.nodeViewMoveInProgress = true;
}
if (e.buttons === MOUSE_EVENT_BUTTONS.NONE) {
if (e instanceof MouseEvent && e.buttons === MOUSE_EVENT_BUTTONS.NONE) {
// Mouse button is not pressed anymore so stop selection mode
// Happens normally when mouse leave the view pressed and then
// comes back unpressed.

View File

@ -1,12 +1,19 @@
import type { ElMessageBoxOptions } from 'element-plus';
import type { ElMessageBoxOptions, Action, MessageBoxInputData } from 'element-plus';
import { ElMessageBox as MessageBox } from 'element-plus';
export type MessageBoxConfirmResult = 'confirm' | 'cancel';
export function useMessage() {
const handleCancelOrClose = (e: unknown) => {
const handleCancelOrClose = (e: Action | Error): Action => {
if (e instanceof Error) throw e;
else return e;
return e;
};
const handleCancelOrClosePrompt = (e: Error | Action): MessageBoxInputData => {
if (e instanceof Error) throw e;
return { value: '', action: e };
};
async function alert(
@ -15,7 +22,7 @@ export function useMessage() {
config?: ElMessageBoxOptions,
) {
const resolvedConfig = {
...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})),
...(config ?? (typeof configOrTitle === 'object' ? configOrTitle : {})),
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
};
@ -32,24 +39,23 @@ export function useMessage() {
message: ElMessageBoxOptions['message'],
configOrTitle?: string | ElMessageBoxOptions,
config?: ElMessageBoxOptions,
): Promise<MessageBoxConfirmResult> {
) {
const resolvedConfig = {
...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})),
...(config ?? (typeof configOrTitle === 'object' ? configOrTitle : {})),
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
distinguishCancelAndClose: true,
showClose: config?.showClose || false,
showClose: config?.showClose ?? false,
closeOnClickModal: false,
};
if (typeof configOrTitle === 'string') {
return await (MessageBox.confirm(message, configOrTitle, resolvedConfig).catch(
return await MessageBox.confirm(message, configOrTitle, resolvedConfig).catch(
handleCancelOrClose,
) as unknown as Promise<MessageBoxConfirmResult>);
);
}
return await (MessageBox.confirm(message, resolvedConfig).catch(
handleCancelOrClose,
) as unknown as Promise<MessageBoxConfirmResult>);
return await MessageBox.confirm(message, resolvedConfig).catch(handleCancelOrClose);
}
async function prompt(
@ -58,17 +64,17 @@ export function useMessage() {
config?: ElMessageBoxOptions,
) {
const resolvedConfig = {
...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})),
...(config ?? (typeof configOrTitle === 'object' ? configOrTitle : {})),
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
};
if (typeof configOrTitle === 'string') {
return await MessageBox.prompt(message, configOrTitle, resolvedConfig).catch(
handleCancelOrClose,
handleCancelOrClosePrompt,
);
}
return await MessageBox.prompt(message, resolvedConfig).catch(handleCancelOrClose);
return await MessageBox.prompt(message, resolvedConfig).catch(handleCancelOrClosePrompt);
}
return {

View File

@ -39,6 +39,7 @@ import type {
IWorkflowData,
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowTemplateNode,
TargetItem,
XYPosition,
} from '@/Interface';
@ -307,7 +308,11 @@ function getNodes(): INodeUi[] {
}
// Returns a workflow instance.
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
function getWorkflow(
nodes: Array<INodeUi | IWorkflowTemplateNode>,
connections: IConnections,
copyData?: boolean,
): Workflow {
return useWorkflowsStore().getWorkflow(nodes, connections, copyData);
}

View File

@ -398,7 +398,6 @@ export const ROLE_OTHER = 'other';
/** END OF PERSONALIZATION SURVEY */
export const MODAL_CANCEL = 'cancel';
export const MODAL_CLOSE = 'close';
export const MODAL_CONFIRM = 'confirm';
export const VALID_EMAIL_REGEX =

34
packages/editor-ui/src/jsplumb.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
import type { Connection, Endpoint, EndpointRepresentation, AbstractConnector, Overlay } from '@jsplumb/core';
import type { NodeConnectionType } from 'n8n-workflow';
declare module '@jsplumb/core' {
interface EndpointRepresentation {
canvas: HTMLElement;
scope: NodeConnectionType;
}
interface AbstractConnector {
canvas: HTMLElement;
overrideTargetEndpoint: Endpoint;
}
interface Overlay {
canvas: HTMLElement;
}
interface Connection {
__meta: {
sourceOutputIndex: number;
targetNodeName: string;
targetOutputIndex: number;
sourceNodeName: string;
};
}
interface Endpoint {
scope: NodeConnectionType;
__meta: {
nodeName: string;
nodeId: string;
index: number;
totalEndpoints: number;
endpointLabelLength: number;
};
};
}

View File

@ -165,7 +165,7 @@ export const routes = [
// Templates view remembers it's scroll position on back
scrollOffset: 0,
telemetry: {
getProperties(route: RouteLocation) {
getProperties() {
const templatesStore = useTemplatesStore();
return {
wf_template_repo_session_id: templatesStore.currentSessionId,
@ -474,7 +474,7 @@ export const routes = [
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'usage',
};
@ -492,7 +492,7 @@ export const routes = [
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'personal',
};
@ -515,7 +515,7 @@ export const routes = [
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'users',
};
@ -533,7 +533,7 @@ export const routes = [
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'api',
};
@ -556,7 +556,7 @@ export const routes = [
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'environments',
};
@ -579,7 +579,7 @@ export const routes = [
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'external-secrets',
};
@ -606,7 +606,7 @@ export const routes = [
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
getProperties() {
return {
feature: 'sso',
};

View File

@ -22,6 +22,7 @@ declare global {
BASE_PATH: string;
REST_ENDPOINT: string;
n8nExternalHooks?: PartialDeep<ExternalHooks>;
preventNodeViewBeforeUnload?: boolean;
}
namespace JSX {

View File

@ -52,7 +52,8 @@ export const useCanvasStore = defineStore('canvas', () => {
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
const isDragging = ref<boolean>(false);
const lastSelectedConnection = ref<Connection | null>(null);
const lastSelectedConnection = ref<Connection>();
const newNodeInsertPosition = ref<XYPosition | null>(null);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
@ -68,6 +69,9 @@ export const useCanvasStore = defineStore('canvas', () => {
const nodeViewScale = ref<number>(1);
const canvasAddButtonPosition = ref<XYPosition>([1, 1]);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const lastSelectedConnectionComputed = computed<Connection | undefined>(
() => lastSelectedConnection.value,
);
watch(readOnlyEnv, (readOnly) => {
if (jsPlumbInstanceRef.value) {
@ -75,6 +79,10 @@ export const useCanvasStore = defineStore('canvas', () => {
}
});
const setLastSelectedConnection = (connection: Connection | undefined) => {
lastSelectedConnection.value = connection;
};
const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => {
const position = getMidCanvasPosition(nodeViewScale.value, offset ?? [0, 0]);
@ -314,11 +322,12 @@ export const useCanvasStore = defineStore('canvas', () => {
isDemo,
nodeViewScale,
canvasAddButtonPosition,
lastSelectedConnection,
newNodeInsertPosition,
jsPlumbInstance,
isLoading: loadingService.isLoading,
aiNodes,
lastSelectedConnection: lastSelectedConnectionComputed,
setLastSelectedConnection,
startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText,
stopLoading: loadingService.stopLoading,

View File

@ -30,6 +30,7 @@ import type {
NodeMetadataMap,
WorkflowMetadata,
IExecutionFlattedResponse,
IWorkflowTemplateNode,
} from '@/Interface';
import { defineStore } from 'pinia';
import type {
@ -312,9 +313,31 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
setNodeValue({ name: node.name, key: 'position', value: position });
}
function convertTemplateNodeToNodeUi(node: IWorkflowTemplateNode): INodeUi {
const filteredCredentials = Object.keys(node.credentials ?? {}).reduce<INodeCredentials>(
(credentials, curr) => {
const credential = node?.credentials?.[curr];
if (!credential || typeof credential === 'string') {
return credentials;
}
credentials[curr] = credential;
return credentials;
},
{},
);
return {
...node,
credentials: filteredCredentials,
};
}
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = getNodeTypes();
let cachedWorkflowId: string | undefined = workflowId.value;
if (cachedWorkflowId && cachedWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
cachedWorkflowId = undefined;
}
@ -327,7 +350,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
active: false,
nodeTypes,
settings: workflowSettings.value,
// @ts-ignore
pinData: pinnedWorkflowData.value,
});
@ -1520,6 +1542,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getPinDataSize,
getNodeTypes,
getNodes,
convertTemplateNodeToNodeUi,
getWorkflow,
getCurrentWorkflow,
getWorkflowFromUrl,

View File

@ -25,6 +25,7 @@ import type {
INodeUpdatePropertiesInformation,
IPersonalizationLatestVersion,
IWorkflowDb,
IWorkflowTemplateNode,
NodeFilterType,
} from '@/Interface';
import type { ComponentPublicInstance } from 'vue/dist/vue';
@ -68,6 +69,7 @@ export interface ExternalHooks {
addNodeButton: Array<ExternalHooksMethod<{ nodeTypeName: string }>>;
onRunNode: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
onRunWorkflow: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
onOpenChat: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
};
main: {
routeChange: Array<ExternalHooksMethod<{ to: RouteLocation; from: RouteLocation }>>;
@ -255,7 +257,7 @@ export interface ExternalHooks {
ExternalHooksMethod<{
templateId: string;
templateName: string;
workflow: { nodes: INodeUi[]; connections: IConnections };
workflow: { nodes: INodeUi[] | IWorkflowTemplateNode[]; connections: IConnections };
}>
>;
};

View File

@ -3,7 +3,7 @@ import {
mapLegacyEndpointsToCanvasConnectionPort,
getUniqueNodeName,
} from '@/utils/canvasUtilsV2';
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
import type { CanvasConnection } from '@/types';
import type { INodeUi } from '@/Interface';
@ -15,7 +15,7 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
it('should map legacy connections to canvas connections', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
main: [[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }]],
},
};
const nodes: INodeUi[] = [
@ -53,11 +53,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -67,7 +67,7 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
it('should return empty array when no matching nodes found', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [[{ node: 'Node B', type: 'main', index: 0 }]],
main: [[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }]],
},
};
const nodes: INodeUi[] = [];
@ -113,8 +113,8 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Node B', type: 'main', index: 0 }],
[{ node: 'Node B', type: 'main', index: 1 }],
[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'Node B', type: NodeConnectionType.Main, index: 1 }],
],
},
};
@ -153,11 +153,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -171,11 +171,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 1,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -186,8 +186,8 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Node B', type: 'main', index: 0 }],
[{ node: 'Node C', type: 'main', index: 0 }],
[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'Node C', type: NodeConnectionType.Main, index: 0 }],
],
},
};
@ -234,11 +234,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -252,11 +252,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -266,11 +266,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
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 }]],
main: [[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }]],
[NodeConnectionType.AiMemory]: [
[{ node: 'Node C', type: NodeConnectionType.AiMemory, index: 1 }],
],
},
'Node B': {
main: [[{ node: 'Node C', type: 'main', index: 0 }]],
main: [[{ node: 'Node C', type: NodeConnectionType.Main, index: 0 }]],
},
};
const nodes: INodeUi[] = [
@ -316,29 +318,29 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
{
id: '[1/other/0][3/other/1]',
id: `[1/${NodeConnectionType.AiMemory}/0][3/${NodeConnectionType.AiMemory}/1]`,
source: '1',
target: '3',
sourceHandle: 'outputs/other/0',
targetHandle: 'inputs/other/1',
sourceHandle: `outputs/${NodeConnectionType.AiMemory}/0`,
targetHandle: `inputs/${NodeConnectionType.AiMemory}/1`,
data: {
fromNodeName: 'Node A',
source: {
index: 0,
type: 'other',
type: NodeConnectionType.AiMemory,
},
target: {
index: 1,
type: 'other',
type: NodeConnectionType.AiMemory,
},
},
},
@ -352,11 +354,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node B',
source: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -367,8 +369,8 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
const legacyConnections: IConnections = {
'Node A': {
main: [
[{ node: 'Nonexistent Node', type: 'main', index: 0 }],
[{ node: 'Node B', type: 'main', index: 0 }],
[{ node: 'Nonexistent Node', type: NodeConnectionType.Main, index: 0 }],
[{ node: 'Node B', type: NodeConnectionType.Main, index: 0 }],
],
},
};
@ -407,11 +409,11 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
fromNodeName: 'Node A',
source: {
index: 1,
type: 'main',
type: NodeConnectionType.Main,
},
target: {
index: 0,
type: 'main',
type: NodeConnectionType.Main,
},
},
},
@ -435,66 +437,69 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
});
it('should map string endpoints correctly', () => {
const endpoints: INodeTypeDescription['inputs'] = ['main', 'ai_tool'];
const endpoints: INodeTypeDescription['inputs'] = [
NodeConnectionType.Main,
NodeConnectionType.AiTool,
];
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
expect(result).toEqual([
{ type: 'main', index: 0, label: undefined },
{ type: 'ai_tool', index: 0, label: undefined },
{ type: NodeConnectionType.Main, index: 0, label: undefined },
{ type: NodeConnectionType.AiTool, 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 },
{ type: NodeConnectionType.Main, displayName: 'Main Input' },
{ type: NodeConnectionType.AiTool, 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 },
{ type: NodeConnectionType.Main, index: 0, label: 'Main Input' },
{ type: NodeConnectionType.AiTool, 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',
NodeConnectionType.Main,
{ type: NodeConnectionType.AiTool, displayName: 'AI Tool' },
NodeConnectionType.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 },
{ type: NodeConnectionType.Main, index: 0, label: undefined },
{ type: NodeConnectionType.AiTool, index: 0, label: 'AI Tool' },
{ type: NodeConnectionType.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' },
{ type: NodeConnectionType.Main, displayName: 'Main Input' },
{ type: NodeConnectionType.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' },
{ type: NodeConnectionType.Main, index: 0, label: 'Main Input' },
{ type: NodeConnectionType.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 },
{ type: NodeConnectionType.Main, displayName: 'Main Input', required: true },
{ type: NodeConnectionType.AiTool, 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' },
{ type: NodeConnectionType.Main, index: 0, label: 'Main Input', required: true },
{ type: NodeConnectionType.AiTool, index: 0, label: 'Optional Tool' },
]);
});
});

View File

@ -1,6 +1,7 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker/locale/en';
import type { ITemplatesWorkflowFull, IWorkflowTemplateNode } from '@/Interface';
import { NodeConnectionType } from 'n8n-workflow';
export const newWorkflowTemplateNode = ({
type,
@ -26,6 +27,7 @@ export const fullShopifyTelegramTwitterTemplate = {
workflow: {
nodes: [
{
id: 'd65f8060-0196-430a-923c-57f838991cc1',
name: 'Twitter',
type: 'n8n-nodes-base.twitter',
position: [720, -220],
@ -39,6 +41,7 @@ export const fullShopifyTelegramTwitterTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991dd3',
name: 'Telegram',
type: 'n8n-nodes-base.telegram',
position: [720, -20],
@ -53,6 +56,7 @@ export const fullShopifyTelegramTwitterTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991dd2',
name: 'product created',
type: 'n8n-nodes-base.shopifyTrigger',
position: [540, -110],
@ -72,12 +76,12 @@ export const fullShopifyTelegramTwitterTemplate = {
[
{
node: 'Twitter',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
{
node: 'Telegram',
type: 'main',
type: NodeConnectionType.Main,
index: 0,
},
],
@ -195,6 +199,7 @@ export const fullSaveEmailAttachmentsToNextCloudTemplate = {
workflow: {
nodes: [
{
id: 'd65f8060-0196-430a-923c-57f8389911f3',
name: 'IMAP Email',
type: 'n8n-nodes-base.emailReadImap',
position: [240, 420],
@ -206,6 +211,7 @@ export const fullSaveEmailAttachmentsToNextCloudTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991gg2',
name: 'Nextcloud',
type: 'n8n-nodes-base.nextCloud',
position: [940, 420],
@ -217,6 +223,7 @@ export const fullSaveEmailAttachmentsToNextCloudTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991ddh',
name: 'Map each attachment',
type: 'n8n-nodes-base.function',
position: [620, 420],
@ -228,8 +235,12 @@ export const fullSaveEmailAttachmentsToNextCloudTemplate = {
},
],
connections: {
'IMAP Email': { main: [[{ node: 'Map each attachment', type: 'main', index: 0 }]] },
'Map each attachment': { main: [[{ node: 'Nextcloud', type: 'main', index: 0 }]] },
'IMAP Email': {
main: [[{ node: 'Map each attachment', type: NodeConnectionType.Main, index: 0 }]],
},
'Map each attachment': {
main: [[{ node: 'Nextcloud', type: NodeConnectionType.Main, index: 0 }]],
},
},
},
workflowInfo: {
@ -303,6 +314,7 @@ export const fullCreateApiEndpointTemplate = {
workflow: {
nodes: [
{
id: 'd65f8060-0196-430a-923c-57f838991dd1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [375, 115],
@ -315,6 +327,7 @@ export const fullCreateApiEndpointTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991dd9',
name: 'Note1',
type: 'n8n-nodes-base.stickyNote',
position: [355, -25],
@ -327,6 +340,7 @@ export const fullCreateApiEndpointTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991dd5',
name: 'Respond to Webhook',
type: 'n8n-nodes-base.respondToWebhook',
position: [815, 115],
@ -339,6 +353,7 @@ export const fullCreateApiEndpointTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991df1',
name: 'Create URL string',
type: 'n8n-nodes-base.set',
position: [595, 115],
@ -358,6 +373,7 @@ export const fullCreateApiEndpointTemplate = {
typeVersion: 1,
},
{
id: 'd65f8060-0196-430a-923c-57f838991dbb',
name: 'Note3',
type: 'n8n-nodes-base.stickyNote',
position: [355, 275],
@ -371,8 +387,10 @@ export const fullCreateApiEndpointTemplate = {
},
],
connections: {
Webhook: { main: [[{ node: 'Create URL string', type: 'main', index: 0 }]] },
'Create URL string': { main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]] },
Webhook: { main: [[{ node: 'Create URL string', type: NodeConnectionType.Main, index: 0 }]] },
'Create URL string': {
main: [[{ node: 'Respond to Webhook', type: NodeConnectionType.Main, index: 0 }]],
},
},
},
lastUpdatedBy: 1,

View File

@ -6,6 +6,8 @@ import type {
} from 'n8n-workflow';
import { nodeConnectionTypes } from 'n8n-workflow';
import type { ICredentialsResponse, NewCredentialsModal } from '@/Interface';
import type { jsPlumbDOMElement } from '@jsplumb/browser-ui';
import type { Connection } from '@jsplumb/core';
/*
Type guards used in editor-ui project
@ -46,10 +48,14 @@ export const isResourceMapperValue = (value: unknown): value is string | number
return ['string', 'number', 'boolean'].includes(typeof value);
};
export const isJSPlumbEndpointElement = (element: Node): element is HTMLElement => {
export const isJSPlumbEndpointElement = (element: Node): element is jsPlumbDOMElement => {
return 'jtk' in element && 'endpoint' in (element.jtk as object);
};
export const isJSPlumbConnection = (connection: unknown): connection is Connection => {
return connection !== null && typeof connection === 'object' && 'connector' in connection;
};
export function isDateObject(date: unknown): date is Date {
return (
!!date && Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date as number)

View File

@ -42,6 +42,7 @@ import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useCollaborationStore } from '@/stores/collaboration.store';
import { getUniqueNodeName } from '@/utils/canvasUtilsV2';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -217,13 +218,17 @@ function onCreateNodeConnection(connection: Connection) {
const sourceNodeId = connection.source;
const sourceNode = workflowsStore.getNodeById(sourceNodeId);
const sourceNodeName = sourceNode?.name ?? '';
const [, sourceType, sourceIndex] = (connection.sourceHandle ?? '').split('/');
const [, sourceType, sourceIndex] = (connection.sourceHandle ?? '')
.split('/')
.filter(isValidNodeConnectionType);
// Input
const targetNodeId = connection.target;
const targetNode = workflowsStore.getNodeById(targetNodeId);
const targetNodeName = targetNode?.name ?? '';
const [, targetType, targetIndex] = (connection.targetHandle ?? '').split('/');
const [, targetType, targetIndex] = (connection.targetHandle ?? '')
.split('/')
.filter(isValidNodeConnectionType);
if (sourceNode && targetNode && !checkIfNodeConnectionIsAllowed(sourceNode, targetNode)) {
return;
@ -248,7 +253,7 @@ function onCreateNodeConnection(connection: Connection) {
}
// @TODO Figure out a way to improve this
function checkIfNodeConnectionIsAllowed(sourceNode: INodeUi, targetNode: INodeUi): boolean {
function checkIfNodeConnectionIsAllowed(_sourceNode: INodeUi, _targetNode: INodeUi): boolean {
// const targetNodeType = nodeTypesStore.getNodeType(
// targetNode.type,
// targetNode.typeVersion,
@ -341,7 +346,7 @@ async function onAddNodes(
) {
let currentPosition = position;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
const node = await addNode(
const _node = await addNode(
{
name,
type,
@ -407,7 +412,7 @@ type AddNodeOptions = {
isAutoAdd?: boolean;
};
async function addNode(node: AddNodeData, options: AddNodeOptions): Promise<INodeUi | undefined> {
async function addNode(node: AddNodeData, _options: AddNodeOptions): Promise<INodeUi | undefined> {
if (!checkIfEditingIsAllowed()) {
return;
}
@ -663,11 +668,11 @@ async function createNodeWithDefaultCredentials(node: Partial<INodeUi>) {
* @TODO Probably not needed and can be merged into addNode
*/
async function injectNode(
nodeTypeName: string,
options: AddNodeOptions = {},
showDetail = true,
trackHistory = false,
isAutoAdd = false,
_nodeTypeName: string,
_options: AddNodeOptions = {},
_showDetail = true,
_trackHistory = false,
_isAutoAdd = false,
) {
// const nodeTypeData: INodeTypeDescription | null =
// this.nodeTypesStore.getNodeType(nodeTypeName);

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@ const SettingsView = defineComponent({
components: {
SettingsSidebar,
},
beforeRouteEnter(to, from, next) {
beforeRouteEnter(_to, from, next) {
next((vm) => {
(vm as unknown as InstanceType<typeof SettingsView>).previousRoute = from;
});

View File

@ -29,7 +29,7 @@ const openWorkflowTemplate = async (templateId: string) => {
const workflow = await workflowsStore.createNewWorkflow({
name,
connections: template.workflow.connections,
nodes: template.workflow.nodes,
nodes: template.workflow.nodes.map(workflowsStore.convertTemplateNodeToNodeUi),
pinData: template.workflow.pinData,
settings: template.workflow.settings,
meta: {

View File

@ -68,7 +68,7 @@ export interface IConnection {
node: string;
// The type of the input on destination node (for example "main")
type: string;
type: NodeConnectionType;
// The output/input-index of destination node (if node has multiple inputs/outputs of the same type)
index: number;

View File

@ -168,7 +168,8 @@ export class Workflow {
if (!connections.hasOwnProperty(sourceNode)) {
continue;
}
for (const type in connections[sourceNode]) {
for (const type of Object.keys(connections[sourceNode]) as NodeConnectionType[]) {
if (!connections[sourceNode].hasOwnProperty(type)) {
continue;
}