n8n/packages/editor-ui/src/views/NodeView.vue
Oliver Trajceski 42742de876
Add Value Survey (#2499)
* N8N-2619 Value Survey Front-end

* N8N-2619 Added Contact Prompt Modal and logic

* N8N-2619 Added Link to Toast Message on Successful submitting ValueSurvey

* N8N-2619 Updated TypeForm URL in ValueSurvey Success Toast

* N8N-2619 Fixed Typo placeholder for ValueSurvey and ContactPrompt Modal

* N8N-2619 Fixed Toast not close automatically in ValueSurvey, Make part of the title bold, Changed Font-sizes on Value Survey

* N8N-2619 Fixed Close Button on ValueSurvey, Vertical Allignment for Questions in ValueSurvey Drawer

* N8N-2619 Make Value Survey with static height

* N8N-2619 Fixed Telemetry Events on closing ValueSurvey

* N8N-2619 Updated N8NPrompt Interface, Added Dynamic Title and Description on ContactPrompt Modal

* N8N-2619 Reversed Answers in ValueSurveyModal

* N8N-2619 Added Telemetry Event on user close ValueSurvey on second Question

* N8N-2619 Re-work, Optimized, Simplify the code after technical review

* N8N-2619 Fixed If else statement in openUserPromptsIfPossible

* N8N-2619 Change Text under Email Box - ValueSurvey, ContactPrompt, Added new Telemetary Event on ValueSurvey Open, Fixed Toast to close aftet 15s

* N8N-2619 Change ContactPrompt Modal to use Atoms like N8N-Heading and N8N-Text

* N8N-2619 Change Design & Logic on ValueSurvey - When to open

* N8N-2619 Updated Value Survey with new Telemetry Events (Refactor), Simplified functions, Added Atoms in ValueSurvey + ContactPrompt

* N8N-2619 Refactor in Interfaces, Updated/Refactor Getters and Vuex store props

* N8N-2619 Defined IN8nValueSurveyData interface

* N8N-2619 Disabled Keyboard shortcuts to be activated on typing in ValueSurvey Input field, Fire an event on Saving WF from Menu and with shorcut, Make Drawer keep-alive

* N8N-2619 Added Atoms in Value Survey Modal (buttons), Rework css

* N8N-2619 Added Responses on ValueSurvey Submit

* N8N-2619 Added Response for SubmittingContactInfo

* N8N-2619 Added loading state for buttons / ValueSurvey

* N8N-2619 Changed ValueSurvey and ContactPrompt to support enter key on submit, Simplifed closeDialog Function, Changed css for button in ValueSurvey, Prevent showing the Modals if Saving WF fails, Add Debouncing on showing prompt

* N8N-2619 Added IsTelemetryEnabled Getter in Vuex.store

* N8N-2619 Created/Added N8N-SquareButton to Design-system

* N8N-2619 Change Promise in MainSideBar with Async/Await function, Nitpick simpliefied

* N8N-2619 Update the text under the input fields

* N8N-2619 Update the text in ContactPrompt Modal

* N8N-2619 Allign Send button on ValueSurvey Modal

* N8N-2619 Fixed Input in ValueSurvey Modal

* N8N-2619 Check if the workflow is saving

* N8N-2619 Check if WF is saving to allowed performs fetchPromptsData

* N8N-2619 Hotfix

* N8N-2619 Fixed ValueSurvey, Updated onSaveButtonClick function, Created onSaveKeyboardShortcut function in NodeView

* N8N-2619 Rework css module classes for ValueSurvey, Simplified

* N8N-2619 Simplified N8N-SquareButton Component, removed dead code

* N8N-2619 Added Breakpoints for Mobile/Tablet View

* N8N-2619 Formatting fix

* N8N-2619 Update css for mobile/tablet, change promises to asyn/await functions in ContactPrompt and ValueSurvey, Added isActive prop to ValueSurvey

* N8N-2619 Update TEMPLATE_BASE_URL to production
2021-12-11 17:38:16 +01:00

2758 lines
88 KiB
Vue

<template>
<div class="node-view-root">
<div
class="node-view-wrapper"
:class="workflowClasses"
@touchstart="mouseDown"
@touchend="mouseUp"
@touchmove="mouseMoveNodeWorkflow"
@mousedown="mouseDown"
v-touch:tap="touchTap"
@mouseup="mouseUp"
@wheel="wheelScroll"
>
<div id="node-view-background" class="node-view-background" :style="backgroundStyle"></div>
<div id="node-view" class="node-view" :style="workflowStyle">
<node
v-for="nodeData in nodes"
@duplicateNode="duplicateNode"
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
@runWorkflow="runWorkflow"
@moved="onNodeMoved"
@run="onNodeRun"
:id="'node-' + getNodeIndex(nodeData.name)"
:key="getNodeIndex(nodeData.name)"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name"
:hideActions="pullConnActive"
></node>
</div>
</div>
<DataDisplay @valueChanged="valueChanged"/>
<div v-if="!createNodeActive && !isReadOnly" class="node-creator-button" title="Add Node" @click="() => openNodeCreator('add_node_button')">
<n8n-icon-button size="xlarge" icon="plus" />
</div>
<node-creator
:active="createNodeActive"
@nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator"
></node-creator>
<div :class="{ 'zoom-menu': true, expanded: !sidebarMenuCollapsed }">
<button @click="zoomToFit" class="button-white" title="Zoom to Fit">
<font-awesome-icon icon="expand"/>
</button>
<button @click="zoomIn()" class="button-white" title="Zoom In">
<font-awesome-icon icon="search-plus"/>
</button>
<button @click="zoomOut()" class="button-white" title="Zoom Out">
<font-awesome-icon icon="search-minus"/>
</button>
<button
v-if="nodeViewScale !== 1"
@click="resetZoom()"
class="button-white"
title="Reset Zoom"
>
<font-awesome-icon icon="undo" title="Reset Zoom"/>
</button>
</div>
<div class="workflow-execute-wrapper" v-if="!isReadOnly">
<n8n-button
@click.stop="runWorkflow()"
:loading="workflowRunning"
:label="runButtonText"
size="large"
icon="play-circle"
title="Executes the Workflow from the Start or Webhook Node."
:type="workflowRunning ? 'light' : 'primary'"
/>
<n8n-icon-button
v-if="workflowRunning === true && !executionWaitingForWebhook"
icon="stop"
size="large"
class="stop-execution"
type="light"
:title="stopExecutionInProgress ? 'Stopping current execution':'Stop current execution'"
:loading="stopExecutionInProgress"
@click.stop="stopExecution()"
/>
<n8n-icon-button
v-if="workflowRunning === true && executionWaitingForWebhook === true"
class="stop-execution"
icon="stop"
size="large"
title="Stop waiting for Webhook call"
type="light"
@click.stop="stopWaitingForWebhook()"
/>
<n8n-icon-button
v-if="!isReadOnly && workflowExecution && !workflowRunning"
title="Deletes the current Execution Data."
icon="trash"
size="large"
@click.stop="clearExecutionData()"
/>
</div>
<Modals />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
Connection, Endpoint, N8nPlusEndpoint,
} from 'jsplumb';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import { NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mouseSelect } from '@/components/mixins/mouseSelect';
import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { newVersions } from '@/components/mixins/newVersions';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import DataDisplay from '@/components/DataDisplay.vue';
import Modals from '@/components/Modals.vue';
import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';
import * as CanvasHelpers from './canvasHelpers';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4} from 'uuid';
import {
IConnection,
IConnections,
IDataObject,
INode,
INodeConnections,
INodeIssues,
INodeTypeDescription,
INodeTypeNameVersion,
NodeHelpers,
Workflow,
IRun,
ITaskData,
INodeCredentialsDetails,
} from 'n8n-workflow';
import {
ICredentialsResponse,
IExecutionResponse,
IWorkflowDb,
IWorkflowData,
INodeUi,
IUpdateInformation,
IWorkflowDataUpdate,
XYPosition,
IPushDataExecutionFinished,
ITag,
IWorkflowTemplate,
IExecutionsSummary,
} from '../Interface';
import { mapGetters } from 'vuex';
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
export default mixins(
copyPaste,
externalHooks,
genericHelpers,
mouseSelect,
moveNodeWorkflow,
restApi,
showMessage,
titleChange,
workflowHelpers,
workflowRun,
newVersions,
)
.extend({
name: 'NodeView',
components: {
DataDisplay,
Modals,
Node,
NodeCreator,
NodeSettings,
RunData,
},
errorCaptured: (err, vm, info) => {
console.error('errorCaptured'); // eslint-disable-line no-console
console.error(err); // eslint-disable-line no-console
},
watch: {
// Listen to route changes and load the workflow accordingly
'$route': 'initView',
activeNode () {
// When a node gets set as active deactivate the create-menu
this.createNodeActive = false;
},
nodes: {
async handler (value, oldValue) {
// Load a workflow
let workflowId = null as string | null;
if (this.$route && this.$route.params.name) {
workflowId = this.$route.params.name;
}
},
deep: true,
},
connections: {
async handler (value, oldValue) {
// Load a workflow
let workflowId = null as string | null;
if (this.$route && this.$route.params.name) {
workflowId = this.$route.params.name;
}
},
deep: true,
},
},
async beforeRouteLeave(to, from, next) {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes');
if (importConfirm === false) {
next(false);
} else {
// Prevent other popups from displaying
this.$store.commit('setStateDirty', false);
next();
}
} else {
next();
}
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
activeNode (): INodeUi | null {
return this.$store.getters.activeNode;
},
executionWaitingForWebhook (): boolean {
return this.$store.getters.executionWaitingForWebhook;
},
lastSelectedNode (): INodeUi | null {
return this.$store.getters.lastSelectedNode;
},
nodes (): INodeUi[] {
return this.$store.getters.allNodes;
},
runButtonText (): string {
if (this.workflowRunning === false) {
return 'Execute Workflow';
}
if (this.executionWaitingForWebhook === true) {
return 'Waiting for Trigger Event';
}
return 'Executing Workflow';
},
workflowStyle (): object {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
return {
left: offsetPosition[0] + 'px',
top: offsetPosition[1] + 'px',
};
},
backgroundStyle (): object {
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},
workflowClasses () {
const returnClasses = [];
if (this.ctrlKeyPressed === true) {
if (this.$store.getters.isNodeViewMoveInProgress === true) {
returnClasses.push('move-in-process');
} else {
returnClasses.push('move-active');
}
}
if (this.selectActive || this.ctrlKeyPressed === true) {
// Makes sure that nothing gets selected while select or move is active
returnClasses.push('do-not-select');
}
return returnClasses;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
data () {
return {
createNodeActive: false,
instance: jsPlumb.getInstance(),
lastSelectedConnection: null as null | Connection,
lastClickPosition: [450, 450] as XYPosition,
nodeViewScale: 1,
ctrlKeyPressed: false,
stopExecutionInProgress: false,
blankRedirect: false,
credentialsUpdated: false,
newNodeInsertPosition: null as XYPosition | null,
pullConnActiveNodeName: null as string | null,
pullConnActive: false,
dropPrevented: false,
};
},
beforeDestroy () {
// Make sure the event listeners get removed again else we
// could add up with them registred multiple times
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
},
methods: {
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
async onSaveKeyboardShortcut () {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
openNodeCreator (source: string) {
this.createNodeActive = true;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive: this.createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, workflow_id: this.$store.getters.workflowId, createNodeActive: this.createNodeActive });
},
async openExecution (executionId: string) {
this.resetWorkspace();
let data: IExecutionResponse | undefined;
try {
data = await this.restApi().getExecution(executionId);
} catch (error) {
this.$showError(error, 'Problem loading execution', 'There was a problem opening the execution:');
return;
}
if (data === undefined) {
throw new Error(`Execution with id "${executionId}" could not be found!`);
}
this.$store.commit('setWorkflowName', {newName: data.workflowData.name, setStateDirty: false});
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowExecutionData', data);
await this.addNodes(JSON.parse(JSON.stringify(data.workflowData.nodes)), JSON.parse(JSON.stringify(data.workflowData.connections)));
this.$nextTick(() => {
this.zoomToFit();
this.$store.commit('setStateDirty', false);
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
if (data.finished !== true && data && data.data && data.data.resultData && data.data.resultData.error) {
// Check if any node contains an error
let nodeErrorFound = false;
if (data.data.resultData.runData) {
const runData = data.data.resultData.runData;
errorCheck:
for (const nodeName of Object.keys(runData)) {
for (const taskData of runData[nodeName]) {
if (taskData.error) {
nodeErrorFound = true;
break errorCheck;
}
}
}
}
if (nodeErrorFound === false) {
const resultError = data.data.resultData.error;
const errorMessage = this.$getExecutionError(resultError);
const shouldTrack = resultError && resultError.node && resultError.node.type.startsWith('n8n-nodes-base');
this.$showMessage({
title: 'Failed execution',
message: errorMessage,
type: 'error',
}, shouldTrack);
if (data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
// TODO: Improve this in the future by displaying in UI
console.error(`Execution ${executionId} error:`); // eslint-disable-line no-console
console.error(data.data.resultData.error.stack); // eslint-disable-line no-console
}
}
}
if ((data as IExecutionsSummary).waitTill) {
this.$showMessage({
title: `This execution hasn't finished yet`,
message: `<a onclick="window.location.reload(false);">Refresh</a> to see the latest status.<br/> <a href="https://docs.n8n.io/nodes/n8n-nodes-base.wait/" target="_blank">More info</a>`,
type: 'warning',
duration: 0,
});
}
},
async openWorkflowTemplate (templateId: string) {
this.setLoadingText('Loading template');
this.resetWorkspace();
let data: IWorkflowTemplate | undefined;
try {
this.$externalHooks().run('template.requested', { templateId });
data = await this.$store.dispatch('workflows/getWorkflowTemplate', templateId);
if (!data) {
throw new Error(`Workflow template with id "${templateId}" could not be found!`);
}
data.workflow.nodes.forEach((node) => {
if (!this.$store.getters.nodeType(node.type)) {
const name = node.type.replace('n8n-nodes-base.', '');
throw new Error(`The ${name} node is not supported`);
}
});
} catch (error) {
this.$showError(error, `Couldn't import workflow`);
this.$router.push({ name: 'NodeViewNew' });
return;
}
const nodes = data.workflow.nodes;
const hasStartNode = !!nodes.find(node => node.type === START_NODE_TYPE);
const leftmostTop = CanvasHelpers.getLeftmostTopNode(nodes);
const diffX = CanvasHelpers.DEFAULT_START_POSITION_X - leftmostTop.position[0];
const diffY = CanvasHelpers.DEFAULT_START_POSITION_Y - leftmostTop.position[1];
data.workflow.nodes.map((node) => {
node.position[0] += diffX + (hasStartNode? 0 : CanvasHelpers.NODE_SIZE * 2);
node.position[1] += diffY;
});
if (!hasStartNode) {
data.workflow.nodes.push({...CanvasHelpers.DEFAULT_START_NODE});
}
this.blankRedirect = true;
this.$router.push({ name: 'NodeViewNew', query: { templateId } });
await this.addNodes(data.workflow.nodes, data.workflow.connections);
await this.$store.dispatch('workflows/setNewWorkflowName', data.name);
this.$nextTick(() => {
this.zoomToFit();
this.$store.commit('setStateDirty', true);
});
this.$externalHooks().run('template.open', { templateId, templateName: data.name, workflow: data.workflow });
},
async openWorkflow (workflowId: string) {
this.resetWorkspace();
let data: IWorkflowDb | undefined;
try {
data = await this.restApi().getWorkflow(workflowId);
} catch (error) {
this.$showError(error, 'Problem opening workflow', 'There was a problem opening the workflow:');
return;
}
if (data === undefined) {
throw new Error(`Workflow with id "${workflowId}" could not be found!`);
}
this.$store.commit('setActive', data.active || false);
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', {newName: data.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', data.settings || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
if (!this.credentialsUpdated) {
this.$store.commit('setStateDirty', false);
}
this.zoomToFit();
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
return data;
},
touchTap (e: MouseEvent | TouchEvent) {
if (this.isTouchDevice) {
this.mouseDown(e);
}
},
mouseDown (e: MouseEvent | TouchEvent) {
// Save the location of the mouse click
this.lastClickPosition = this.getMousePositionWithinNodeView(e);
this.mouseDownMouseSelect(e as MouseEvent);
this.mouseDownMoveWorkflow(e as MouseEvent);
// Hide the node-creator
this.createNodeActive = false;
},
mouseUp (e: MouseEvent) {
this.mouseUpMouseSelect(e);
this.mouseUpMoveWorkflow(e);
},
wheelScroll (e: WheelEvent) {
//* Control + scroll zoom
if (e.ctrlKey) {
if (e.deltaY > 0) {
this.zoomOut();
} else {
this.zoomIn();
}
e.preventDefault();
return;
}
this.wheelMoveWorkflow(e);
},
keyUp (e: KeyboardEvent) {
if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = false;
}
},
async keyDown (e: KeyboardEvent) {
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
// Check if the keys got emitted from a message box or from something
// else which should ignore the default keybindings
for (let index = 0; index < path.length; index++) {
if (path[index].className && typeof path[index].className === 'string' && (
path[index].className.includes('ignore-key-press')
)) {
return;
}
}
// el-dialog or el-message-box element is open
if (window.document.body.classList.contains('el-popup-parent--hidden')) {
return;
}
if (e.key === 'Escape') {
this.createNodeActive = false;
if (this.activeNode) {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.$store.commit('setActiveNode', null);
}
return;
}
// node modal is open
if (this.activeNode) {
return;
}
if (e.key === 'd') {
this.callDebounced('deactivateSelectedNode', 350);
} else if (e.key === 'Delete') {
e.stopPropagation();
e.preventDefault();
this.callDebounced('deleteSelectedNodes', 500);
} else if (e.key === 'Tab') {
this.createNodeActive = !this.createNodeActive && !this.isReadOnly;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source: 'tab', createNodeActive: this.createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source: 'tab', workflow_id: this.$store.getters.workflowId, createNodeActive: this.createNodeActive });
} else if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = true;
} else if (e.key === 'F2' && !this.isReadOnly) {
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) {
this.callDebounced('renameNodePrompt', 1500, lastSelectedNode.name);
}
} else if ((e.key === '=' || e.key === '+') && !this.isCtrlKeyPressed(e)) {
this.zoomIn();
} else if ((e.key === '_' || e.key === '-') && !this.isCtrlKeyPressed(e)) {
this.zoomOut();
} else if ((e.key === '0') && !this.isCtrlKeyPressed(e)) {
this.resetZoom();
} else if ((e.key === '1') && !this.isCtrlKeyPressed(e)) {
this.zoomToFit();
} else if ((e.key === 'a') && (this.isCtrlKeyPressed(e) === true)) {
// Select all nodes
e.stopPropagation();
e.preventDefault();
this.callDebounced('selectAllNodes', 1000);
} else if ((e.key === 'c') && (this.isCtrlKeyPressed(e) === true)) {
this.callDebounced('copySelectedNodes', 1000);
} else if ((e.key === 'x') && (this.isCtrlKeyPressed(e) === true)) {
// Cut nodes
e.stopPropagation();
e.preventDefault();
this.callDebounced('cutSelectedNodes', 1000);
} else if (e.key === 'o' && this.isCtrlKeyPressed(e) === true) {
// Open workflow dialog
e.stopPropagation();
e.preventDefault();
this.$store.dispatch('ui/openModal', WORKFLOW_OPEN_MODAL_KEY);
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) === true && e.altKey === true) {
// Create a new workflow
e.stopPropagation();
e.preventDefault();
if (this.$router.currentRoute.name === 'NodeViewNew') {
this.$root.$emit('newWorkflow');
} else {
this.$router.push({ name: 'NodeViewNew' });
}
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow was successfully created!',
type: 'success',
});
} else if ((e.key === 's') && (this.isCtrlKeyPressed(e) === true)) {
// Save workflow
e.stopPropagation();
e.preventDefault();
if (this.isReadOnly) {
return;
}
this.callDebounced('onSaveKeyboardShortcut', 1000);
} else if (e.key === 'Enter') {
// Activate the last selected node
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) {
this.$store.commit('setActiveNode', lastSelectedNode.name);
}
} else if (e.key === 'ArrowRight' && e.shiftKey === true) {
// Select all downstream nodes
e.stopPropagation();
e.preventDefault();
this.callDebounced('selectDownstreamNodes', 1000);
} else if (e.key === 'ArrowRight') {
// Set child node active
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode === null) {
return;
}
const connections = this.$store.getters.outgoingConnectionsByNodeName(lastSelectedNode.name);
if (connections.main === undefined || connections.main.length === 0) {
return;
}
this.callDebounced('nodeSelectedByName', 100, connections.main[0][0].node, false, true);
} else if (e.key === 'ArrowLeft' && e.shiftKey === true) {
// Select all downstream nodes
e.stopPropagation();
e.preventDefault();
this.callDebounced('selectUpstreamNodes', 1000);
} else if (e.key === 'ArrowLeft') {
// Set parent node active
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode === null) {
return;
}
const workflow = this.getWorkflow();
if (!workflow.connectionsByDestinationNode.hasOwnProperty(lastSelectedNode.name)) {
return;
}
const connections = workflow.connectionsByDestinationNode[lastSelectedNode.name];
if (connections.main === undefined || connections.main.length === 0) {
return;
}
this.callDebounced('nodeSelectedByName', 100, connections.main[0][0].node, false, true);
} else if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
// Set sibling node as active
// Check first if it has a parent node
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode === null) {
return;
}
const workflow = this.getWorkflow();
if (!workflow.connectionsByDestinationNode.hasOwnProperty(lastSelectedNode.name)) {
return;
}
const connections = workflow.connectionsByDestinationNode[lastSelectedNode.name];
if (connections.main === undefined || connections.main.length === 0) {
return;
}
const parentNode = connections.main[0][0].node;
const connectionsParent = this.$store.getters.outgoingConnectionsByNodeName(parentNode);
if (connectionsParent.main === undefined || connectionsParent.main.length === 0) {
return;
}
// Get all the sibling nodes and their x positions to know which one to set active
let siblingNode: INodeUi;
let lastCheckedNodePosition = e.key === 'ArrowUp' ? -99999999 : 99999999;
let nextSelectNode: string | null = null;
for (const ouputConnections of connectionsParent.main) {
for (const ouputConnection of ouputConnections) {
if (ouputConnection.node === lastSelectedNode.name) {
// Ignore current node
continue;
}
siblingNode = this.$store.getters.getNodeByName(ouputConnection.node);
if (e.key === 'ArrowUp') {
// Get the next node on the left
if (siblingNode.position[1] <= lastSelectedNode.position[1] && siblingNode.position[1] > lastCheckedNodePosition) {
nextSelectNode = siblingNode.name;
lastCheckedNodePosition = siblingNode.position[1];
}
} else {
// Get the next node on the right
if (siblingNode.position[1] >= lastSelectedNode.position[1] && siblingNode.position[1] < lastCheckedNodePosition) {
nextSelectNode = siblingNode.name;
lastCheckedNodePosition = siblingNode.position[1];
}
}
}
}
if (nextSelectNode !== null) {
this.callDebounced('nodeSelectedByName', 100, nextSelectNode, false, true);
}
}
},
deactivateSelectedNode () {
if (this.editAllowedCheck() === false) {
return;
}
this.disableNodes(this.$store.getters.getSelectedNodes);
},
deleteSelectedNodes () {
// Copy "selectedNodes" as the nodes get deleted out of selection
// when they get deleted and if we would use original it would mess
// with the index and would so not delete all nodes
const nodesToDelete: string[] = this.$store.getters.getSelectedNodes.map((node: INodeUi) => {
return node.name;
});
nodesToDelete.forEach((nodeName: string) => {
this.removeNode(nodeName);
});
},
selectAllNodes () {
this.nodes.forEach((node) => {
this.nodeSelectedByName(node.name);
});
},
selectUpstreamNodes () {
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode === null) {
return;
}
this.deselectAllNodes();
// Get all upstream nodes and select them
const workflow = this.getWorkflow();
for (const nodeName of workflow.getParentNodes(lastSelectedNode.name)) {
this.nodeSelectedByName(nodeName);
}
// At the end select the previously selected node again
this.nodeSelectedByName(lastSelectedNode.name);
},
selectDownstreamNodes () {
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode === null) {
return;
}
this.deselectAllNodes();
// Get all downstream nodes and select them
const workflow = this.getWorkflow();
for (const nodeName of workflow.getChildNodes(lastSelectedNode.name)) {
this.nodeSelectedByName(nodeName);
}
// At the end select the previously selected node again
this.nodeSelectedByName(lastSelectedNode.name);
},
pushDownstreamNodes (sourceNodeName: string, margin: number) {
const sourceNode = this.$store.getters.nodesByName[sourceNodeName];
const workflow = this.getWorkflow();
const childNodes = workflow.getChildNodes(sourceNodeName);
for (const nodeName of childNodes) {
const node = this.$store.getters.nodesByName[nodeName] as INodeUi;
if (node.position[0] < sourceNode.position[0]) {
continue;
}
const updateInformation = {
name: nodeName,
properties: {
position: [node.position[0] + margin, node.position[1]],
},
};
this.$store.commit('updateNodeProperties', updateInformation);
this.onNodeMoved(node);
}
},
cutSelectedNodes () {
this.copySelectedNodes(true);
this.deleteSelectedNodes();
},
copySelectedNodes (isCut: boolean) {
this.getSelectedNodesToSave().then((data) => {
const nodeData = JSON.stringify(data, null, 2);
this.copyToClipboard(nodeData);
if (data.nodes.length > 0) {
if(!isCut){
this.$showMessage({
title: 'Copied!',
message: '',
type: 'success',
});
}
this.$telemetry.track('User copied nodes', {
node_types: data.nodes.map((node) => node.type),
workflow_id: this.$store.getters.workflowId,
});
}
});
},
resetZoom () {
const { scale, offset } = CanvasHelpers.scaleReset({scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition});
this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: offset});
},
zoomIn() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleBigger({scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition});
this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [xOffset, yOffset]});
},
zoomOut() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleSmaller({scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition});
this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [xOffset, yOffset]});
},
setZoomLevel (zoomLevel: number) {
this.nodeViewScale = zoomLevel; // important for background
const element = this.instance.getContainer() as HTMLElement;
// https://docs.jsplumbtoolkit.com/community/current/articles/zooming.html
const prependProperties = ['webkit', 'moz', 'ms', 'o'];
const scaleString = 'scale(' + zoomLevel + ')';
for (let i = 0; i < prependProperties.length; i++) {
// @ts-ignore
element.style[prependProperties[i] + 'Transform'] = scaleString;
}
element.style['transform'] = scaleString;
// @ts-ignore
this.instance.setZoom(zoomLevel);
},
zoomToFit () {
const nodes = this.$store.getters.allNodes as INodeUi[];
if (nodes.length === 0) { // some unknown workflow executions
return;
}
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes);
this.setZoomLevel(zoomLevel);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: offset});
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
return;
}
try {
this.stopExecutionInProgress = true;
await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" was stopped!`,
type: 'success',
});
} catch (error) {
// Execution stop might fail when the execution has already finished. Let's treat this here.
const execution = await this.restApi().getExecution(executionId);
if (execution.finished) {
const executedData = {
data: execution.data,
finished: execution.finished,
mode: execution.mode,
startedAt: execution.startedAt,
stoppedAt: execution.stoppedAt,
} as IRun;
const pushData = {
data: executedData,
executionId,
retryOf: execution.retryOf,
} as IPushDataExecutionFinished;
this.$store.commit('finishActiveExecution', pushData);
this.$titleSet(execution.workflowData.name, 'IDLE');
this.$store.commit('setExecutingNode', null);
this.$store.commit('setWorkflowExecutionData', executedData);
this.$store.commit('removeActiveAction', 'workflowRunning');
this.$showMessage({
title: 'Workflow finished executing',
message: 'Unable to stop operation in time. Workflow finished executing already.',
type: 'success',
});
} else {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
}
}
this.stopExecutionInProgress = false;
},
async stopWaitingForWebhook () {
try {
await this.restApi().removeTestWebhook(this.$store.getters.workflowId);
} catch (error) {
this.$showError(error, 'Problem deleting the test-webhook', 'There was a problem deleting webhook:');
return;
}
},
/**
* This method gets called when data got pasted into the window
*/
async receivedCopyPasteData (plainTextData: string): Promise<void> {
let workflowData: IWorkflowDataUpdate | undefined;
// Check if it is an URL which could contain workflow data
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) {
// Pasted data points to a possible workflow JSON file
if (this.editAllowedCheck() === false) {
return;
}
const importConfirm = await this.confirmMessage(`Import workflow from this URL:<br /><i>${plainTextData}<i>`, 'Import Workflow from URL?', 'warning', 'Yes, import!');
if (importConfirm === false) {
return;
}
workflowData = await this.getWorkflowDataFromUrl(plainTextData);
if (workflowData === undefined) {
return;
}
} else {
// Pasted data is is possible workflow data
try {
// Check first if it is valid JSON
workflowData = JSON.parse(plainTextData);
if (this.editAllowedCheck() === false) {
return;
}
} catch (e) {
// Is no valid JSON so ignore
return;
}
}
this.$telemetry.track('User pasted nodes', {
workflow_id: this.$store.getters.workflowId,
});
return this.importWorkflowData(workflowData!);
},
// Returns the workflow data from a given URL. If no data gets found or
// data is invalid it returns undefined and displays an error message by itself.
async getWorkflowDataFromUrl (url: string): Promise<IWorkflowDataUpdate | undefined> {
let workflowData: IWorkflowDataUpdate;
this.startLoading();
try {
workflowData = await this.restApi().getWorkflowFromUrl(url);
} catch (error) {
this.stopLoading();
this.$showError(error, 'Problem loading workflow', 'There was a problem loading the workflow data from URL:');
return;
}
this.stopLoading();
this.$telemetry.track('User imported workflow', { source: 'url', workflow_id: this.$store.getters.workflowId });
return workflowData;
},
// Imports the given workflow data into the current workflow
async importWorkflowData (workflowData: IWorkflowDataUpdate): Promise<void> {
// If it is JSON check if it looks on the first look like data we can use
if (
!workflowData.hasOwnProperty('nodes') ||
!workflowData.hasOwnProperty('connections')
) {
return;
}
try {
// By default we automatically deselect all the currently
// selected nodes and select the new ones
this.deselectAllNodes();
// Fix the node position as it could be totally offscreen
// and the pasted nodes would so not be directly visible to
// the user
this.updateNodePositions(workflowData, CanvasHelpers.getNewNodePosition(this.nodes, this.lastClickPosition));
const data = await this.addNodesToWorkflow(workflowData);
setTimeout(() => {
data.nodes!.forEach((node: INodeUi) => {
this.nodeSelectedByName(node.name);
});
});
} catch (error) {
this.$showError(error, 'Problem importing workflow', 'There was a problem importing workflow data:');
}
},
closeNodeCreator () {
this.createNodeActive = false;
},
nodeTypeSelected (nodeTypeName: string) {
this.addNodeButton(nodeTypeName);
this.createNodeActive = false;
},
nodeDeselectedByName (nodeName: string) {
const node = this.$store.getters.getNodeByName(nodeName);
if (node) {
this.nodeDeselected(node);
}
},
nodeSelectedByName (nodeName: string, setActive = false, deselectAllOthers?: boolean) {
if (deselectAllOthers === true) {
this.deselectAllNodes();
}
const node = this.$store.getters.getNodeByName(nodeName);
if (node) {
this.nodeSelected(node);
}
this.$store.commit('setLastSelectedNode', node.name);
this.$store.commit('setLastSelectedNodeOutputIndex', null);
this.lastSelectedConnection = null;
this.newNodeInsertPosition = null;
if (setActive === true) {
this.$store.commit('setActiveNode', node.name);
}
},
showMaxNodeTypeError (nodeTypeData: INodeTypeDescription) {
const maxNodes = nodeTypeData.maxNodes;
this.$showMessage({
title: 'Could not create node!',
message: `Node can not be created because in a workflow max. ${maxNodes} ${maxNodes === 1 ? 'node' : 'nodes'} of type "${nodeTypeData.displayName}" ${maxNodes === 1 ? 'is' : 'are'} allowed!`,
type: 'error',
duration: 0,
});
},
async injectNode (nodeTypeName: string) {
const nodeTypeData: INodeTypeDescription | null = this.$store.getters.nodeType(nodeTypeName);
if (nodeTypeData === null) {
this.$showMessage({
title: 'Could not create node!',
message: `Node of type "${nodeTypeName}" could not be created as it is not known.`,
type: 'error',
});
return;
}
if (nodeTypeData.maxNodes !== undefined && this.getNodeTypeCount(nodeTypeName) >= nodeTypeData.maxNodes) {
this.showMaxNodeTypeError(nodeTypeData);
return;
}
const newNodeData: INodeUi = {
name: nodeTypeData.defaults.name as string,
type: nodeTypeData.name,
typeVersion: nodeTypeData.version,
position: [0, 0],
parameters: {},
};
// when pulling new connection from node or injecting into a connection
const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode) {
const lastSelectedConnection = this.lastSelectedConnection;
if (lastSelectedConnection) { // set when injecting into a connection
const [diffX] = CanvasHelpers.getConnectorLengths(lastSelectedConnection);
if (diffX <= CanvasHelpers.MAX_X_TO_PUSH_DOWNSTREAM_NODES) {
this.pushDownstreamNodes(lastSelectedNode.name, CanvasHelpers.PUSH_NODES_OFFSET);
}
}
// set when pulling connections
if (this.newNodeInsertPosition) {
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, [this.newNodeInsertPosition[0] + CanvasHelpers.GRID_SIZE, this.newNodeInsertPosition[1] - CanvasHelpers.NODE_SIZE / 2]);
this.newNodeInsertPosition = null;
}
else {
let yOffset = 0;
if (lastSelectedConnection) {
const sourceNodeType = this.$store.getters.nodeType(lastSelectedNode.type) as INodeTypeDescription | null;
const offsets = [[-100, 100], [-140, 0, 140], [-240, -100, 100, 240]];
if (sourceNodeType && sourceNodeType.outputs.length > 1) {
const offset = offsets[sourceNodeType.outputs.length - 2];
const sourceOutputIndex = lastSelectedConnection.__meta ? lastSelectedConnection.__meta.sourceOutputIndex : 0;
yOffset = offset[sourceOutputIndex];
}
}
// If a node is active then add the new node directly after the current one
// newNodeData.position = [activeNode.position[0], activeNode.position[1] + 60];
newNodeData.position = CanvasHelpers.getNewNodePosition(
this.nodes,
[lastSelectedNode.position[0] + CanvasHelpers.PUSH_NODES_OFFSET, lastSelectedNode.position[1] + yOffset],
[100, 0],
);
}
} else {
// If no node is active find a free spot
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, this.lastClickPosition);
}
// Check if node-name is unique else find one that is
newNodeData.name = CanvasHelpers.getUniqueNodeName(this.$store.getters.allNodes, newNodeData.name);
if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) {
newNodeData.webhookId = uuidv4();
}
await this.addNodes([newNodeData]);
this.$store.commit('setStateDirty', true);
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', { node_type: nodeTypeName, workflow_id: this.$store.getters.workflowId });
// Automatically deselect all nodes and select the current one and also active
// current node
this.deselectAllNodes();
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, true);
});
return newNodeData;
},
getConnection (sourceNodeName: string, sourceNodeOutputIndex: number, targetNodeName: string, targetNodeOuputIndex: number): IConnection | undefined {
const nodeConnections = (this.$store.getters.outgoingConnectionsByNodeName(sourceNodeName) as INodeConnections).main;
if (nodeConnections) {
const connections: IConnection[] | null = nodeConnections[sourceNodeOutputIndex];
if (connections) {
return connections.find((connection: IConnection) => connection.node === targetNodeName && connection.index === targetNodeOuputIndex);
}
}
return undefined;
},
connectTwoNodes (sourceNodeName: string, sourceNodeOutputIndex: number, targetNodeName: string, targetNodeOuputIndex: number) {
if (this.getConnection(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex)) {
return;
}
const connectionData = [
{
node: sourceNodeName,
type: 'main',
index: sourceNodeOutputIndex,
},
{
node: targetNodeName,
type: 'main',
index: targetNodeOuputIndex,
},
] as [IConnection, IConnection];
this.__addConnection(connectionData, true);
},
async addNodeButton (nodeTypeName: string) {
if (this.editAllowedCheck() === false) {
return;
}
const lastSelectedConnection = this.lastSelectedConnection;
const lastSelectedNode = this.lastSelectedNode;
const lastSelectedNodeOutputIndex = this.$store.getters.lastSelectedNodeOutputIndex;
const newNodeData = await this.injectNode(nodeTypeName);
if (!newNodeData) {
return;
}
const outputIndex = lastSelectedNodeOutputIndex || 0;
// If a node is last selected then connect between the active and its child ones
if (lastSelectedNode) {
await Vue.nextTick();
if (lastSelectedConnection && lastSelectedConnection.__meta) {
this.__deleteJSPlumbConnection(lastSelectedConnection);
const targetNodeName = lastSelectedConnection.__meta.targetNodeName;
const targetOutputIndex = lastSelectedConnection.__meta.targetOutputIndex;
this.connectTwoNodes(newNodeData.name, 0, targetNodeName, targetOutputIndex);
}
// Connect active node to the newly created one
this.connectTwoNodes(lastSelectedNode.name, outputIndex, newNodeData.name, 0);
}
},
initNodeView () {
this.instance.importDefaults({
Connector: CanvasHelpers.CONNECTOR_FLOWCHART_TYPE,
Endpoint: ['Dot', { radius: 5 }],
DragOptions: { cursor: 'pointer', zIndex: 5000 },
PaintStyle: CanvasHelpers.CONNECTOR_PAINT_STYLE_DEFAULT,
HoverPaintStyle: CanvasHelpers.CONNECTOR_PAINT_STYLE_PRIMARY,
ConnectionOverlays: CanvasHelpers.CONNECTOR_ARROW_OVERLAYS,
Container: '#node-view',
});
const insertNodeAfterSelected = (info: {sourceId: string, index: number, eventSource: string, connection?: Connection}) => {
// Get the node and set it as active that new nodes
// which get created get automatically connected
// to it.
const sourceNodeName = this.$store.getters.getNodeNameByIndex(info.sourceId.slice(NODE_NAME_PREFIX.length));
this.$store.commit('setLastSelectedNode', sourceNodeName);
this.$store.commit('setLastSelectedNodeOutputIndex', info.index);
this.newNodeInsertPosition = null;
if (info.connection) {
this.lastSelectedConnection = info.connection;
}
this.openNodeCreator(info.eventSource);
};
this.instance.bind('connectionAborted', (connection) => {
try {
if (this.dropPrevented) {
this.dropPrevented = false;
return;
}
if (this.pullConnActiveNodeName) {
const sourceNodeName = this.$store.getters.getNodeNameByIndex(connection.sourceId.slice(NODE_NAME_PREFIX.length));
const outputIndex = connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
this.pullConnActiveNodeName = null;
return;
}
insertNodeAfterSelected({
sourceId: connection.sourceId,
index: connection.getParameters().index,
eventSource: 'node_connection_drop',
});
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind('beforeDrop', (info) => {
try {
const sourceInfo = info.connection.endpoints[0].getParameters();
// @ts-ignore
const targetInfo = info.dropEndpoint.getParameters();
const sourceNodeName = this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex);
const targetNodeName = this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex);
// check for duplicates
if (this.getConnection(sourceNodeName, sourceInfo.index, targetNodeName, targetInfo.index)) {
this.dropPrevented = true;
this.pullConnActiveNodeName = null;
return false;
}
return true;
} catch (e) {
console.error(e); // eslint-disable-line no-console
return true;
}
});
// only one set of visible actions should be visible at the same time
let activeConnection: null | Connection = null;
this.instance.bind('connection', (info: OnConnectionBindInfo) => {
try {
const sourceInfo = info.sourceEndpoint.getParameters();
const targetInfo = info.targetEndpoint.getParameters();
const sourceNodeName = this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex);
const targetNodeName = this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex);
info.connection.__meta = {
sourceNodeName,
sourceOutputIndex: sourceInfo.index,
targetNodeName,
targetOutputIndex: targetInfo.index,
};
CanvasHelpers.resetConnection(info.connection);
if (this.isReadOnly === false) {
let exitTimer: NodeJS.Timeout | undefined;
let enterTimer: NodeJS.Timeout | undefined;
info.connection.bind('mouseover', (connection: Connection) => {
try {
if (exitTimer !== undefined) {
clearTimeout(exitTimer);
exitTimer = undefined;
}
if (enterTimer) {
return;
}
if (!info.connection || info.connection === activeConnection) {
return;
}
CanvasHelpers.hideConnectionActions(activeConnection);
enterTimer = setTimeout(() => {
enterTimer = undefined;
if (info.connection) {
activeConnection = info.connection;
CanvasHelpers.showConectionActions(info.connection);
}
}, 150);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
info.connection.bind('mouseout', (connection: Connection) => {
try {
if (exitTimer) {
return;
}
if (enterTimer) {
clearTimeout(enterTimer);
enterTimer = undefined;
}
if (!info.connection || activeConnection !== info.connection) {
return;
}
exitTimer = setTimeout(() => {
exitTimer = undefined;
if (info.connection && activeConnection === info.connection) {
CanvasHelpers.hideConnectionActions(activeConnection);
activeConnection = null;
}
}, 500);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
CanvasHelpers.addConnectionActionsOverlay(info.connection,
() => {
activeConnection = null;
this.__deleteJSPlumbConnection(info.connection);
},
() => {
setTimeout(() => {
insertNodeAfterSelected({
sourceId: info.sourceId,
index: sourceInfo.index,
connection: info.connection,
eventSource: 'node_connection_action',
});
}, 150);
});
}
CanvasHelpers.moveBackInputLabelPosition(info.targetEndpoint);
this.$store.commit('addConnection', {
connection: [
{
node: sourceNodeName,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: targetNodeName,
type: targetInfo.type,
index: targetInfo.index,
},
],
setStateDirty: true,
});
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind('connectionMoved', (info) => {
try {
// When a connection gets moved from one node to another it for some reason
// calls the "connection" event but not the "connectionDetached" one. So we listen
// additionally to the "connectionMoved" event and then only delete the existing connection.
CanvasHelpers.resetInputLabelPosition(info.originalTargetEndpoint);
// @ts-ignore
const sourceInfo = info.originalSourceEndpoint.getParameters();
// @ts-ignore
const targetInfo = info.originalTargetEndpoint.getParameters();
const connectionInfo = [
{
node: this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex),
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex),
type: targetInfo.type,
index: targetInfo.index,
},
] as [IConnection, IConnection];
this.__removeConnection(connectionInfo, false);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
this.instance.bind('connectionDetached', (info) => {
try {
CanvasHelpers.resetInputLabelPosition(info.targetEndpoint);
info.connection.removeOverlays();
this.__removeConnectionByConnectionInfo(info, false);
if (this.pullConnActiveNodeName) { // establish new connection when dragging connection from one node to another
const sourceNodeName = this.$store.getters.getNodeNameByIndex(info.connection.sourceId.slice(NODE_NAME_PREFIX.length));
const outputIndex = info.connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
this.pullConnActiveNodeName = null;
}
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
// @ts-ignore
this.instance.bind('connectionDrag', (connection: Connection) => {
try {
this.pullConnActiveNodeName = null;
this.pullConnActive = true;
this.newNodeInsertPosition = null;
CanvasHelpers.resetConnection(connection);
const nodes = [...document.querySelectorAll('.node-default')];
const onMouseMove = (e: MouseEvent | TouchEvent) => {
if (!connection) {
return;
}
const element = document.querySelector('.jtk-endpoint.dropHover');
if (element) {
// @ts-ignore
CanvasHelpers.showDropConnectionState(connection, element._jsPlumb);
return;
}
const inputMargin = 24;
const intersecting = nodes.find((element: Element) => {
const {top, left, right, bottom} = element.getBoundingClientRect();
const [x, y] = CanvasHelpers.getMousePosition(e);
if (top <= y && bottom >= y && (left - inputMargin) <= x && right >= x) {
const nodeName = (element as HTMLElement).dataset['name'] as string;
const node = this.$store.getters.getNodeByName(nodeName) as INodeUi | null;
if (node) {
const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (nodeType && nodeType.inputs && nodeType.inputs.length === 1) {
this.pullConnActiveNodeName = node.name;
const endpoint = this.instance.getEndpoint(this.getInputEndpointUUID(nodeName, 0));
CanvasHelpers.showDropConnectionState(connection, endpoint);
return true;
}
}
}
return false;
});
if (!intersecting) {
CanvasHelpers.showPullConnectionState(connection);
this.pullConnActiveNodeName = null;
}
};
const onMouseUp = (e: MouseEvent | TouchEvent) => {
this.pullConnActive = false;
this.newNodeInsertPosition = this.getMousePositionWithinNodeView(e);
CanvasHelpers.resetConnectionAfterPull(connection);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('touchend', onMouseMove);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
});
// @ts-ignore
this.instance.bind(('plusEndpointClick'), (endpoint: Endpoint) => {
if (endpoint && endpoint.__meta) {
insertNodeAfterSelected({
sourceId: endpoint.__meta.nodeId,
index: endpoint.__meta.index,
eventSource: 'plus_endpoint',
});
}
});
},
async newWorkflow (): Promise<void> {
await this.resetWorkspace();
await this.$store.dispatch('workflows/setNewWorkflowName');
this.$store.commit('setStateDirty', false);
await this.addNodes([{...CanvasHelpers.DEFAULT_START_NODE}]);
this.nodeSelectedByName(CanvasHelpers.DEFAULT_START_NODE.name, false);
this.$store.commit('setStateDirty', false);
this.setZoomLevel(1);
setTimeout(() => {
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [0, 0]});
}, 0);
},
async initView (): Promise<void> {
if (this.$route.params.action === 'workflowSave') {
// In case the workflow got saved we do not have to run init
// as only the route changed but all the needed data is already loaded
this.$store.commit('setStateDirty', false);
return Promise.resolve();
}
if (this.blankRedirect) {
this.blankRedirect = false;
}
else if (this.$route.name === 'WorkflowTemplate') {
const templateId = this.$route.params.id;
await this.openWorkflowTemplate(templateId);
}
else if (this.$route.name === 'ExecutionById') {
// Load an execution
const executionId = this.$route.params.id;
await this.openExecution(executionId);
} else {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes');
if (importConfirm === false) {
return Promise.resolve();
}
}
// Load a workflow
let workflowId = null as string | null;
if (this.$route.params.name) {
workflowId = this.$route.params.name;
}
if (workflowId !== null) {
const workflow = await this.restApi().getWorkflow(workflowId);
if (!workflow) {
this.$router.push({
name: "NodeViewNew",
});
this.$showMessage({
title: 'Error',
message: 'Could not find workflow',
type: 'error',
});
} else {
this.$titleSet(workflow.name, 'IDLE');
// Open existing workflow
await this.openWorkflow(workflowId);
}
} else {
// Create new workflow
await this.newWorkflow();
}
}
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener("beforeunload", (e) => {
if(this.$store.getters.getStateIsDirty === true) {
const confirmationMessage = 'It looks like you have been editing something. '
+ 'If you leave before saving, your changes will be lost.';
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
} else {
this.startLoading('Redirecting');
return;
}
});
},
getOutputEndpointUUID(nodeName: string, index: number) {
return CanvasHelpers.getOutputEndpointUUID(this.getNodeIndex(nodeName), index);
},
getInputEndpointUUID(nodeName: string, index: number) {
return CanvasHelpers.getInputEndpointUUID(this.getNodeIndex(nodeName), index);
},
__addConnection (connection: [IConnection, IConnection], addVisualConnection = false) {
if (addVisualConnection === true) {
const uuid: [string, string] = [
this.getOutputEndpointUUID(connection[0].node, connection[0].index),
this.getInputEndpointUUID(connection[1].node, connection[1].index),
];
// Create connections in DOM
// @ts-ignore
this.instance.connect({
uuids: uuid,
detachable: !this.isReadOnly,
});
} else {
const connectionProperties = {connection, setStateDirty: false};
// When nodes get connected it gets saved automatically to the storage
// so if we do not connect we have to save the connection manually
this.$store.commit('addConnection', connectionProperties);
}
},
__removeConnection (connection: [IConnection, IConnection], removeVisualConnection = false) {
if (removeVisualConnection === true) {
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(connection[0].node),
target: NODE_NAME_PREFIX + this.getNodeIndex(connection[1].node),
});
// @ts-ignore
connections.forEach((connectionInstance) => {
this.__deleteJSPlumbConnection(connectionInstance);
});
}
this.$store.commit('removeConnection', { connection });
},
__deleteJSPlumbConnection(connection: Connection) {
// Make sure to remove the overlay else after the second move
// it visibly stays behind free floating without a connection.
connection.removeOverlays();
const sourceEndpoint = connection.endpoints && connection.endpoints[0];
this.pullConnActiveNodeName = null; // prevent new connections when connectionDetached is triggered
this.instance.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
if (sourceEndpoint) {
const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId);
endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint
}
},
__removeConnectionByConnectionInfo (info: OnConnectionBindInfo, removeVisualConnection = false) {
// @ts-ignore
const sourceInfo = info.sourceEndpoint.getParameters();
// @ts-ignore
const targetInfo = info.targetEndpoint.getParameters();
const connectionInfo = [
{
node: this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex),
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex),
type: targetInfo.type,
index: targetInfo.index,
},
] as [IConnection, IConnection];
if (removeVisualConnection) {
this.__deleteJSPlumbConnection(info.connection);
}
this.$store.commit('removeConnection', { connection: connectionInfo });
},
async duplicateNode (nodeName: string) {
if (this.editAllowedCheck() === false) {
return;
}
const node = this.$store.getters.getNodeByName(nodeName);
const nodeTypeData: INodeTypeDescription | null= this.$store.getters.nodeType(node.type);
if (nodeTypeData && nodeTypeData.maxNodes !== undefined && this.getNodeTypeCount(node.type) >= nodeTypeData.maxNodes) {
this.showMaxNodeTypeError(nodeTypeData);
return;
}
// Deep copy the data so that data on lower levels of the node-properties do
// not share objects
const newNodeData = JSON.parse(JSON.stringify(this.getNodeDataToSave(node)));
// Check if node-name is unique else find one that is
newNodeData.name = CanvasHelpers.getUniqueNodeName(this.$store.getters.allNodes, newNodeData.name);
newNodeData.position = CanvasHelpers.getNewNodePosition(
this.nodes,
[node.position[0], node.position[1] + 140],
[0, 140],
);
if (newNodeData.webhookId) {
// Make sure that the node gets a new unique webhook-ID
newNodeData.webhookId = uuidv4();
}
await this.addNodes([newNodeData]);
this.$store.commit('setStateDirty', true);
// Automatically deselect all nodes and select the current one and also active
// current node
this.deselectAllNodes();
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, true);
});
this.$telemetry.track('User duplicated node', { node_type: node.type, workflow_id: this.$store.getters.workflowId });
},
getJSPlumbConnection (sourceNodeName: string, sourceOutputIndex: number, targetNodeName: string, targetInputIndex: number): Connection | undefined {
const sourceIndex = this.getNodeIndex(sourceNodeName);
const sourceId = `${NODE_NAME_PREFIX}${sourceIndex}`;
const targetIndex = this.getNodeIndex(targetNodeName);
const targetId = `${NODE_NAME_PREFIX}${targetIndex}`;
const sourceEndpoint = CanvasHelpers.getOutputEndpointUUID(sourceIndex, sourceOutputIndex);
const targetEndpoint = CanvasHelpers.getInputEndpointUUID(targetIndex, targetInputIndex);
// @ts-ignore
const connections = this.instance.getConnections({
source: sourceId,
target: targetId,
}) as Connection[];
return connections.find((connection: Connection) => {
const uuids = connection.getUuids();
return uuids[0] === sourceEndpoint && uuids[1] === targetEndpoint;
});
},
getJSPlumbEndpoints (nodeName: string): Endpoint[] {
const nodeIndex = this.getNodeIndex(nodeName);
const nodeId = `${NODE_NAME_PREFIX}${nodeIndex}`;
return this.instance.getEndpoints(nodeId);
},
getPlusEndpoint (nodeName: string, outputIndex: number): Endpoint | undefined {
const endpoints = this.getJSPlumbEndpoints(nodeName);
// @ts-ignore
return endpoints.find((endpoint: Endpoint) => endpoint.type === 'N8nPlus' && endpoint.__meta && endpoint.__meta.index === outputIndex);
},
getIncomingOutgoingConnections(nodeName: string): {incoming: Connection[], outgoing: Connection[]} {
const name = `${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(nodeName)}`;
// @ts-ignore
const outgoing = this.instance.getConnections({
source: name,
}) as Connection[];
// @ts-ignore
const incoming = this.instance.getConnections({
target: name,
}) as Connection[];
return {
incoming,
outgoing,
};
},
onNodeMoved (node: INodeUi) {
const {incoming, outgoing} = this.getIncomingOutgoingConnections(node.name);
[...incoming, ...outgoing].forEach((connection: Connection) => {
CanvasHelpers.showOrHideMidpointArrow(connection);
CanvasHelpers.showOrHideItemsLabel(connection);
});
},
onNodeRun ({name, data, waiting}: {name: string, data: ITaskData[] | null, waiting: boolean}) {
const sourceNodeName = name;
const sourceIndex = this.$store.getters.getNodeIndex(sourceNodeName);
const sourceId = `${NODE_NAME_PREFIX}${sourceIndex}`;
if (data === null || data.length === 0 || waiting) {
// @ts-ignore
const outgoing = this.instance.getConnections({
source: sourceId,
}) as Connection[];
outgoing.forEach((connection: Connection) => {
CanvasHelpers.resetConnection(connection);
});
const endpoints = this.getJSPlumbEndpoints(sourceNodeName);
endpoints.forEach((endpoint: Endpoint) => {
// @ts-ignore
if (endpoint.type === 'N8nPlus') {
(endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput();
}
});
return;
}
const nodeConnections = (this.$store.getters.outgoingConnectionsByNodeName(sourceNodeName) as INodeConnections).main;
const outputMap = CanvasHelpers.getOutputSummary(data, nodeConnections || []);
Object.keys(outputMap).forEach((sourceOutputIndex: string) => {
Object.keys(outputMap[sourceOutputIndex]).forEach((targetNodeName: string) => {
Object.keys(outputMap[sourceOutputIndex][targetNodeName]).forEach((targetInputIndex: string) => {
if (targetNodeName) {
const connection = this.getJSPlumbConnection(sourceNodeName, parseInt(sourceOutputIndex, 10), targetNodeName, parseInt(targetInputIndex, 10));
if (connection) {
const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex];
if (!output || !output.total) {
CanvasHelpers.resetConnection(connection);
}
else {
CanvasHelpers.addConnectionOutputSuccess(connection, output);
}
}
}
const endpoint = this.getPlusEndpoint(sourceNodeName, parseInt(sourceOutputIndex, 10));
if (endpoint && endpoint.endpoint) {
const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
if (output && output.total > 0) {
(endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput(CanvasHelpers.getRunItemsLabel(output));
}
else {
(endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput();
}
}
});
});
});
},
removeNode (nodeName: string) {
if (this.editAllowedCheck() === false) {
return;
}
const node = this.$store.getters.getNodeByName(nodeName) as INodeUi | null;
if (!node) {
return;
}
// "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes = [ START_NODE_TYPE ];
if (requiredNodeTypes.includes(node.type)) {
// The node is of the required type so check first
// if any node of that type would be left when the
// current one would get deleted.
let deleteAllowed = false;
for (const checkNode of this.nodes) {
if (checkNode.name === node.name) {
continue;
}
if (requiredNodeTypes.includes(checkNode.type)) {
deleteAllowed = true;
break;
}
}
if (deleteAllowed === false) {
return;
}
}
let waitForNewConnection = false;
// connect nodes before/after deleted node
const nodeType: INodeTypeDescription | null = this.$store.getters.nodeType(node.type, node.typeVersion);
if (nodeType && nodeType.outputs.length === 1
&& nodeType.inputs.length === 1) {
const {incoming, outgoing} = this.getIncomingOutgoingConnections(node.name);
if (incoming.length === 1 && outgoing.length === 1) {
const conn1 = incoming[0];
const conn2 = outgoing[0];
if (conn1.__meta && conn2.__meta) {
waitForNewConnection = true;
const sourceNodeName = conn1.__meta.sourceNodeName;
const sourceNodeOutputIndex = conn1.__meta.sourceOutputIndex;
const targetNodeName = conn2.__meta.targetNodeName;
const targetNodeOuputIndex = conn2.__meta.targetOutputIndex;
setTimeout(() => {
this.connectTwoNodes(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex);
if (waitForNewConnection) {
this.instance.setSuspendDrawing(false, true);
waitForNewConnection = false;
}
}, 100); // just to make it clear to users that this is a new connection
}
}
}
setTimeout(() => {
const nodeIndex = this.$store.getters.getNodeIndex(nodeName);
const nodeIdName = `node-${nodeIndex}`;
// Suspend drawing
this.instance.setSuspendDrawing(true);
// Remove all endpoints and the connections in jsplumb
this.instance.removeAllEndpoints(nodeIdName);
// Remove the draggable
// @ts-ignore
this.instance.destroyDraggable(nodeIdName);
// Remove the connections in data
this.$store.commit('removeAllNodeConnection', node);
this.$store.commit('removeNode', node);
this.$store.commit('clearNodeExecutionData', node.name);
if (!waitForNewConnection) {
// Now it can draw again
this.instance.setSuspendDrawing(false, true);
}
// Remove node from selected index if found in it
this.$store.commit('removeNodeFromSelection', node);
// Remove from node index
if (nodeIndex !== -1) {
this.$store.commit('setNodeIndex', { index: nodeIndex, name: null });
}
}, 0); // allow other events to finish like drag stop
},
valueChanged (parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
// The name changed so we have to take care that
// the connections get changed.
this.renameNode(parameterData.oldValue as string, parameterData.value as string);
}
},
async renameNodePrompt (currentName: string) {
try {
const promptResponsePromise = this.$prompt('New Name:', `Rename Node: "${currentName}"`, {
customClass: 'rename-prompt',
confirmButtonText: 'Rename',
cancelButtonText: 'Cancel',
inputErrorMessage: 'Invalid Name',
inputValue: currentName,
});
// Wait till it had time to display
await Vue.nextTick();
// Get the input and select the text in it
const nameInput = document.querySelector('.rename-prompt .el-input__inner') as HTMLInputElement | undefined;
if (nameInput) {
nameInput.focus();
nameInput.select();
}
const promptResponse = await promptResponsePromise as MessageBoxInputData;
this.renameNode(currentName, promptResponse.value);
} catch (e) {}
},
async renameNode (currentName: string, newName: string) {
if (currentName === newName) {
return;
}
// Check if node-name is unique else find one that is
newName = CanvasHelpers.getUniqueNodeName(this.$store.getters.allNodes, newName);
// Rename the node and update the connections
const workflow = this.getWorkflow(undefined, undefined, true);
workflow.renameNode(currentName, newName);
// Update also last selected node and exeuction data
this.$store.commit('renameNodeSelectedAndExecution', { old: currentName, new: newName });
// Reset all nodes and connections to load the new ones
this.deleteEveryEndpoint();
this.$store.commit('removeAllConnections');
this.$store.commit('removeAllNodes', {setStateDirty: true});
// Wait a tick that the old nodes had time to get removed
await Vue.nextTick();
// Add the new updated nodes
await this.addNodes(Object.values(workflow.nodes), workflow.connectionsBySourceNode);
// Make sure that the node is selected again
this.deselectAllNodes();
this.nodeSelectedByName(newName);
},
deleteEveryEndpoint () {
// Check as it does not exist on first load
if (this.instance) {
const nodes = this.$store.getters.allNodes as INodeUi[];
// @ts-ignore
nodes.forEach((node: INodeUi) => this.instance.destroyDraggable(`${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(node.name)}`));
this.instance.deleteEveryEndpoint();
}
},
matchCredentials(node: INodeUi) {
if (!node.credentials) {
return;
}
Object.entries(node.credentials).forEach(([nodeCredentialType, nodeCredentials]: [string, INodeCredentialsDetails]) => {
const credentialOptions = this.$store.getters['credentials/getCredentialsByType'](nodeCredentialType) as ICredentialsResponse[];
// Check if workflows applies old credentials style
if (typeof nodeCredentials === 'string') {
nodeCredentials = {
id: null,
name: nodeCredentials,
};
this.credentialsUpdated = true;
}
if (nodeCredentials.id) {
// Check whether the id is matching with a credential
const credentialsId = nodeCredentials.id.toString(); // due to a fixed bug in the migration UpdateWorkflowCredentials (just sqlite) we have to cast to string and check later if it has been a number
const credentialsForId = credentialOptions.find((optionData: ICredentialsResponse) =>
optionData.id === credentialsId,
);
if (credentialsForId) {
if (credentialsForId.name !== nodeCredentials.name || typeof nodeCredentials.id === 'number') {
node.credentials![nodeCredentialType] = { id: credentialsForId.id, name: credentialsForId.name };
this.credentialsUpdated = true;
}
return;
}
}
// No match for id found or old credentials type used
node.credentials![nodeCredentialType] = nodeCredentials;
// check if only one option with the name would exist
const credentialsForName = credentialOptions.filter((optionData: ICredentialsResponse) => optionData.name === nodeCredentials.name);
// only one option exists for the name, take it
if (credentialsForName.length === 1) {
node.credentials![nodeCredentialType].id = credentialsForName[0].id;
this.credentialsUpdated = true;
}
});
},
async addNodes (nodes: INodeUi[], connections?: IConnections) {
if (!nodes || !nodes.length) {
return;
}
// Before proceeding we must check if all nodes contain the `properties` attribute.
// Nodes are loaded without this information so we must make sure that all nodes
// being added have this information.
await this.loadNodesProperties(nodes.map(node => ({name: node.type, version: node.typeVersion})));
// Add the node to the node-list
let nodeType: INodeTypeDescription | null;
let foundNodeIssues: INodeIssues | null;
nodes.forEach((node) => {
nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
// Make sure that some properties always exist
if (!node.hasOwnProperty('disabled')) {
node.disabled = false;
}
if (!node.hasOwnProperty('parameters')) {
node.parameters = {};
}
// Load the defaul parameter values because only values which differ
// from the defaults get saved
if (nodeType !== null) {
let nodeParameters = null;
try {
nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, true, false);
} catch (e) {
console.error(`There was a problem loading the node-parameters of node: "${node.name}"`); // eslint-disable-line no-console
console.error(e); // eslint-disable-line no-console
}
node.parameters = nodeParameters !== null ? nodeParameters : {};
// if it's a webhook and the path is empty set the UUID as the default path
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
node.parameters.path = node.webhookId as string;
}
}
// check and match credentials, apply new format if old is used
this.matchCredentials(node);
foundNodeIssues = this.getNodeIssues(nodeType, node);
if (foundNodeIssues !== null) {
node.issues = foundNodeIssues;
}
this.$store.commit('addNode', node);
});
// Wait for the node to be rendered
await Vue.nextTick();
// Suspend drawing
this.instance.setSuspendDrawing(true);
// Load the connections
if (connections !== undefined) {
let connectionData;
for (const sourceNode of Object.keys(connections)) {
for (const type of Object.keys(connections[sourceNode])) {
for (let sourceIndex = 0; sourceIndex < connections[sourceNode][type].length; sourceIndex++) {
connections[sourceNode][type][sourceIndex].forEach((
targetData,
) => {
connectionData = [
{
node: sourceNode,
type,
index: sourceIndex,
},
{
node: targetData.node,
type: targetData.type,
index: targetData.index,
},
] as [IConnection, IConnection];
this.__addConnection(connectionData, true);
});
}
}
}
}
// Now it can draw again
this.instance.setSuspendDrawing(false, true);
},
async addNodesToWorkflow (data: IWorkflowDataUpdate): Promise<IWorkflowDataUpdate> {
// Because nodes with the same name maybe already exist, it could
// be needed that they have to be renamed. Also could it be possible
// that nodes are not allowd to be created because they have a create
// limit set. So we would then link the new nodes with the already existing ones.
// In this object all that nodes get saved in the format:
// old-name -> new-name
const nodeNameTable: {
[key: string]: string;
} = {};
const newNodeNames: string[] = [];
if (!data.nodes) {
// No nodes to add
throw new Error('No nodes given to add!');
}
// Get how many of the nodes of the types which have
// a max limit set already exist
const nodeTypesCount = this.getNodeTypesMaxCount();
let oldName: string;
let newName: string;
const createNodes: INode[] = [];
await this.loadNodesProperties(data.nodes.map(node => ({name: node.type, version: node.typeVersion})));
data.nodes.forEach(node => {
if (nodeTypesCount[node.type] !== undefined) {
if (nodeTypesCount[node.type].exist >= nodeTypesCount[node.type].max) {
// Node is not allowed to be created so
// do not add it to the create list but
// add the name of the existing node
// that this one gets linked up instead.
nodeNameTable[node.name] = nodeTypesCount[node.type].nodeNames[0];
return;
} else {
// Node can be created but increment the
// counter in case multiple ones are
// supposed to be created
nodeTypesCount[node.type].exist += 1;
}
}
oldName = node.name;
newName = CanvasHelpers.getUniqueNodeName(this.$store.getters.allNodes, node.name, newNodeNames);
newNodeNames.push(newName);
nodeNameTable[oldName] = newName;
createNodes.push(node);
});
// Get only the connections of the nodes that get created
const newConnections: IConnections = {};
const currentConnections = data.connections!;
const createNodeNames = createNodes.map((node) => node.name);
let sourceNode, type, sourceIndex, connectionIndex, connectionData;
for (sourceNode of Object.keys(currentConnections)) {
if (!createNodeNames.includes(sourceNode)) {
// Node does not get created so skip output connections
continue;
}
const connection: INodeConnections = {};
for (type of Object.keys(currentConnections[sourceNode])) {
connection[type] = [];
for (sourceIndex = 0; sourceIndex < currentConnections[sourceNode][type].length; sourceIndex++) {
const nodeSourceConnections = [];
if (currentConnections[sourceNode][type][sourceIndex]) {
for (connectionIndex = 0; connectionIndex < currentConnections[sourceNode][type][sourceIndex].length; connectionIndex++) {
connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex];
if (!createNodeNames.includes(connectionData.node)) {
// Node does not get created so skip input connection
continue;
}
nodeSourceConnections.push(connectionData);
// Add connection
}
}
connection[type].push(nodeSourceConnections);
}
}
newConnections[sourceNode] = connection;
}
// Create a workflow with the new nodes and connections that we can use
// the rename method
const tempWorkflow: Workflow = this.getWorkflow(createNodes, newConnections);
// Rename all the nodes of which the name changed
for (oldName in nodeNameTable) {
if (oldName === nodeNameTable[oldName]) {
// Name did not change so skip
continue;
}
tempWorkflow.renameNode(oldName, nodeNameTable[oldName]);
}
// Add the nodes with the changed node names, expressions and connections
await this.addNodes(Object.values(tempWorkflow.nodes), tempWorkflow.connectionsBySourceNode);
this.$store.commit('setStateDirty', true);
return {
nodes: Object.values(tempWorkflow.nodes),
connections: tempWorkflow.connectionsBySourceNode,
};
},
getSelectedNodesToSave (): Promise<IWorkflowData> {
const data: IWorkflowData = {
nodes: [],
connections: {},
};
// Get data of all the selected noes
let nodeData;
const exportNodeNames: string[] = [];
for (const node of this.$store.getters.getSelectedNodes) {
try {
nodeData = this.getNodeDataToSave(node);
exportNodeNames.push(node.name);
} catch (e) {
return Promise.reject(e);
}
data.nodes.push(nodeData);
}
// Get only connections of exported nodes and ignore all other ones
let connectionToKeep,
connections: INodeConnections,
type: string,
connectionIndex: number,
sourceIndex: number,
connectionData: IConnection,
typeConnections: INodeConnections;
data.nodes.forEach((node) => {
connections = this.$store.getters.outgoingConnectionsByNodeName(node.name);
if (Object.keys(connections).length === 0) {
return;
}
// Keep only the connection to node which get also exported
// @ts-ignore
typeConnections = {};
for (type of Object.keys(connections)) {
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
connectionToKeep = [];
for (connectionIndex = 0; connectionIndex < connections[type][sourceIndex].length; connectionIndex++) {
connectionData = connections[type][sourceIndex][connectionIndex];
if (exportNodeNames.indexOf(connectionData.node) !== -1) {
connectionToKeep.push(connectionData);
}
}
if (connectionToKeep.length) {
if (!typeConnections.hasOwnProperty(type)) {
typeConnections[type] = [];
}
typeConnections[type][sourceIndex] = connectionToKeep;
}
}
}
if (Object.keys(typeConnections).length) {
data.connections[node.name] = typeConnections;
}
});
return Promise.resolve(data);
},
resetWorkspace () {
// Reset nodes
this.deleteEveryEndpoint();
if (this.executionWaitingForWebhook === true) {
// Make sure that if there is a waiting test-webhook that
// it gets removed
this.restApi().removeTestWebhook(this.$store.getters.workflowId)
.catch(() => {
// Ignore all errors
});
}
this.$store.commit('removeAllConnections', {setStateDirty: false});
this.$store.commit('removeAllNodes', {setStateDirty: false});
// Reset workflow execution data
this.$store.commit('setWorkflowExecutionData', null);
this.$store.commit('resetAllNodesIssues');
// vm.$forceUpdate();
this.$store.commit('setActive', false);
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowName', {newName: '', setStateDirty: false});
this.$store.commit('setWorkflowSettings', {});
this.$store.commit('setWorkflowTagIds', []);
this.$store.commit('setActiveExecutionId', null);
this.$store.commit('setExecutingNode', null);
this.$store.commit('removeActiveAction', 'workflowRunning');
this.$store.commit('setExecutionWaitingForWebhook', false);
this.$store.commit('resetNodeIndex');
this.$store.commit('resetSelectedNodes');
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [0, 0], setStateDirty: false});
return Promise.resolve();
},
async loadActiveWorkflows (): Promise<void> {
const activeWorkflows = await this.restApi().getActiveWorkflows();
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async loadSettings (): Promise<void> {
await this.$store.dispatch('settings/getSettings');
},
async loadNodeTypes (): Promise<void> {
const nodeTypes = await this.restApi().getNodeTypes();
this.$store.commit('setNodeTypes', nodeTypes);
},
async loadCredentialTypes (): Promise<void> {
await this.$store.dispatch('credentials/fetchCredentialTypes');
},
async loadCredentials (): Promise<void> {
await this.$store.dispatch('credentials/fetchAllCredentials');
},
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodes:INodeTypeDescription[] = this.$store.getters.allNodeTypes;
const nodesToBeFetched:INodeTypeNameVersion[] = [];
allNodes.forEach(node => {
if(!!nodeInfos.find(n => n.name === node.name && n.version === node.version) && !node.hasOwnProperty('properties')) {
nodesToBeFetched.push({
name: node.name,
version: node.version,
});
}
});
if (nodesToBeFetched.length > 0) {
// Only call API if node information is actually missing
this.startLoading();
const nodeInfo = await this.restApi().getNodesInformation(nodesToBeFetched);
this.$store.commit('updateNodeTypes', nodeInfo);
this.stopLoading();
}
},
},
async mounted () {
this.$root.$on('importWorkflowData', async (data: IDataObject) => {
await this.importWorkflowData(data.data as IWorkflowDataUpdate);
});
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowUrl', async (data: IDataObject) => {
const workflowData = await this.getWorkflowDataFromUrl(data.url as string);
if (workflowData !== undefined) {
await this.importWorkflowData(workflowData);
}
});
this.startLoading();
const loadPromises = [
this.loadActiveWorkflows(),
this.loadCredentials(),
this.loadCredentialTypes(),
this.loadNodeTypes(),
this.loadSettings(),
];
try {
await Promise.all(loadPromises);
} catch (error) {
this.$showError(error, 'Init Problem', 'There was a problem loading init data:');
return;
}
this.instance.ready(async () => {
try {
this.initNodeView();
await this.initView();
} catch (error) {
this.$showError(error, 'Init Problem', 'There was a problem initializing the workflow:');
}
this.stopLoading();
setTimeout(() => {
this.checkForNewVersions();
}, 0);
});
this.$externalHooks().run('nodeView.mount');
this.$telemetry.page('Editor', this.$route.name);
},
destroyed () {
this.resetWorkspace();
},
});
</script>
<style scoped lang="scss">
.zoom-menu {
$--zoom-menu-margin: 5;
position: fixed;
left: $--sidebar-width + $--zoom-menu-margin;
width: 200px;
bottom: 45px;
line-height: 25px;
color: #444;
padding-right: 5px;
@media (max-width: $--breakpoint-2xs) {
bottom: 90px;
}
&.expanded {
left: $--sidebar-expanded-width + $--zoom-menu-margin;
}
button {
border: var(--border-base);
}
}
.node-creator-button {
position: fixed;
text-align: center;
top: 80px;
right: 20px;
}
.node-creator-button button {
position: relative;
}
.node-view-root {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
overflow: hidden;
}
.node-view-wrapper {
position: fixed;
width: 100%;
height: 100%;
}
.node-view {
position: relative;
width: 100%;
height: 100%;
transform-origin: 0 0;
}
.node-view-background {
position: absolute;
width: 10000px;
height: 10000px;
}
.move-active {
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
touch-action: none;
}
.move-in-process {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
touch-action: none;
}
.workflow-execute-wrapper {
position: fixed;
line-height: 65px;
left: calc(50% - 150px);
bottom: 30px;
width: 300px;
text-align: center;
> * {
margin-inline-end: 0.625rem;
}
}
/* Makes sure that when selected with mouse it does not select text */
.do-not-select *,
.jtk-drag-select * {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>
<style lang="scss">
.connection-run-items-label {
span {
border-radius: 7px;
background-color: hsla(var(--color-canvas-background-h),var( --color-canvas-background-s), var(--color-canvas-background-l), .85);
line-height: 1.3em;
padding: 0px 3px;
white-space: nowrap;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
color: var(--color-success);
}
.floating {
position: absolute;
top: -22px;
transform: translateX(-50%);
}
}
.connection-input-name-label {
position: relative;
span {
position: absolute;
top: -10px;
left: -60px;
}
}
.drop-add-node-label {
color: #555;
font-weight: 600;
font-size: 0.8em;
text-align: center;
background-color: #ffffff55;
}
.node-input-endpoint-label,
.node-output-endpoint-label {
background-color: hsla(var(--color-canvas-background-h),var( --color-canvas-background-s), var(--color-canvas-background-l), .85);
border-radius: 7px;
font-size: 0.7em;
padding: 2px;
white-space: nowrap;
}
.node-input-endpoint-label {
text-align: right;
}
.button-white {
border: none;
padding: 0.3em;
margin: 0 0.1em;
border-radius: 3px;
font-size: 1.2em;
background: #fff;
width: 40px;
height: 40px;
color: #666;
cursor: pointer;
&:hover {
transform: scale(1.1);
}
}
.connection-actions {
&:hover {
display: block !important;
}
> div {
color: var(--color-foreground-xdark);
border: 2px solid var(--color-foreground-xdark);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
height: var(--spacing-l);
width: var(--spacing-l);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
position: absolute;
top: -12px;
&.add {
right: 4px;
}
&.delete {
left: 4px;
}
svg {
pointer-events: none;
font-size: var(--font-size-2xs);
}
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
}
</style>