From 68420ca6bee603877a7a84bad8a9ef23a8aba0a9 Mon Sep 17 00:00:00 2001 From: oleg Date: Mon, 3 Jun 2024 16:33:20 +0200 Subject: [PATCH] refactor(editor): Fix NodeView/Canvas related TS errors (#9581) Signed-off-by: Oleg Ivaniv --- packages/editor-ui/src/Interface.ts | 13 +- .../AIAssistantChat/AIAssistantChat.vue | 6 +- .../components/MainHeader/WorkflowDetails.vue | 9 +- .../src/composables/useCanvasMouseSelect.ts | 2 +- .../src/composables/useCanvasPanning.ts | 8 +- .../editor-ui/src/composables/useMessage.ts | 36 +- .../src/composables/useWorkflowHelpers.ts | 7 +- packages/editor-ui/src/constants.ts | 1 - packages/editor-ui/src/jsplumb.d.ts | 34 + packages/editor-ui/src/router.ts | 16 +- packages/editor-ui/src/shims.d.ts | 1 + packages/editor-ui/src/stores/canvas.store.ts | 13 +- .../editor-ui/src/stores/workflows.store.ts | 25 +- packages/editor-ui/src/types/externalHooks.ts | 4 +- .../editor-ui/src/utils/canvasUtilsV2.spec.ts | 113 ++-- .../src/utils/testData/templateTestData.ts | 30 +- packages/editor-ui/src/utils/typeGuards.ts | 8 +- packages/editor-ui/src/views/NodeView.v2.vue | 25 +- packages/editor-ui/src/views/NodeView.vue | 616 ++++++++++-------- packages/editor-ui/src/views/SettingsView.vue | 2 +- .../src/views/WorkflowOnboardingView.vue | 2 +- packages/workflow/src/Interfaces.ts | 2 +- packages/workflow/src/Workflow.ts | 3 +- 23 files changed, 587 insertions(+), 389 deletions(-) create mode 100644 packages/editor-ui/src/jsplumb.d.ts diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index b9ef64d99d..9a58bed13c 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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; + nodes?: INode[]; connections?: IConnections; settings?: IWorkflowSettings; active?: boolean; @@ -268,7 +270,10 @@ export interface NewWorkflowResponse { } export interface IWorkflowTemplateNode - extends Pick { + 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 = diff --git a/packages/editor-ui/src/components/AIAssistantChat/AIAssistantChat.vue b/packages/editor-ui/src/components/AIAssistantChat/AIAssistantChat.vue index 484ce7474e..810dbd5c92 100644 --- a/packages/editor-ui/src/components/AIAssistantChat/AIAssistantChat.vue +++ b/packages/editor-ui/src/components/AIAssistantChat/AIAssistantChat.vue @@ -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 }, }); diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 3a0d8b0c46..5ad4b88c44 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -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 { } } -async function onWorkflowMenuSelect(action: string): Promise { +async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise { switch (action) { case WORKFLOW_MENU_ACTIONS.DUPLICATE: { uiStore.openModalWithData({ @@ -427,7 +426,7 @@ async function onWorkflowMenuSelect(action: string): Promise { } 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 { 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; } diff --git a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts index 525402e819..4abb3aaced 100644 --- a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts +++ b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts @@ -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); diff --git a/packages/editor-ui/src/composables/useCanvasPanning.ts b/packages/editor-ui/src/composables/useCanvasPanning.ts index b11b92b1f9..b1bf79a025 100644 --- a/packages/editor-ui/src/composables/useCanvasPanning.ts +++ b/packages/editor-ui/src/composables/useCanvasPanning.ts @@ -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. diff --git a/packages/editor-ui/src/composables/useMessage.ts b/packages/editor-ui/src/composables/useMessage.ts index ecf7a0ba16..b3f8b32a36 100644 --- a/packages/editor-ui/src/composables/useMessage.ts +++ b/packages/editor-ui/src/composables/useMessage.ts @@ -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 { + ) { 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); + ); } - return await (MessageBox.confirm(message, resolvedConfig).catch( - handleCancelOrClose, - ) as unknown as Promise); + + 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 { diff --git a/packages/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/editor-ui/src/composables/useWorkflowHelpers.ts index 1924ea43ff..bf7cf04a18 100644 --- a/packages/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/editor-ui/src/composables/useWorkflowHelpers.ts @@ -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, + connections: IConnections, + copyData?: boolean, +): Workflow { return useWorkflowsStore().getWorkflow(nodes, connections, copyData); } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index c149b11452..4f19cd5f44 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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 = diff --git a/packages/editor-ui/src/jsplumb.d.ts b/packages/editor-ui/src/jsplumb.d.ts new file mode 100644 index 0000000000..05d2c328ec --- /dev/null +++ b/packages/editor-ui/src/jsplumb.d.ts @@ -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; + }; + }; +} diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 9d43e9c0da..6a9a2fef94 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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', }; diff --git a/packages/editor-ui/src/shims.d.ts b/packages/editor-ui/src/shims.d.ts index 048ee19f2d..643d63b91f 100644 --- a/packages/editor-ui/src/shims.d.ts +++ b/packages/editor-ui/src/shims.d.ts @@ -22,6 +22,7 @@ declare global { BASE_PATH: string; REST_ENDPOINT: string; n8nExternalHooks?: PartialDeep; + preventNodeViewBeforeUnload?: boolean; } namespace JSX { diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts index 93e0975e57..d3fb870901 100644 --- a/packages/editor-ui/src/stores/canvas.store.ts +++ b/packages/editor-ui/src/stores/canvas.store.ts @@ -52,7 +52,8 @@ export const useCanvasStore = defineStore('canvas', () => { const jsPlumbInstanceRef = ref(); const isDragging = ref(false); - const lastSelectedConnection = ref(null); + const lastSelectedConnection = ref(); + const newNodeInsertPosition = ref(null); const nodes = computed(() => workflowStore.allNodes); @@ -68,6 +69,9 @@ export const useCanvasStore = defineStore('canvas', () => { const nodeViewScale = ref(1); const canvasAddButtonPosition = ref([1, 1]); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); + const lastSelectedConnectionComputed = computed( + () => 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, diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 30345ae1b6..7d7826537f 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -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( + (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, diff --git a/packages/editor-ui/src/types/externalHooks.ts b/packages/editor-ui/src/types/externalHooks.ts index cfe88ad6ae..f71c12b483 100644 --- a/packages/editor-ui/src/types/externalHooks.ts +++ b/packages/editor-ui/src/types/externalHooks.ts @@ -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>; onRunNode: Array>; onRunWorkflow: Array>; + onOpenChat: Array>; }; main: { routeChange: Array>; @@ -255,7 +257,7 @@ export interface ExternalHooks { ExternalHooksMethod<{ templateId: string; templateName: string; - workflow: { nodes: INodeUi[]; connections: IConnections }; + workflow: { nodes: INodeUi[] | IWorkflowTemplateNode[]; connections: IConnections }; }> >; }; diff --git a/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts b/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts index 3f4f213f63..c880288a2f 100644 --- a/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts +++ b/packages/editor-ui/src/utils/canvasUtilsV2.spec.ts @@ -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' }, ]); }); }); diff --git a/packages/editor-ui/src/utils/testData/templateTestData.ts b/packages/editor-ui/src/utils/testData/templateTestData.ts index c878acf72f..28a3c83952 100644 --- a/packages/editor-ui/src/utils/testData/templateTestData.ts +++ b/packages/editor-ui/src/utils/testData/templateTestData.ts @@ -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, diff --git a/packages/editor-ui/src/utils/typeGuards.ts b/packages/editor-ui/src/utils/typeGuards.ts index 5cd497f007..90268b88a6 100644 --- a/packages/editor-ui/src/utils/typeGuards.ts +++ b/packages/editor-ui/src/utils/typeGuards.ts @@ -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) diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 6e30e58e1e..0ba060423b 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -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 { +async function addNode(node: AddNodeData, _options: AddNodeOptions): Promise { if (!checkIfEditingIsAllowed()) { return; } @@ -663,11 +668,11 @@ async function createNodeWithDefaultCredentials(node: Partial) { * @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); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 518610f734..f0f5c17e6c 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -66,7 +66,7 @@ > @@ -221,13 +221,12 @@ import { EVENT_CONNECTION_MOVED, INTERCEPT_BEFORE_DROP, } from '@jsplumb/core'; -import type { MessageBoxInputData, ElNotification } from 'element-plus'; +import type { NotificationHandle } from 'element-plus'; import { FIRST_ONBOARDING_PROMPT_TIMEOUT, MAIN_HEADER_TABS, MODAL_CANCEL, - MODAL_CLOSE, MODAL_CONFIRM, ONBOARDING_CALL_SIGNUP_MODAL_KEY, ONBOARDING_PROMPT_TIMEBOX, @@ -297,6 +296,7 @@ import { deepCopy, jsonParse, NodeConnectionType, + nodeConnectionTypes, NodeHelpers, TelemetryHelpers, } from 'n8n-workflow'; @@ -321,9 +321,10 @@ import type { ToggleNodeCreatorOptions, IPushDataExecutionFinished, AIAssistantConnectionInfo, + NodeFilterType, } from '@/Interface'; -import { type Route, type RawLocation, useRouter } from 'vue-router'; +import { type RouteLocation, useRouter } from 'vue-router'; import { dataPinningEventBus, nodeViewEventBus } from '@/event-bus'; import { useCanvasStore } from '@/stores/canvas.store'; import { useCollaborationStore } from '@/stores/collaboration.store'; @@ -378,7 +379,11 @@ import { N8nAddInputEndpointType, } from '@/plugins/jsplumb/N8nAddInputEndpointType'; import { sourceControlEventBus } from '@/event-bus/source-control'; -import { getConnectorPaintStyleData, OVERLAY_ENDPOINT_ARROW_ID } from '@/utils/nodeViewUtils'; +import { + getConnectorPaintStyleData, + OVERLAY_ENDPOINT_ARROW_ID, + getEndpointScope, +} from '@/utils/nodeViewUtils'; import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useClipboard } from '@/composables/useClipboard'; @@ -395,7 +400,7 @@ import { useProjectsStore } from '@/features/projects/projects.store'; import type { ProjectSharingData } from '@/features/projects/projects.types'; import { useAIStore } from '@/stores/ai.store'; import { useStorage } from '@/composables/useStorage'; -import { isJSPlumbEndpointElement } from '@/utils/typeGuards'; +import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards'; import { usePostHog } from '@/stores/posthog.store'; import { ProjectTypes } from '@/features/projects/projects.utils'; @@ -466,14 +471,12 @@ export default defineComponent({ if (from.name === VIEWS.NEW_WORKFLOW) { // Replace the current route with the new workflow route // before navigating to the new route when saving new workflow. - await this.$router.replace( - { name: VIEWS.WORKFLOW, params: { name: this.currentWorkflow } }, - () => { - // We can't use next() here since vue-router - // would prevent the navigation with an error - void this.$router.push(to as RawLocation); - }, - ); + await this.$router.replace({ + name: VIEWS.WORKFLOW, + params: { name: this.currentWorkflow }, + }); + + await this.$router.push(to); } else { this.collaborationStore.notifyWorkflowClosed(this.currentWorkflow); next(); @@ -491,9 +494,9 @@ export default defineComponent({ } }, setup() { - const nodeViewRootRef = ref(null); - const nodeViewRef = ref(null); - const onMouseMoveEnd = ref(null); + const nodeViewRootRef = ref(null); + const nodeViewRef = ref(null); + const onMouseMoveEnd = ref<((e: MouseEvent | TouchEvent) => void) | null>(null); const router = useRouter(); const ndvStore = useNDVStore(); @@ -537,69 +540,45 @@ export default defineComponent({ ...useExecutionDebugging(), }; }, - watch: { - // Listen to route changes and load the workflow accordingly - async $route(to: Route, from: Route) { - this.readOnlyEnvRouteCheck(); - - const currentTab = getNodeViewTab(to); - const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized; - let workflowChanged = - from.params.name !== to.params.name && - // Both 'new' and __EMPTY__ are new workflow names, so ignore them when detecting if wf changed - !(from.params.name === 'new' && this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID) && - !(from.name === VIEWS.NEW_WORKFLOW) && - // Also ignore if workflow id changes when saving new workflow - to.params.action !== 'workflowSave'; - const isOpeningTemplate = to.name === VIEWS.TEMPLATE_IMPORT; - - // When entering this tab: - if (currentTab === MAIN_HEADER_TABS.WORKFLOW || isOpeningTemplate) { - if (workflowChanged || nodeViewNotInitialized || isOpeningTemplate) { - this.canvasStore.startLoading(); - if (nodeViewNotInitialized) { - const previousDirtyState = this.uiStore.stateIsDirty; - this.resetWorkspace(); - this.uiStore.stateIsDirty = previousDirtyState; - } - await this.initView(); - this.canvasStore.stopLoading(); - if (this.blankRedirect) { - this.blankRedirect = false; - } - } - await this.checkAndInitDebugMode(); - } - // Also, when landing on executions tab, check if workflow data is changed - if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) { - workflowChanged = - from.params.name !== to.params.name && - !(to.params.name === 'new' && from.params.name === undefined); - if (workflowChanged) { - // This will trigger node view to update next time workflow tab is opened - this.uiStore.nodeViewInitialized = false; - } - } - }, - activeNode() { - // When a node gets set as active deactivate the create-menu - this.createNodeActive = false; - }, - containsTrigger(containsTrigger) { - // Re-center CanvasAddButton if there's no triggers - if (containsTrigger === false) - this.canvasStore.setRecenteredCanvasAddButtonPosition(this.getNodeViewOffsetPosition); - }, - nodeViewScale(newScale) { - const elementRef = this.nodeViewRef as HTMLDivElement | undefined; - if (elementRef) { - elementRef.style.transform = `scale(${newScale})`; - } - }, - }, - errorCaptured: (err, vm, info) => { - console.error('errorCaptured'); - console.error(err); + data() { + return { + GRID_SIZE: NodeViewUtils.GRID_SIZE, + STICKY_NODE_TYPE, + createNodeActive: false, + lastClickPosition: [450, 450] as XYPosition, + ctrlKeyPressed: false, + moveCanvasKeyPressed: false, + stopExecutionInProgress: false, + blankRedirect: false, + credentialsUpdated: false, + pullConnActiveNodeName: null as string | null, + pullConnActive: false, + dropPrevented: false, + connectionDragScope: { + type: null, + connection: null, + } as { type: string | null; connection: 'source' | 'target' | null }, + renamingActive: false, + showStickyButton: false, + isExecutionPreview: false, + showTriggerMissingTooltip: false, + workflowData: null as INewWorkflowData | null, + activeConnection: null as null | Connection, + isInsertingNodes: false, + isProductionExecutionPreview: false, + enterTimer: undefined as undefined | ReturnType, + exitTimer: undefined as undefined | ReturnType, + readOnlyNotification: null as null | NotificationHandle, + // jsplumb automatically deletes all loose connections which is in turn recorded + // in undo history as a user action. + // This should prevent automatically removed connections from populating undo stack + suspendRecordingDetachedConnections: false, + NODE_CREATOR_OPEN_SOURCES, + eventsAttached: false, + unloadTimeout: undefined as undefined | ReturnType, + canOpenNDV: true, + hideNodeIssues: false, + }; }, computed: { ...mapStores( @@ -668,19 +647,19 @@ export default defineComponent({ return this.$locale.baseText('nodeView.runButtonText.executingWorkflow'); }, - workflowStyle(): object { + workflowStyle() { const offsetPosition = this.uiStore.nodeViewOffsetPosition; return { left: offsetPosition[0] + 'px', top: offsetPosition[1] + 'px', }; }, - canvasAddButtonStyle(): object { + canvasAddButtonStyle() { return { 'pointer-events': this.createNodeActive ? 'none' : 'all', }; }, - backgroundStyle(): object { + backgroundStyle() { return NodeViewUtils.getBackgroundStyles( this.nodeViewScale, this.uiStore.nodeViewOffsetPosition, @@ -718,7 +697,7 @@ export default defineComponent({ return this.uiStore.isActionActive('workflowRunning'); }, currentWorkflow(): string { - return this.$route.params.name || this.workflowsStore.workflowId; + return this.$route.params.name?.toString() || this.workflowsStore.workflowId; }, workflowName(): string { return this.workflowsStore.workflowName; @@ -789,52 +768,84 @@ export default defineComponent({ return isCloudDeployment && experimentEnabled && !userHasSeenAIAssistantExperiment; }, }, - data() { - return { - GRID_SIZE: NodeViewUtils.GRID_SIZE, - STICKY_NODE_TYPE, - createNodeActive: false, - lastClickPosition: [450, 450] as XYPosition, - ctrlKeyPressed: false, - moveCanvasKeyPressed: false, - stopExecutionInProgress: false, - blankRedirect: false, - credentialsUpdated: false, - pullConnActiveNodeName: null as string | null, - pullConnActive: false, - dropPrevented: false, - connectionDragScope: { - type: null, - connection: null, - } as { type: string | null; connection: 'source' | 'target' | null }, - renamingActive: false, - showStickyButton: false, - isExecutionPreview: false, - showTriggerMissingTooltip: false, - workflowData: null as INewWorkflowData | null, - activeConnection: null as null | Connection, - isInsertingNodes: false, - isProductionExecutionPreview: false, - enterTimer: undefined as undefined | ReturnType, - exitTimer: undefined as undefined | ReturnType, - readOnlyNotification: null as null | typeof ElNotification, - // jsplumb automatically deletes all loose connections which is in turn recorded - // in undo history as a user action. - // This should prevent automatically removed connections from populating undo stack - suspendRecordingDetachedConnections: false, - NODE_CREATOR_OPEN_SOURCES, - eventsAttached: false, - unloadTimeout: undefined as undefined | ReturnType, - canOpenNDV: true, - hideNodeIssues: false, - }; + watch: { + // Listen to route changes and load the workflow accordingly + async $route(to: RouteLocation, from: RouteLocation) { + this.readOnlyEnvRouteCheck(); + + const currentTab = getNodeViewTab(to); + const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized; + let workflowChanged = + from.params.name !== to.params.name && + // Both 'new' and __EMPTY__ are new workflow names, so ignore them when detecting if wf changed + !(from.params.name === 'new' && this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID) && + !(from.name === VIEWS.NEW_WORKFLOW) && + // Also ignore if workflow id changes when saving new workflow + to.params.action !== 'workflowSave'; + const isOpeningTemplate = to.name === VIEWS.TEMPLATE_IMPORT; + + // When entering this tab: + if (currentTab === MAIN_HEADER_TABS.WORKFLOW || isOpeningTemplate) { + if (workflowChanged || nodeViewNotInitialized || isOpeningTemplate) { + this.canvasStore.startLoading(); + if (nodeViewNotInitialized) { + const previousDirtyState = this.uiStore.stateIsDirty; + this.resetWorkspace(); + this.uiStore.stateIsDirty = previousDirtyState; + } + await this.initView(); + this.canvasStore.stopLoading(); + if (this.blankRedirect) { + this.blankRedirect = false; + } + } + await this.checkAndInitDebugMode(); + } + // Also, when landing on executions tab, check if workflow data is changed + if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) { + workflowChanged = + from.params.name !== to.params.name && + !(to.params.name === 'new' && from.params.name === undefined); + if (workflowChanged) { + // This will trigger node view to update next time workflow tab is opened + this.uiStore.nodeViewInitialized = false; + } + } + }, + activeNode() { + // When a node gets set as active deactivate the create-menu + this.createNodeActive = false; + }, + containsTrigger(containsTrigger) { + // Re-center CanvasAddButton if there's no triggers + if (containsTrigger === false) + this.canvasStore.setRecenteredCanvasAddButtonPosition(this.getNodeViewOffsetPosition); + }, + nodeViewScale(newScale) { + const elementRef = this.nodeViewRef; + if (elementRef) { + elementRef.style.transform = `scale(${newScale})`; + } + }, + }, + errorCaptured: (err) => { + console.error('errorCaptured'); + console.error(err); }, async mounted() { // To be refactored (unref) when migrating to composition API this.onMouseMoveEnd = this.mouseUp; this.resetWorkspace(); - this.canvasStore.initInstance(this.nodeViewRef as HTMLElement); + if (!this.nodeViewRef) { + this.showError( + new Error('NodeView reference not found'), + this.$locale.baseText('nodeView.showError.mounted1.title'), + this.$locale.baseText('nodeView.showError.mounted1.message') + ':', + ); + return; + } + this.canvasStore.initInstance(this.nodeViewRef); this.titleReset(); window.addEventListener('message', this.onPostMessageReceived); @@ -898,7 +909,7 @@ export default defineComponent({ }); // TODO: This currently breaks since front-end hooks are still not updated to work with pinia store - void this.externalHooks.run('nodeView.mount').catch((e) => {}); + void this.externalHooks.run('nodeView.mount').catch(() => {}); if ( this.currentUser?.personalizationAnswers !== null && @@ -1052,9 +1063,9 @@ export default defineComponent({ node, creatorview, }: { - connectiontype: ConnectionTypes; + connectiontype: NodeConnectionType; node: string; - creatorview?: string; + creatorview?: NodeFilterType; }) { const nodeName = node ?? this.ndvStore.activeNodeName; const nodeData = nodeName ? this.workflowsStore.getNodeByName(nodeName) : null; @@ -1080,8 +1091,8 @@ export default defineComponent({ }); }, editAllowedCheck(): boolean { - if (this.readOnlyNotification?.visible) { - return; + if (this.readOnlyNotification) { + return false; } if (this.isReadOnlyRoute || this.readOnlyEnv) { this.readOnlyNotification = this.showMessage({ @@ -1099,6 +1110,9 @@ export default defineComponent({ ), type: 'info', dangerouslyUseHTMLString: true, + onClose: () => { + this.readOnlyNotification = null; + }, }); return false; @@ -1428,7 +1442,10 @@ export default defineComponent({ this.blankRedirect = true; await this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } }); - await this.addNodes(data.workflow.nodes, data.workflow.connections); + const convertedNodes = data.workflow.nodes.map( + this.workflowsStore.convertTemplateNodeToNodeUi, + ); + await this.addNodes(convertedNodes, data.workflow.connections); this.workflowData = (await this.workflowsStore.getNewWorkflowData( data.name, @@ -1457,8 +1474,8 @@ export default defineComponent({ this.workflowsStore.setActive(workflow.active || false); this.workflowsStore.setWorkflowId(workflow.id); this.workflowsStore.setWorkflowName({ newName: workflow.name, setStateDirty: false }); - this.workflowsStore.setWorkflowSettings(workflow.settings || {}); - this.workflowsStore.setWorkflowPinData(workflow.pinData || {}); + this.workflowsStore.setWorkflowSettings(workflow.settings ?? {}); + this.workflowsStore.setWorkflowPinData(workflow.pinData ?? {}); this.workflowsStore.setWorkflowVersionId(workflow.versionId); this.workflowsStore.setWorkflowMetadata(workflow.meta); @@ -1473,7 +1490,7 @@ export default defineComponent({ this.workflowsStore.setUsedCredentials(workflow.usedCredentials); } - const tags = (workflow.tags || []) as ITag[]; + const tags = (workflow.tags ?? []) as ITag[]; const tagIds = tags.map((tag) => tag.id); this.workflowsStore.setWorkflowTagIds(tagIds || []); this.tagsStore.upsertTags(tags); @@ -1516,12 +1533,12 @@ export default defineComponent({ // Hide the node-creator this.createNodeActive = false; }, - mouseUp(e: MouseEvent) { - if (e.button === 1) { + mouseUp(e: MouseEvent | TouchEvent) { + if (e instanceof MouseEvent && e.button === 1) { this.moveCanvasKeyPressed = false; } this.mouseUpMouseSelect(e); - this.canvasPanning.onMouseUp(e); + this.canvasPanning.onMouseUp(); }, keyUp(e: KeyboardEvent) { if (e.key === this.deviceSupport.controlKeyCode) { @@ -1554,13 +1571,13 @@ export default defineComponent({ return; } - // @ts-ignore - const path = e.path || (e.composedPath && e.composedPath()); + const path = e?.composedPath() ?? []; // Check if the keys got emitted from a message box or from something // else which should ignore the default keybindings for (const element of path) { if ( + element instanceof HTMLElement && element.className && typeof element.className === 'string' && element.className.includes('ignore-key-press') @@ -1654,7 +1671,7 @@ export default defineComponent({ return; } - if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) { + if (this.$router.currentRoute.value.name === VIEWS.NEW_WORKFLOW) { nodeViewEventBus.emit('newWorkflow'); } else { void this.$router.push({ name: VIEWS.NEW_WORKFLOW }); @@ -2268,7 +2285,7 @@ export default defineComponent({ const data = await this.addNodesToWorkflow(workflowData); setTimeout(() => { - data.nodes!.forEach((node: INodeUi) => { + (data?.nodes ?? []).forEach((node: INodeUi) => { this.nodeSelectedByName(node.name); }); }); @@ -2319,7 +2336,7 @@ export default defineComponent({ if (!node.credentials) continue; for (const [name, credential] of Object.entries(node.credentials)) { - if (credential.id === null) continue; + if (typeof credential === 'string' || credential.id === null) continue; if (!this.credentialsStore.getCredentialById(credential.id)) { delete node.credentials[name]; @@ -2370,8 +2387,8 @@ export default defineComponent({ this.uiStore.lastSelectedNode = node.name; this.uiStore.lastSelectedNodeOutputIndex = null; this.uiStore.lastSelectedNodeEndpointUuid = null; - this.canvasStore.lastSelectedConnection = null; this.canvasStore.newNodeInsertPosition = null; + this.canvasStore.setLastSelectedConnection(undefined); if (setActive) { this.ndvStore.activeNodeName = node.name; @@ -2593,11 +2610,15 @@ export default defineComponent({ ); // If node has only scoped outputs, position it below the last selected node + const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name); + if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) { + console.error('Could not find last selected node or node type'); + return; + } if ( outputTypes.length > 0 && outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) ) { - const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name); const lastSelectedInputs = NodeHelpers.getNodeInputs( workflow, lastSelectedNodeWorkflow, @@ -2731,7 +2752,7 @@ export default defineComponent({ sourceNodeOutputIndex: number, targetNodeName: string, targetNodeOutputIndex: number, - type: ConnectionTypes, + type: NodeConnectionType, ) { this.uiStore.stateIsDirty = true; @@ -2806,7 +2827,7 @@ export default defineComponent({ const targetEndpoint = lastSelectedNodeEndpointUuid || ''; // Handle connection of scoped_endpoint types - if (lastSelectedNodeEndpointUuid && !isAutoAdd) { + if (lastSelectedNodeEndpointUuid && !isAutoAdd && lastSelectedNode) { const lastSelectedEndpoint = this.instance.getEndpoint(lastSelectedNodeEndpointUuid); if ( lastSelectedEndpoint && @@ -2892,7 +2913,7 @@ export default defineComponent({ return filter; }, - insertNodeAfterSelected(info: NewConnectionInfo) { + insertNodeAfterSelected(info: AIAssistantConnectionInfo) { const type = info.outputType ?? NodeConnectionType.Main; // Get the node and set it as active that new nodes // which get created get automatically connected @@ -2909,7 +2930,7 @@ export default defineComponent({ this.canvasStore.newNodeInsertPosition = null; if (info.connection) { - this.canvasStore.lastSelectedConnection = info.connection; + this.canvasStore.setLastSelectedConnection(info.connection); } this.onToggleNodeCreator({ @@ -2922,7 +2943,7 @@ export default defineComponent({ // after the node creator is opened const isOutput = info.connection?.endpoints[0].parameters.connection === 'source'; const isScopedConnection = - type !== NodeConnectionType.Main && Object.values(NodeConnectionType).includes(type); + type !== NodeConnectionType.Main && nodeConnectionTypes.includes(type); if (isScopedConnection) { useViewStacks() @@ -2931,7 +2952,7 @@ export default defineComponent({ isOutput, this.getNodeCreatorFilter(sourceNode.name, type), ) - .catch((e) => {}); + .catch(() => {}); } }, async onEventConnectionAbort(connection: Connection) { @@ -2989,9 +3010,10 @@ export default defineComponent({ // Find the mutation in which the current endpoint becomes visible again const endpointMutation = mutations.find((mutation) => { const target = mutation.target; + return ( isJSPlumbEndpointElement(target) && - target.jtk.endpoint.uuid === endpoint.uuid && + target.jtk?.endpoint?.uuid === endpoint.uuid && target.style.display === 'block' ); }); @@ -3037,7 +3059,7 @@ export default defineComponent({ const workflow = this.workflowHelpers.getCurrentWorkflow(); const workflowNode = workflow.getNode(targetNode.name); let inputs: Array = []; - if (targetNodeType) { + if (targetNodeType && workflowNode) { inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType); } @@ -3146,6 +3168,10 @@ export default defineComponent({ NodeViewUtils.resetConnection(info.connection); NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint); + if (!sourceNodeName || !targetNodeName) { + console.error('Could not find source or target node name'); + return; + } const connectionData: [IConnection, IConnection] = [ { node: sourceNodeName, @@ -3208,7 +3234,7 @@ export default defineComponent({ info.source, info.target, info.connection?.connector?.hasOwnProperty('canvas') - ? (info.connection.connector.canvas as HTMLElement) + ? info.connection.connector.canvas : undefined, ); }, 0); @@ -3224,7 +3250,7 @@ export default defineComponent({ connection.source, connection.target, connection?.connector?.hasOwnProperty('canvas') - ? (connection?.connector.canvas as HTMLElement) + ? connection?.connector.canvas : undefined, ); }); @@ -3270,7 +3296,11 @@ export default defineComponent({ this.enterTimer = setTimeout(() => { // If there is already an active connection then hide it first - if (this.activeConnection && !this.isConnectionActive(connection)) { + if ( + this.activeConnection && + !this.isConnectionActive(connection) && + isJSPlumbConnection(this.activeConnection) + ) { NodeViewUtils.hideConnectionActions(this.activeConnection); } this.enterTimer = undefined; @@ -3304,7 +3334,11 @@ export default defineComponent({ this.exitTimer = setTimeout(() => { this.exitTimer = undefined; - if (connection && this.isConnectionActive(connection)) { + if ( + connection && + this.isConnectionActive(connection) && + isJSPlumbConnection(this.activeConnection) + ) { NodeViewUtils.hideConnectionActions(this.activeConnection); this.activeConnection = null; } @@ -3342,7 +3376,7 @@ export default defineComponent({ console.error(e); } }, - onEndpointMouseOver(endpoint: Endpoint, mouse) { + onEndpointMouseOver(endpoint: Endpoint, mouse: MouseEvent) { // This event seems bugged. It gets called constantly even when the mouse is not over the endpoint // if the endpoint has a connection attached to it. So we need to check if the mouse is actually over // the endpoint. @@ -3373,6 +3407,11 @@ export default defineComponent({ // establish new connection when dragging connection from one node to another this.historyStore.startRecordingUndo(); const sourceNode = this.workflowsStore.getNodeById(info.connection.parameters.nodeId); + + if (!sourceNode) { + throw new Error('Could not find source node'); + } + const sourceNodeName = sourceNode.name; const outputIndex = info.connection.parameters.index; const overrideTargetEndpoint = info.connection.connector @@ -3398,7 +3437,7 @@ export default defineComponent({ ) { // Ff connection being detached by user, save this in history // but skip if it's detached as a side effect of bulk undo/redo or node rename process - const removeCommand = new RemoveConnectionCommand(connectionInfo, this); + const removeCommand = new RemoveConnectionCommand(connectionInfo); this.historyStore.pushCommandToUndo(removeCommand); } @@ -3426,7 +3465,9 @@ export default defineComponent({ const requiredType = connectionType === 'source' ? 'target' : 'source'; const filteredEndpoints = scopedEndpoints.filter((el) => { - const endpoint = el.jtk.endpoint as Endpoint; + if (!isJSPlumbEndpointElement(el)) return false; + + const endpoint = el.jtk.endpoint; if (!endpoint) return false; // Prevent snapping(but not connecting) to the same node @@ -3443,6 +3484,7 @@ export default defineComponent({ const intersectingEndpoints = filteredEndpoints .filter((element: Element) => { + if (!isJSPlumbEndpointElement(element)) return false; const endpoint = element.jtk.endpoint as Endpoint; if (element.classList.contains('jtk-floating-endpoint')) { @@ -3486,7 +3528,10 @@ export default defineComponent({ return bEndpointIntersect.y - aEndpointIntersect.y; }); - if (intersectingEndpoints.length > 0) { + if ( + intersectingEndpoints.length > 0 && + isJSPlumbEndpointElement(intersectingEndpoints[0]) + ) { const intersectingEndpoint = intersectingEndpoints[0]; const endpoint = intersectingEndpoint.jtk.endpoint as Endpoint; const node = this.workflowsStore.getNodeById(endpoint.parameters.nodeId); @@ -3526,7 +3571,7 @@ export default defineComponent({ console.error(e); } }, - onConnectionDragAbortDetached(connection: Connection) { + onConnectionDragAbortDetached() { Object.values(this.instance?.endpointsByElement) .flatMap((endpoints) => Object.values(endpoints)) .filter((endpoint) => endpoint.endpoint.type === 'N8nPlus') @@ -3539,7 +3584,7 @@ export default defineComponent({ sourceId: endpoint.__meta.nodeId, index: endpoint.__meta.index, eventSource: NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT, - outputType: endpoint.scope as NodeConnectionType, + outputType: getEndpointScope(endpoint.scope), endpointUuid: endpoint.uuid, stepName: endpoint.__meta.nodeName, }; @@ -3554,7 +3599,7 @@ export default defineComponent({ sourceId: endpoint.__meta.nodeId, index: endpoint.__meta.index, eventSource: NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT, - outputType: endpoint.scope as ConnectionTypes, + outputType: getEndpointScope(endpoint.scope), endpointUuid: endpoint.uuid, stepName: endpoint.__meta.nodeName, }); @@ -3567,7 +3612,7 @@ export default defineComponent({ index: endpoint.__meta.index, eventSource: NODE_CREATOR_OPEN_SOURCES.ADD_INPUT_ENDPOINT, nodeCreatorView: AI_NODE_CREATOR_VIEW, - outputType: endpoint.scope as ConnectionTypes, + outputType: getEndpointScope(endpoint.scope), endpointUuid: endpoint.uuid, }); } @@ -3622,7 +3667,7 @@ export default defineComponent({ this.instance.unbind(EVENT_ADD_INPUT_ENDPOINT_CLICK, this.onAddInputEndpointClick); this.eventsAttached = false; }, - unbindEndpointEventListeners(bind = true) { + unbindEndpointEventListeners() { if (this.instance) { // Get all the endpoints and unbind the events const elements = this.instance.getManagedElements(); @@ -3714,7 +3759,7 @@ export default defineComponent({ this.blankRedirect = false; } else if (this.$route.name === VIEWS.TEMPLATE_IMPORT) { const templateId = this.$route.params.id; - await this.openWorkflowTemplate(templateId); + await this.openWorkflowTemplate(templateId.toString()); } else { if (this.uiStore.stateIsDirty && !this.readOnlyEnv) { const confirmModal = await this.confirm( @@ -3734,7 +3779,7 @@ export default defineComponent({ if (confirmModal === MODAL_CONFIRM) { const saved = await this.workflowHelpers.saveCurrentWorkflow(); if (saved) await this.settingsStore.fetchPromptsData(); - } else if (confirmModal === MODAL_CLOSE) { + } else if (confirmModal === MODAL_CANCEL) { return; } } @@ -3742,7 +3787,7 @@ export default defineComponent({ // Load a workflow let workflowId = null as string | null; if (this.$route.params.name) { - workflowId = this.$route.params.name; + workflowId = this.$route.params.name.toString(); } if (workflowId !== null) { let workflow: IWorkflowDb | undefined = undefined; @@ -3795,7 +3840,7 @@ export default defineComponent({ }, getOutputEndpointUUID( nodeName: string, - connectionType: ConnectionTypes, + connectionType: NodeConnectionType, index: number, ): string | null { const node = this.workflowsStore.getNodeByName(nodeName); @@ -3805,7 +3850,7 @@ export default defineComponent({ return NodeViewUtils.getOutputEndpointUUID(node.id, connectionType, index); }, - getInputEndpointUUID(nodeName: string, connectionType: ConnectionTypes, index: number) { + getInputEndpointUUID(nodeName: string, connectionType: NodeConnectionType, index: number) { const node = this.workflowsStore.getNodeByName(nodeName); if (!node) { return null; @@ -3816,12 +3861,12 @@ export default defineComponent({ __addConnection(connection: [IConnection, IConnection]) { const outputUuid = this.getOutputEndpointUUID( connection[0].node, - connection[0].type as ConnectionTypes, + connection[0].type, connection[0].index, ); const inputUuid = this.getInputEndpointUUID( connection[1].node, - connection[1].type as ConnectionTypes, + connection[1].type, connection[1].index, ); if (!outputUuid || !inputUuid) { @@ -3836,7 +3881,7 @@ export default defineComponent({ }); setTimeout(() => { - this.addPinDataConnections(this.workflowsStore.pinnedWorkflowData || ({} as IPinData)); + this.addPinDataConnections(this.workflowsStore.pinnedWorkflowData ?? ({} as IPinData)); }); }, __removeConnection(connection: [IConnection, IConnection], removeVisualConnection = false) { @@ -3848,35 +3893,31 @@ export default defineComponent({ return; } - const sourceNodeType = this.nodeTypesStore.getNodeType( - sourceNode.type, - sourceNode.typeVersion, - ); - const sourceNodeOutput = - sourceNodeType?.outputs?.[connection[0].index] || NodeConnectionType.Main; - const sourceNodeOutputName = - typeof sourceNodeOutput === 'string' ? sourceNodeOutput : sourceNodeOutput.name; - const scope = NodeViewUtils.getEndpointScope(sourceNodeOutputName); + const sourceElement = document.getElementById(sourceNode.id); + const targetElement = document.getElementById(targetNode.id); - const connections = this.instance?.getConnections({ - scope, - source: sourceNode.id, - target: targetNode.id, - }); + if (sourceElement && targetElement) { + const connections = this.instance?.getConnections({ + source: sourceElement, + target: targetElement, + }); - connections.forEach((connectionInstance: Connection) => { - if (connectionInstance.__meta) { - // Only delete connections from specific indexes (if it can be determined by meta) - if ( - connectionInstance.__meta.sourceOutputIndex === connection[0].index && - connectionInstance.__meta.targetOutputIndex === connection[1].index - ) { - this.__deleteJSPlumbConnection(connectionInstance); - } - } else { - this.__deleteJSPlumbConnection(connectionInstance); + if (Array.isArray(connections)) { + connections.forEach((connectionInstance: Connection) => { + if (connectionInstance.__meta) { + // Only delete connections from specific indexes (if it can be determined by meta) + if ( + connectionInstance.__meta.sourceOutputIndex === connection[0].index && + connectionInstance.__meta.targetOutputIndex === connection[1].index + ) { + this.__deleteJSPlumbConnection(connectionInstance); + } + } else { + this.__deleteJSPlumbConnection(connectionInstance); + } + }); } - }); + } } this.workflowsStore.removeConnection({ connection }); @@ -3901,11 +3942,15 @@ export default defineComponent({ type: NodeConnectionType.Main, }, ]; - const removeCommand = new RemoveConnectionCommand(connectionData, this); + const removeCommand = new RemoveConnectionCommand(connectionData); this.historyStore.pushCommandToUndo(removeCommand); } }, - __removeConnectionByConnectionInfo(info, removeVisualConnection = false, trackHistory = false) { + __removeConnectionByConnectionInfo( + info: ConnectionDetachedParams, + removeVisualConnection = false, + trackHistory = false, + ) { const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info); if (connectionInfo) { @@ -3932,19 +3977,21 @@ export default defineComponent({ const node = this.workflowsStore.getNodeByName(nodeName); if (node) { - // @ts-ignore + const nodeEl = document.getElementById(node.id); + if (!nodeEl) { + return { incoming: [], outgoing: [] }; + } + const outgoing = this.instance?.getConnections({ - source: node.id, + source: nodeEl, }); - // @ts-ignore const incoming = this.instance?.getConnections({ - target: node.id, - }) as Connection[]; - + target: nodeEl, + }); return { - incoming, - outgoing, + incoming: Array.isArray(incoming) ? incoming : Object.values(incoming), + outgoing: Array.isArray(outgoing) ? outgoing : Object.values(outgoing), }; } return { incoming: [], outgoing: [] }; @@ -3990,13 +4037,20 @@ export default defineComponent({ const sourceId = sourceNode !== null ? sourceNode.id : ''; if (data === null || data.length === 0 || waiting) { - const outgoing = this.instance?.getConnections({ - source: sourceId, - }) as Connection[]; + const sourceElement = document.getElementById(sourceId); + if (!sourceElement) { + return; + } - outgoing.forEach((connection: Connection) => { - NodeViewUtils.resetConnection(connection); + const outgoing = this.instance?.getConnections({ + source: sourceElement, }); + + (Array.isArray(outgoing) ? outgoing : Object.values(outgoing)).forEach( + (connection: Connection) => { + NodeViewUtils.resetConnection(connection); + }, + ); const endpoints = NodeViewUtils.getJSPlumbEndpoints(sourceNode, this.instance); endpoints.forEach((endpoint: Endpoint) => { if (endpoint.endpoint.type === 'N8nPlus') { @@ -4067,7 +4121,7 @@ export default defineComponent({ const workflowNode = workflow.getNode(node.name); let inputs: Array = []; let outputs: Array = []; - if (nodeType) { + if (nodeType && workflowNode) { inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, nodeType); outputs = NodeHelpers.getNodeOutputs(workflow, workflowNode, nodeType); } @@ -4136,7 +4190,7 @@ export default defineComponent({ async onSwitchSelectedNode(nodeName: string) { this.nodeSelectedByName(nodeName, true, true); }, - async onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) { + async onOpenConnectionNodeCreator(node: string, connectionType: NodeConnectionType) { await this.openSelectiveNodeCreator({ connectiontype: connectionType, node, @@ -4189,9 +4243,11 @@ export default defineComponent({ nameInput.select(); } - const promptResponse = (await promptResponsePromise) as MessageBoxInputData; + const promptResponse = await promptResponsePromise; - if (promptResponse?.action !== MODAL_CONFIRM) return; + if (promptResponse?.action !== MODAL_CONFIRM) { + return; + } await this.renameNode(currentName, promptResponse.value, true); } catch (e) {} @@ -4331,29 +4387,33 @@ export default defineComponent({ // Add the node to the node-list let nodeType: INodeTypeDescription | null; nodes.forEach((node) => { - if (!node.id) { - node.id = uuid(); + const newNode: INodeUi = { + ...node, + }; + + if (!newNode.id) { + newNode.id = uuid(); } - nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion); + nodeType = this.nodeTypesStore.getNodeType(newNode.type, newNode.typeVersion); // Make sure that some properties always exist - if (!node.hasOwnProperty('disabled')) { - node.disabled = false; + if (!newNode.hasOwnProperty('disabled')) { + newNode.disabled = false; } - if (!node.hasOwnProperty('parameters')) { - node.parameters = {}; + if (!newNode.hasOwnProperty('parameters')) { + newNode.parameters = {}; } - // Load the defaul parameter values because only values which differ + // 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, + newNode.parameters, true, false, node, @@ -4361,26 +4421,26 @@ export default defineComponent({ } catch (e) { console.error( this.$locale.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') + - `: "${node.name}"`, + `: "${newNode.name}"`, ); console.error(e); } - node.parameters = nodeParameters !== null ? nodeParameters : {}; + newNode.parameters = 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 === '' + [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(newNode.type) && + newNode.parameters.path === '' ) { - node.parameters.path = node.webhookId as string; + newNode.parameters.path = newNode.webhookId as string; } } // check and match credentials, apply new format if old is used - this.matchCredentials(node); - this.workflowsStore.addNode(node); + this.matchCredentials(newNode); + this.workflowsStore.addNode(newNode); if (trackHistory) { - this.historyStore.pushCommandToUndo(new AddNodeCommand(node)); + this.historyStore.pushCommandToUndo(new AddNodeCommand(newNode)); } }); @@ -4410,7 +4470,11 @@ export default defineComponent({ if (outwardConnections) { outwardConnections.forEach((targetData) => { batchedConnectionData.push([ - { node: sourceNode, type, index: sourceIndex }, + { + node: sourceNode, + type: getEndpointScope(type) ?? NodeConnectionType.Main, + index: sourceIndex, + }, { node: targetData.node, type: targetData.type, index: targetData.index }, ]); }); @@ -4833,13 +4897,19 @@ export default defineComponent({ if (hasRun) { classNames.push('has-run'); } - - // @ts-ignore + const nodeElement = document.getElementById(node.id); + if (!nodeElement) { + return; + } const connections = this.instance?.getConnections({ - source: node.id, - }) as Connection[]; + source: nodeElement, + }); - connections.forEach((connection) => { + const connectionsArray = Array.isArray(connections) + ? connections + : Object.values(connections); + + connectionsArray.forEach((connection) => { NodeViewUtils.addConnectionOutputSuccess(connection, { total: pinData[nodeName].length, iterations: 0, @@ -4855,13 +4925,21 @@ export default defineComponent({ return; } - // @ts-ignore + const nodeElement = document.getElementById(node.id); + if (!nodeElement) { + return; + } + const connections = this.instance?.getConnections({ - source: node.id, - }) as Connection[]; + source: nodeElement, + }); + + const connectionsArray = Array.isArray(connections) + ? connections + : Object.values(connections); this.instance.setSuspendDrawing(true); - connections.forEach(NodeViewUtils.resetConnection); + connectionsArray.forEach(NodeViewUtils.resetConnection); this.instance.setSuspendDrawing(false, true); }); }, @@ -4891,7 +4969,7 @@ export default defineComponent({ mode = 'regular'; } - if (createNodeActive) this.nodeCreatorStore.setOpenSource(source); + if (createNodeActive && source) this.nodeCreatorStore.setOpenSource(source); void this.externalHooks.run('nodeView.createNodeActiveChanged', { source, mode, @@ -5003,7 +5081,7 @@ export default defineComponent({ async onRevertNameChange({ currentName, newName }: { currentName: string; newName: string }) { await this.renameNode(newName, currentName); }, - onRevertEnableToggle({ nodeName, isDisabled }: { nodeName: string; isDisabled: boolean }) { + onRevertEnableToggle({ nodeName }: { nodeName: string }) { const node = this.workflowsStore.getNodeByName(nodeName); if (node) { this.nodeHelpers.disableNodes([node]); @@ -5018,7 +5096,7 @@ export default defineComponent({ readOnlyEnvRouteCheck() { if ( this.readOnlyEnv && - [VIEWS.NEW_WORKFLOW, VIEWS.TEMPLATE_IMPORT].includes(this.$route.name) + (this.$route.name === VIEWS.NEW_WORKFLOW || this.$route.name === VIEWS.TEMPLATE_IMPORT) ) { void this.$nextTick(async () => { this.resetWorkspace(); @@ -5080,27 +5158,31 @@ export default defineComponent({ break; } }, - }, - async onSourceControlPull() { - let workflowId = null as string | null; - if (this.$route.params.name) { - workflowId = this.$route.params.name; - } - - try { - await Promise.all([this.loadVariables(), this.tagsStore.fetchAll(), this.loadCredentials()]); - - if (workflowId !== null && !this.uiStore.stateIsDirty) { - const workflow: IWorkflowDb | undefined = - await this.workflowsStore.fetchWorkflow(workflowId); - if (workflow) { - this.titleSet(workflow.name, 'IDLE'); - await this.openWorkflow(workflow); - } + async onSourceControlPull() { + let workflowId = null as string | null; + if (this.$route.params.name) { + workflowId = this.$route.params.name.toString(); } - } catch (error) { - console.error(error); - } + + try { + await Promise.all([ + this.loadVariables(), + this.tagsStore.fetchAll(), + this.loadCredentials(), + ]); + + if (workflowId !== null && !this.uiStore.stateIsDirty) { + const workflow: IWorkflowDb | undefined = + await this.workflowsStore.fetchWorkflow(workflowId); + if (workflow) { + this.titleSet(workflow.name, 'IDLE'); + await this.openWorkflow(workflow); + } + } + } catch (error) { + console.error(error); + } + }, }, }); diff --git a/packages/editor-ui/src/views/SettingsView.vue b/packages/editor-ui/src/views/SettingsView.vue index 0af8d1da15..00214271f5 100644 --- a/packages/editor-ui/src/views/SettingsView.vue +++ b/packages/editor-ui/src/views/SettingsView.vue @@ -25,7 +25,7 @@ const SettingsView = defineComponent({ components: { SettingsSidebar, }, - beforeRouteEnter(to, from, next) { + beforeRouteEnter(_to, from, next) { next((vm) => { (vm as unknown as InstanceType).previousRoute = from; }); diff --git a/packages/editor-ui/src/views/WorkflowOnboardingView.vue b/packages/editor-ui/src/views/WorkflowOnboardingView.vue index 8988702c6f..8fa4a6367a 100644 --- a/packages/editor-ui/src/views/WorkflowOnboardingView.vue +++ b/packages/editor-ui/src/views/WorkflowOnboardingView.vue @@ -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: { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index d70bb4b620..7485cdbf48 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -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; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 1b18936e3d..d857d43d21 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -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; }