Initial commit to release
This commit is contained in:
201
packages/editor-ui/src/components/mixins/copyPaste.ts
Normal file
201
packages/editor-ui/src/components/mixins/copyPaste.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Captures any pasted data and sends it to method "receivedCopyPasteData" which has to be
|
||||
* defined on the component which uses this mixin
|
||||
*/
|
||||
import Vue from 'vue';
|
||||
|
||||
// export const copyPaste = {
|
||||
export const copyPaste = Vue.extend({
|
||||
data () {
|
||||
return {
|
||||
copyPasteElementsGotCreated: false,
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
if (this.copyPasteElementsGotCreated === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.copyPasteElementsGotCreated = true;
|
||||
// Define the style of the html elements that get created to make
|
||||
// sure that they are not visible
|
||||
const style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.innerHTML = `
|
||||
.hidden-copy-paste {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
font-size: 1px;
|
||||
z-index: -1;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
`;
|
||||
document.getElementsByTagName('head')[0].appendChild(style);
|
||||
|
||||
// Code is mainly from
|
||||
// https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
|
||||
const isSafari = navigator.appVersion.search('Safari') !== -1 && navigator.appVersion.search('Chrome') === -1 && navigator.appVersion.search('CrMo') === -1 && navigator.appVersion.search('CriOS') === -1;
|
||||
const isIe = (navigator.userAgent.toLowerCase().indexOf('msie') !== -1 || navigator.userAgent.toLowerCase().indexOf('trident') !== -1);
|
||||
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.setAttribute('type', 'text');
|
||||
hiddenInput.setAttribute('id', 'hidden-input-copy-paste');
|
||||
hiddenInput.setAttribute('class', 'hidden-copy-paste');
|
||||
|
||||
document.body.append(hiddenInput);
|
||||
|
||||
let ieClipboardDiv: HTMLDivElement | null = null;
|
||||
if (isIe) {
|
||||
ieClipboardDiv = document.createElement('div');
|
||||
ieClipboardDiv.setAttribute('id', 'hidden-ie-clipboard-copy-paste');
|
||||
ieClipboardDiv.setAttribute('class', 'hidden-copy-paste');
|
||||
ieClipboardDiv.setAttribute('contenteditable', 'true');
|
||||
document.body.append(ieClipboardDiv);
|
||||
|
||||
document.addEventListener('beforepaste', () => {
|
||||
// @ts-ignore
|
||||
if (hiddenInput.is(':focus')) {
|
||||
this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
let userInput = '';
|
||||
const hiddenInputListener = (text: string) => { };
|
||||
|
||||
hiddenInput.addEventListener('input', (e) => {
|
||||
const value = hiddenInput.value;
|
||||
userInput += value;
|
||||
hiddenInputListener(userInput);
|
||||
|
||||
// There is a bug (sometimes) with Safari and the input area can't be updated during
|
||||
// the input event, so we update the input area after the event is done being processed
|
||||
if (isSafari) {
|
||||
hiddenInput.focus();
|
||||
setTimeout(() => { this.focusHiddenArea(hiddenInput); }, 0);
|
||||
} else {
|
||||
this.focusHiddenArea(hiddenInput);
|
||||
}
|
||||
});
|
||||
|
||||
// Set clipboard event listeners on the document.
|
||||
['paste'].forEach((event) => {
|
||||
document.addEventListener(event, (e) => {
|
||||
// Check if the event got emitted from a message box or from something
|
||||
// else which should ignore the copy/paste
|
||||
// @ts-ignore
|
||||
const path = e.path || (e.composedPath && e.composedPath());
|
||||
for (let index = 0; index < path.length; index++) {
|
||||
if (path[index].className && typeof path[index].className === 'string' && (
|
||||
path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ieClipboardDiv !== null) {
|
||||
this.ieClipboardEvent(event, ieClipboardDiv);
|
||||
} else {
|
||||
this.standardClipboardEvent(event, e as ClipboardEvent);
|
||||
// @ts-ignore
|
||||
if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
|
||||
// That it still allows to paste into text, email, password & textarea-fiels we
|
||||
// check if we can identify the active element and if so only
|
||||
// run it if something else is selected.
|
||||
this.focusHiddenArea(hiddenInput);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
receivedCopyPasteData (plainTextData: string, event?: ClipboardEvent): void {
|
||||
// THIS HAS TO BE DEFINED IN COMPONENT!
|
||||
},
|
||||
|
||||
// For every browser except IE, we can easily get and set data on the clipboard
|
||||
standardClipboardEvent (clipboardEventName: string, event: ClipboardEvent) {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData !== null && clipboardEventName === 'paste') {
|
||||
const clipboardText = clipboardData.getData('text/plain');
|
||||
this.receivedCopyPasteData(clipboardText, event);
|
||||
}
|
||||
},
|
||||
|
||||
// For IE, we can get/set Text or URL just as we normally would
|
||||
ieClipboardEvent (clipboardEventName: string, ieClipboardDiv: HTMLDivElement) {
|
||||
// @ts-ignore
|
||||
const clipboardData = window.clipboardData;
|
||||
if (clipboardEventName === 'paste') {
|
||||
const clipboardText = clipboardData.getData('Text');
|
||||
// @ts-ignore
|
||||
ieClipboardDiv.empty();
|
||||
this.receivedCopyPasteData(clipboardText);
|
||||
}
|
||||
},
|
||||
|
||||
// Focuses an element to be ready for copy/paste (used exclusively for IE)
|
||||
focusIeClipboardDiv (ieClipboardDiv: HTMLDivElement) {
|
||||
ieClipboardDiv.focus();
|
||||
const range = document.createRange();
|
||||
// @ts-ignore
|
||||
range.selectNodeContents((ieClipboardDiv.get(0)));
|
||||
const selection = window.getSelection();
|
||||
if (selection !== null) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
},
|
||||
|
||||
focusHiddenArea (hiddenInput: HTMLInputElement) {
|
||||
// In order to ensure that the browser will fire clipboard events, we always need to have something selected
|
||||
hiddenInput.value = ' ';
|
||||
hiddenInput.focus();
|
||||
hiddenInput.select();
|
||||
},
|
||||
|
||||
/**
|
||||
* Copies given data to clipboard
|
||||
*/
|
||||
copyToClipboard (value: string): void {
|
||||
// FROM: https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f
|
||||
const element = document.createElement('textarea'); // Create a <textarea> element
|
||||
element.value = value; // Set its value to the string that you want copied
|
||||
element.setAttribute('readonly', ''); // Make it readonly to be tamper-proof
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = '-9999px'; // Move outside the screen to make it invisible
|
||||
document.body.appendChild(element); // Append the <textarea> element to the HTML document
|
||||
|
||||
const selection = document.getSelection();
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = selection.rangeCount > 0 // Check if there is any content selected previously
|
||||
? selection.getRangeAt(0) // Store selection if found
|
||||
: false; // Mark as false to know no selection existed before
|
||||
element.select(); // Select the <textarea> content
|
||||
document.execCommand('copy'); // Copy - only works as a result of a user action (e.g. click events)
|
||||
document.body.removeChild(element); // Remove the <textarea> element
|
||||
if (selected) {
|
||||
// If a selection existed before copying
|
||||
selection.removeAllRanges(); // Unselect everything on the HTML document
|
||||
selection.addRange(selected); // Restore the original selection
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
77
packages/editor-ui/src/components/mixins/genericHelpers.ts
Normal file
77
packages/editor-ui/src/components/mixins/genericHelpers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import dateformat from 'dateformat';
|
||||
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import { MessageType } from '@/Interface';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
export const genericHelpers = mixins(showMessage).extend({
|
||||
data () {
|
||||
return {
|
||||
loadingService: null as any | null, // tslint:disable-line:no-any
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isReadOnly (): boolean {
|
||||
if (['NodeViewExisting', 'NodeViewNew'].includes(this.$route.name as string)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
convertToDisplayDate (epochTime: number) {
|
||||
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
|
||||
},
|
||||
|
||||
editAllowedCheck (): boolean {
|
||||
if (this.isReadOnly) {
|
||||
this.$showMessage({
|
||||
title: 'Workflow can not be changed!',
|
||||
message: `The workflow can not be edited as a past execution gets displayed. To make changed either open the original workflow of which the execution gets displayed or save it under a new name first.`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
startLoading () {
|
||||
if (this.loadingService !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadingService = this.$loading(
|
||||
{
|
||||
lock: true,
|
||||
text: 'Loading',
|
||||
spinner: 'el-icon-loading',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
}
|
||||
);
|
||||
},
|
||||
stopLoading () {
|
||||
if (this.loadingService !== null) {
|
||||
this.loadingService.close();
|
||||
this.loadingService = null;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
|
||||
try {
|
||||
await this.$confirm(message, headline, {
|
||||
confirmButtonText,
|
||||
cancelButtonText,
|
||||
type,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
162
packages/editor-ui/src/components/mixins/mouseSelect.ts
Normal file
162
packages/editor-ui/src/components/mixins/mouseSelect.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { INodeUi } from '@/Interface';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { nodeIndex } from '@/components/mixins/nodeIndex';
|
||||
|
||||
export const mouseSelect = mixins(nodeIndex).extend({
|
||||
data () {
|
||||
return {
|
||||
selectActive: false,
|
||||
selectBox: document.createElement('span'),
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.createSelectBox();
|
||||
},
|
||||
methods: {
|
||||
createSelectBox () {
|
||||
this.selectBox.id = 'select-box';
|
||||
this.selectBox.style.margin = '0px auto';
|
||||
this.selectBox.style.border = '2px dotted #FF0000';
|
||||
this.selectBox.style.position = 'fixed';
|
||||
this.selectBox.style.zIndex = '100';
|
||||
this.selectBox.style.visibility = 'hidden';
|
||||
|
||||
this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect);
|
||||
|
||||
// document.body.appendChild(this.selectBox);
|
||||
this.$el.appendChild(this.selectBox);
|
||||
},
|
||||
showSelectBox (event: MouseEvent) {
|
||||
// @ts-ignore
|
||||
this.selectBox.x = event.pageX;
|
||||
// @ts-ignore
|
||||
this.selectBox.y = event.pageY;
|
||||
|
||||
this.selectBox.style.left = event.pageX + 'px';
|
||||
this.selectBox.style.top = event.pageY + 'px';
|
||||
this.selectBox.style.visibility = 'visible';
|
||||
|
||||
this.selectActive = true;
|
||||
},
|
||||
updateSelectBox (event: MouseEvent) {
|
||||
const selectionBox = this.getSelectionBox(event);
|
||||
this.selectBox.style.left = selectionBox.x + 'px';
|
||||
this.selectBox.style.top = selectionBox.y + 'px';
|
||||
|
||||
this.selectBox.style.width = selectionBox.width + 'px';
|
||||
this.selectBox.style.height = selectionBox.height + 'px';
|
||||
},
|
||||
hideSelectBox () {
|
||||
this.selectBox.style.visibility = 'hidden';
|
||||
// @ts-ignore
|
||||
this.selectBox.x = 0;
|
||||
// @ts-ignore
|
||||
this.selectBox.y = 0;
|
||||
this.selectBox.style.left = '0px';
|
||||
this.selectBox.style.top = '0px';
|
||||
this.selectBox.style.width = '0px';
|
||||
this.selectBox.style.height = '0px';
|
||||
|
||||
this.selectActive = false;
|
||||
},
|
||||
getSelectionBox (event: MouseEvent) {
|
||||
return {
|
||||
// @ts-ignore
|
||||
x: Math.min(event.pageX, this.selectBox.x),
|
||||
// @ts-ignore
|
||||
y: Math.min(event.pageY, this.selectBox.y),
|
||||
// @ts-ignore
|
||||
width: Math.abs(event.pageX - this.selectBox.x),
|
||||
// @ts-ignore
|
||||
height: Math.abs(event.pageY - this.selectBox.y),
|
||||
};
|
||||
},
|
||||
getNodesInSelection (event: MouseEvent): INodeUi[] {
|
||||
const returnNodes: INodeUi[] = [];
|
||||
const selectionBox = this.getSelectionBox(event);
|
||||
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
|
||||
|
||||
// Consider the offset of the workflow when it got moved
|
||||
selectionBox.x -= offsetPosition[0];
|
||||
selectionBox.y -= offsetPosition[1];
|
||||
|
||||
// Go through all nodes and check if they are selected
|
||||
this.$store.getters.allNodes.forEach((node: INodeUi) => {
|
||||
// TODO: Currently always uses the top left corner for checking. Should probably use the center instead
|
||||
if (node.position[0] < selectionBox.x || node.position[0] > (selectionBox.x + selectionBox.width)) {
|
||||
return;
|
||||
}
|
||||
if (node.position[1] < selectionBox.y || node.position[1] > (selectionBox.y + selectionBox.height)) {
|
||||
return;
|
||||
}
|
||||
returnNodes.push(node);
|
||||
});
|
||||
|
||||
return returnNodes;
|
||||
},
|
||||
mouseDownMouseSelect (e: MouseEvent) {
|
||||
if (e.ctrlKey === true) {
|
||||
// We only care about it when the ctrl key is not pressed at the same time.
|
||||
// So we exit when it is pressed.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.getters.isActionActive('dragActive')) {
|
||||
// If a node does currently get dragged we do not activate the selection
|
||||
return;
|
||||
}
|
||||
this.showSelectBox(e);
|
||||
|
||||
// @ts-ignore // Leave like this. Do not add a anonymous function because then remove would not work anymore
|
||||
this.$el.addEventListener('mousemove', this.mouseMoveSelect);
|
||||
},
|
||||
mouseUpMouseSelect (e: MouseEvent) {
|
||||
if (this.selectActive === false) {
|
||||
// If it is not active return direcly.
|
||||
// Else normal node dragging will not work.
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.$el.removeEventListener('mousemove', this.mouseMoveSelect);
|
||||
|
||||
// Deselect all nodes
|
||||
this.deselectAllNodes();
|
||||
|
||||
// Select the nodes which are in the selection box
|
||||
const selectedNodes = this.getNodesInSelection(e);
|
||||
selectedNodes.forEach((node) => {
|
||||
this.nodeSelected(node);
|
||||
});
|
||||
|
||||
this.hideSelectBox();
|
||||
},
|
||||
mouseMoveSelect (e: MouseEvent) {
|
||||
if (e.buttons === 0) {
|
||||
// Mouse button is not pressed anymore so stop selection mode
|
||||
// Happens normally when mouse leave the view pressed and then
|
||||
// comes back unpressed.
|
||||
this.mouseUpMouseSelect(e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSelectBox(e);
|
||||
},
|
||||
|
||||
nodeSelected (node: INodeUi) {
|
||||
this.$store.commit('addSelectedNode', node);
|
||||
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
|
||||
// @ts-ignore
|
||||
this.instance.addToDragSelection(nodeElement);
|
||||
},
|
||||
deselectAllNodes () {
|
||||
// @ts-ignore
|
||||
this.instance.clearDragSelection();
|
||||
this.$store.commit('resetSelectedNodes');
|
||||
this.$store.commit('setLastSelectedNode', null);
|
||||
this.$store.commit('setActiveNode', null);
|
||||
},
|
||||
},
|
||||
});
|
||||
72
packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts
Normal file
72
packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { nodeIndex } from '@/components/mixins/nodeIndex';
|
||||
|
||||
export const moveNodeWorkflow = mixins(nodeIndex).extend({
|
||||
data () {
|
||||
return {
|
||||
moveLastPosition: [0, 0],
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
methods: {
|
||||
moveWorkflow (e: MouseEvent) {
|
||||
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
|
||||
|
||||
const nodeViewOffsetPositionX = offsetPosition[0] + (e.pageX - this.moveLastPosition[0]);
|
||||
const nodeViewOffsetPositionY = offsetPosition[1] + (e.pageY - this.moveLastPosition[1]);
|
||||
this.$store.commit('setNodeViewOffsetPosition', [nodeViewOffsetPositionX, nodeViewOffsetPositionY]);
|
||||
|
||||
// Update the last position
|
||||
this.moveLastPosition[0] = e.pageX;
|
||||
this.moveLastPosition[1] = e.pageY;
|
||||
},
|
||||
mouseDownMoveWorkflow (e: MouseEvent) {
|
||||
if (e.ctrlKey === false) {
|
||||
// We only care about it when the ctrl key is pressed at the same time.
|
||||
// So we exit when it is not pressed.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.getters.isActionActive('dragActive')) {
|
||||
// If a node does currently get dragged we do not activate the selection
|
||||
return;
|
||||
}
|
||||
|
||||
this.$store.commit('setNodeViewMoveInProgress', true);
|
||||
|
||||
this.moveLastPosition[0] = e.pageX;
|
||||
this.moveLastPosition[1] = e.pageY;
|
||||
|
||||
// @ts-ignore
|
||||
this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow);
|
||||
},
|
||||
mouseUpMoveWorkflow (e: MouseEvent) {
|
||||
if (this.$store.getters.isNodeViewMoveInProgress === false) {
|
||||
// If it is not active return direcly.
|
||||
// Else normal node dragging will not work.
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.$el.removeEventListener('mousemove', this.mouseMoveNodeWorkflow);
|
||||
|
||||
this.$store.commit('setNodeViewMoveInProgress', false);
|
||||
|
||||
// Nothing else to do. Simply leave the node view at the current offset
|
||||
},
|
||||
mouseMoveNodeWorkflow (e: MouseEvent) {
|
||||
if (e.buttons === 0) {
|
||||
// Mouse button is not pressed anymore so stop selection mode
|
||||
// Happens normally when mouse leave the view pressed and then
|
||||
// comes back unpressed.
|
||||
// @ts-ignore
|
||||
this.mouseUp(e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.moveWorkflow(e);
|
||||
},
|
||||
},
|
||||
});
|
||||
293
packages/editor-ui/src/components/mixins/nodeBase.ts
Normal file
293
packages/editor-ui/src/components/mixins/nodeBase.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { IConnectionsUi, IEndpointOptions, INodeUi, XYPositon } from '@/Interface';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { nodeIndex } from '@/components/mixins/nodeIndex';
|
||||
import { NODE_NAME_PREFIX } from '@/constants';
|
||||
|
||||
export const nodeBase = mixins(nodeIndex).extend({
|
||||
mounted () {
|
||||
// Initialize the node
|
||||
if (this.data !== null) {
|
||||
this.__addNode(this.data);
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
data (): INodeUi {
|
||||
return this.$store.getters.nodeByName(this.name);
|
||||
},
|
||||
hasIssues (): boolean {
|
||||
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
isReadOnly (): boolean {
|
||||
if (['NodeViewExisting', 'NodeViewNew'].includes(this.$route.name as string)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
nodeName (): string {
|
||||
return NODE_NAME_PREFIX + this.nodeIndex;
|
||||
},
|
||||
nodeIndex (): string {
|
||||
return this.$store.getters.getNodeIndex(this.data.name).toString();
|
||||
},
|
||||
nodeStyle (): object {
|
||||
const returnStyles: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
left: this.data.position[0] + 'px',
|
||||
top: this.data.position[1] + 'px',
|
||||
'border-color': this.data.color as string,
|
||||
};
|
||||
|
||||
return returnStyles;
|
||||
},
|
||||
},
|
||||
props: [
|
||||
'name',
|
||||
'nodeId',
|
||||
'instance',
|
||||
],
|
||||
methods: {
|
||||
__addNode (node: INodeUi) {
|
||||
// TODO: Later move the node-connection definitions to a special file
|
||||
const nodeConnectors: IConnectionsUi = {
|
||||
main: {
|
||||
input: {
|
||||
uuid: '-top',
|
||||
maxConnections: -1,
|
||||
endpoint: 'Rectangle',
|
||||
endpointStyle: { width: 24, height: 12, fill: '#555', stroke: '#555', strokeWidth: 0 },
|
||||
dragAllowedWhenFull: true,
|
||||
},
|
||||
output: {
|
||||
uuid: '-bottom',
|
||||
maxConnections: -1,
|
||||
endpoint: 'Dot',
|
||||
endpointStyle: { radius: 9, fill: '#555', outlineStroke: 'none' },
|
||||
dragAllowedWhenFull: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let nodeTypeData = this.$store.getters.nodeType(node.type);
|
||||
|
||||
if (!nodeTypeData) {
|
||||
// If node type is not know use by default the base.noOp data to display it
|
||||
nodeTypeData = this.$store.getters.nodeType('n8n-nodes-base.noOp');
|
||||
}
|
||||
|
||||
const anchorPositions: {
|
||||
[key: string]: {
|
||||
[key: number]: string[] | number[][];
|
||||
}
|
||||
} = {
|
||||
input: {
|
||||
1: [
|
||||
'Top',
|
||||
],
|
||||
2: [
|
||||
[0.3, 0, 0, -1],
|
||||
[0.7, 0, 0, -1],
|
||||
],
|
||||
3: [
|
||||
[0.25, 0, 0, -1],
|
||||
[0.5, 0, 0, -1],
|
||||
[0.75, 0, 0, -1],
|
||||
],
|
||||
},
|
||||
output: {
|
||||
1: [
|
||||
'Bottom',
|
||||
],
|
||||
2: [
|
||||
[0.3, 1, 0, 1],
|
||||
[0.7, 1, 0, 1],
|
||||
],
|
||||
3: [
|
||||
[0.25, 1, 0, 1],
|
||||
[0.5, 1, 0, 1],
|
||||
[0.75, 1, 0, 1],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Add Inputs
|
||||
let index, inputData, anchorPosition;
|
||||
let newEndpointData: IEndpointOptions;
|
||||
let indexData: {
|
||||
[key: string]: number;
|
||||
} = {};
|
||||
|
||||
nodeTypeData.inputs.forEach((inputName: string) => {
|
||||
// @ts-ignore
|
||||
inputData = nodeConnectors[inputName].input;
|
||||
|
||||
// Increment the index for inputs with current name
|
||||
if (indexData.hasOwnProperty(inputName)) {
|
||||
indexData[inputName]++;
|
||||
} else {
|
||||
indexData[inputName] = 0;
|
||||
}
|
||||
index = indexData[inputName];
|
||||
|
||||
// Get the position of the anchor depending on how many it has
|
||||
anchorPosition = anchorPositions.input[nodeTypeData.inputs.length][index];
|
||||
|
||||
newEndpointData = {
|
||||
uuid: `${this.nodeIndex}` + inputData.uuid + index,
|
||||
anchor: anchorPosition,
|
||||
maxConnections: inputData.maxConnections,
|
||||
endpoint: inputData.endpoint,
|
||||
endpointStyle: inputData.endpointStyle,
|
||||
isSource: false,
|
||||
isTarget: true,
|
||||
parameters: {
|
||||
nodeIndex: this.nodeIndex,
|
||||
type: inputName,
|
||||
index,
|
||||
},
|
||||
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
|
||||
dropOptions: {
|
||||
tolerance: 'touch',
|
||||
hoverClass: 'dropHover',
|
||||
},
|
||||
};
|
||||
|
||||
this.instance.addEndpoint(this.nodeName, newEndpointData);
|
||||
|
||||
if (index === 0 && inputName === 'main') {
|
||||
// Make the first main-input the default one to connect to when connection gets dropped on node
|
||||
this.instance.makeTarget(this.nodeName, newEndpointData);
|
||||
}
|
||||
});
|
||||
|
||||
// Add Outputs
|
||||
indexData = {};
|
||||
nodeTypeData.outputs.forEach((inputName: string) => {
|
||||
inputData = nodeConnectors[inputName].output;
|
||||
|
||||
// Increment the index for outputs with current name
|
||||
if (indexData.hasOwnProperty(inputName)) {
|
||||
indexData[inputName]++;
|
||||
} else {
|
||||
indexData[inputName] = 0;
|
||||
}
|
||||
index = indexData[inputName];
|
||||
|
||||
// Get the position of the anchor depending on how many it has
|
||||
anchorPosition = anchorPositions.output[nodeTypeData.outputs.length][index];
|
||||
|
||||
newEndpointData = {
|
||||
uuid: `${this.nodeIndex}` + inputData.uuid + index,
|
||||
anchor: anchorPosition,
|
||||
maxConnections: inputData.maxConnections,
|
||||
endpoint: inputData.endpoint,
|
||||
endpointStyle: inputData.endpointStyle,
|
||||
isSource: true,
|
||||
isTarget: false,
|
||||
parameters: {
|
||||
nodeIndex: this.nodeIndex,
|
||||
type: inputName,
|
||||
index,
|
||||
},
|
||||
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
|
||||
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
|
||||
};
|
||||
|
||||
if (nodeTypeData.outputNames) {
|
||||
// Apply output names if they got set
|
||||
newEndpointData.overlays = [
|
||||
['Label',
|
||||
{
|
||||
location: [0.5, 1.5],
|
||||
label: nodeTypeData.outputNames[index],
|
||||
cssClass: 'node-endpoint-label',
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
this.instance.addEndpoint(this.nodeName, newEndpointData);
|
||||
});
|
||||
|
||||
// Make nodes draggable
|
||||
this.instance.draggable(this.nodeName, {
|
||||
grid: [10, 10],
|
||||
start: (params: { e: MouseEvent }) => {
|
||||
if (params.e && !this.$store.getters.isNodeSelected(this.data.name)) {
|
||||
// Only the node which gets dragged directly gets an event, for all others it is
|
||||
// undefined. So check if the currently dragged node is selected and if not clear
|
||||
// the drag-selection.
|
||||
this.instance.clearDragSelection();
|
||||
this.$store.commit('resetSelectedNodes');
|
||||
}
|
||||
|
||||
this.$store.commit('addActiveAction', 'dragActive');
|
||||
},
|
||||
stop: (params: { e: MouseEvent}) => {
|
||||
if (this.$store.getters.isActionActive('dragActive')) {
|
||||
const moveNodes = this.$store.getters.getSelectedNodes.slice();
|
||||
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
|
||||
if (!selectedNodeNames.includes(this.data.name)) {
|
||||
// If the current node is not in selected add it to the nodes which
|
||||
// got moved manually
|
||||
moveNodes.push(this.data);
|
||||
}
|
||||
|
||||
// This does for some reason just get called once for the node that got clicked
|
||||
// even though "start" and "drag" gets called for all. So lets do for now
|
||||
// some dirty DOM query to get the new positions till I have more time to
|
||||
// create a proper solution
|
||||
let newNodePositon: XYPositon;
|
||||
moveNodes.forEach((node: INodeUi) => {
|
||||
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
|
||||
const element = document.getElementById(nodeElement);
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
newNodePositon = [
|
||||
parseInt(element.style.left!.slice(0, -2), 10),
|
||||
parseInt(element.style.top!.slice(0, -2), 10),
|
||||
];
|
||||
|
||||
const updateInformation = {
|
||||
name: node.name,
|
||||
properties: {
|
||||
// @ts-ignore, draggable does not have definitions
|
||||
position: newNodePositon,
|
||||
},
|
||||
};
|
||||
|
||||
this.$store.commit('updateNodeProperties', updateInformation);
|
||||
});
|
||||
|
||||
this.$store.commit('removeActiveAction', 'dragActive');
|
||||
}
|
||||
},
|
||||
filter: '.action-button',
|
||||
});
|
||||
},
|
||||
|
||||
mouseLeftClick (e: MouseEvent) {
|
||||
if (this.$store.getters.isActionActive('dragActive')) {
|
||||
this.$store.commit('removeActiveAction', 'dragActive');
|
||||
} else {
|
||||
if (!e.ctrlKey) {
|
||||
this.$emit('deselectAllNodes');
|
||||
}
|
||||
|
||||
this.$emit('nodeSelected', this.name);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
251
packages/editor-ui/src/components/mixins/nodeHelpers.ts
Normal file
251
packages/editor-ui/src/components/mixins/nodeHelpers.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
IBinaryKeyData,
|
||||
ICredentialType,
|
||||
INodeCredentialDescription,
|
||||
NodeHelpers,
|
||||
INodeParameters,
|
||||
INodeExecutionData,
|
||||
INodeIssues,
|
||||
INodeIssueObjectProperty,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
ICredentialsResponse,
|
||||
INodeUi,
|
||||
} from '../../Interface';
|
||||
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
|
||||
import { get } from 'lodash';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
export const nodeHelpers = mixins(
|
||||
restApi,
|
||||
)
|
||||
.extend({
|
||||
methods: {
|
||||
|
||||
// Returns the parameter value
|
||||
getParameterValue (nodeValues: INodeParameters, parameterName: string, path: string) {
|
||||
return get(
|
||||
nodeValues,
|
||||
path ? path + '.' + parameterName : parameterName,
|
||||
);
|
||||
},
|
||||
|
||||
// Returns if the given parameter should be displayed or not
|
||||
displayParameter (nodeValues: INodeParameters, parameter: INodeProperties | INodeCredentialDescription, path: string) {
|
||||
return NodeHelpers.displayParameterPath(nodeValues, parameter, path);
|
||||
},
|
||||
|
||||
// Returns all the issues of the node
|
||||
getNodeIssues (nodeType: INodeTypeDescription | null, node: INodeUi, ignoreIssues?: string[]): INodeIssues | null {
|
||||
let nodeIssues: INodeIssues | null = null;
|
||||
ignoreIssues = ignoreIssues || [];
|
||||
|
||||
if (nodeType === null) {
|
||||
// Node type is not known
|
||||
if (!ignoreIssues.includes('typeUnknown')) {
|
||||
nodeIssues = {
|
||||
typeUnknown: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Node type is known
|
||||
|
||||
// Add potential parameter issues
|
||||
if (!ignoreIssues.includes('parameters')) {
|
||||
nodeIssues = NodeHelpers.getNodeParametersIssues(nodeType.properties, node);
|
||||
}
|
||||
|
||||
if (!ignoreIssues.includes('credentials')) {
|
||||
// Add potential credential issues
|
||||
const nodeCredentialIssues = this.getNodeCredentialIssues(node, nodeType);
|
||||
if (nodeIssues === null) {
|
||||
nodeIssues = nodeCredentialIssues;
|
||||
} else {
|
||||
NodeHelpers.mergeIssues(nodeIssues, nodeCredentialIssues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasNodeExecutionIssues(node) === true && !ignoreIssues.includes('execution')) {
|
||||
if (nodeIssues === null) {
|
||||
nodeIssues = {};
|
||||
}
|
||||
nodeIssues.execution = true;
|
||||
}
|
||||
|
||||
return nodeIssues;
|
||||
},
|
||||
|
||||
// Set the status on all the nodes which produced an error so that it can be
|
||||
// displayed in the node-view
|
||||
hasNodeExecutionIssues (node: INodeUi): boolean {
|
||||
const workflowResultData: IRunData = this.$store.getters.getWorkflowRunData;
|
||||
|
||||
if (workflowResultData === null || !workflowResultData.hasOwnProperty(node.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const taskData of workflowResultData[node.name]) {
|
||||
if (taskData.error !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Updates the execution issues.
|
||||
updateNodesExecutionIssues () {
|
||||
const nodes = this.$store.getters.allNodes;
|
||||
|
||||
for (const node of nodes) {
|
||||
this.$store.commit('setNodeIssue', {
|
||||
node: node.name,
|
||||
type: 'execution',
|
||||
value: this.hasNodeExecutionIssues(node) ? true : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Returns all the credential-issues of the node
|
||||
getNodeCredentialIssues (node: INodeUi, nodeType?: INodeTypeDescription): INodeIssues | null {
|
||||
if (nodeType === undefined) {
|
||||
nodeType = this.$store.getters.nodeType(node.type);
|
||||
}
|
||||
|
||||
if (nodeType === null || nodeType!.credentials === undefined) {
|
||||
// Node does not need any credentials or nodeType could not be found
|
||||
return null;
|
||||
}
|
||||
|
||||
const foundIssues: INodeIssueObjectProperty = {};
|
||||
|
||||
let userCredentials: ICredentialsResponse[] | null;
|
||||
let credentialType: ICredentialType | null;
|
||||
let credentialDisplayName: string;
|
||||
let selectedCredentials: string;
|
||||
for (const credentialTypeDescription of nodeType!.credentials) {
|
||||
// Check if credentials should be displayed else ignore
|
||||
if (this.displayParameter(node.parameters, credentialTypeDescription, '') !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the display name of the credential type
|
||||
credentialType = this.$store.getters.credentialType(credentialTypeDescription.name);
|
||||
if (credentialType === null) {
|
||||
credentialDisplayName = credentialTypeDescription.name;
|
||||
} else {
|
||||
credentialDisplayName = credentialType.displayName;
|
||||
}
|
||||
|
||||
if (node.credentials === undefined || node.credentials[credentialTypeDescription.name] === undefined) {
|
||||
// Credentials are not set
|
||||
if (credentialTypeDescription.required === true) {
|
||||
foundIssues[credentialTypeDescription.name] = [`Credentials for "${credentialDisplayName}" are not set.`];
|
||||
}
|
||||
} else {
|
||||
// If they are set check if the value is valid
|
||||
selectedCredentials = node.credentials[credentialTypeDescription.name];
|
||||
userCredentials = this.$store.getters.credentialsByType(credentialTypeDescription.name);
|
||||
|
||||
if (userCredentials === null) {
|
||||
userCredentials = [];
|
||||
}
|
||||
|
||||
if (userCredentials.find((credentialData) => credentialData.name === selectedCredentials) === undefined) {
|
||||
foundIssues[credentialTypeDescription.name] = [`Credentials with name "${selectedCredentials}" do not exist for "${credentialDisplayName}".`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Could later check also if the node has access to the credentials
|
||||
if (Object.keys(foundIssues).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: foundIssues,
|
||||
};
|
||||
},
|
||||
|
||||
// Updates the node credential issues
|
||||
updateNodesCredentialsIssues () {
|
||||
const nodes = this.$store.getters.allNodes;
|
||||
let issues: INodeIssues | null;
|
||||
|
||||
for (const node of nodes) {
|
||||
issues = this.getNodeCredentialIssues(node);
|
||||
|
||||
this.$store.commit('setNodeIssue', {
|
||||
node: node.name,
|
||||
type: 'credentials',
|
||||
value: issues === null ? null : issues.credentials,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getNodeInputData (node: INodeUi | null, runIndex = 0, outputIndex = 0): INodeExecutionData[] {
|
||||
if (node === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.$store.getters.getWorkflowExecution === null) {
|
||||
return [];
|
||||
}
|
||||
const executionData: IRunExecutionData = this.$store.getters.getWorkflowExecution.data;
|
||||
const runData = executionData.resultData.runData;
|
||||
|
||||
if (runData === null || runData[node.name] === undefined ||
|
||||
!runData[node.name][runIndex].data ||
|
||||
runData[node.name][runIndex].data === undefined
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getMainInputData(runData[node.name][runIndex].data!, outputIndex);
|
||||
},
|
||||
|
||||
// Returns the data of the main input
|
||||
getMainInputData (connectionsData: ITaskDataConnections, outputIndex: number): INodeExecutionData[] {
|
||||
if (!connectionsData || !connectionsData.hasOwnProperty('main') || connectionsData.main === undefined || connectionsData.main.length < outputIndex) {
|
||||
return [];
|
||||
}
|
||||
return connectionsData.main[outputIndex] as INodeExecutionData[];
|
||||
},
|
||||
|
||||
// Returns all the binary data of all the entries
|
||||
getBinaryData (workflowRunData: IRunData | null, node: string | null, runIndex: number, outputIndex: number): IBinaryKeyData[] {
|
||||
if (node === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const runData: IRunData | null = workflowRunData;
|
||||
|
||||
if (runData === null || !runData[node] || !runData[node][runIndex] ||
|
||||
!runData[node][runIndex].data
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const inputData = this.getMainInputData(runData[node][runIndex].data!, outputIndex);
|
||||
|
||||
const returnData: IBinaryKeyData[] = [];
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
if (inputData[i].hasOwnProperty('binary') && inputData[i].binary !== undefined) {
|
||||
returnData.push(inputData[i].binary!);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
},
|
||||
},
|
||||
});
|
||||
18
packages/editor-ui/src/components/mixins/nodeIndex.ts
Normal file
18
packages/editor-ui/src/components/mixins/nodeIndex.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export const nodeIndex = Vue.extend({
|
||||
methods: {
|
||||
getNodeIndex (nodeName: string): string {
|
||||
let uniqueId = this.$store.getters.getNodeIndex(nodeName);
|
||||
|
||||
if (uniqueId === -1) {
|
||||
this.$store.commit('addToNodeIndex', nodeName);
|
||||
uniqueId = this.$store.getters.getNodeIndex(nodeName);
|
||||
}
|
||||
|
||||
// We return as string as draggable and jsplumb seems to make problems
|
||||
// when numbers are given
|
||||
return uniqueId.toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
178
packages/editor-ui/src/components/mixins/pushConnection.ts
Normal file
178
packages/editor-ui/src/components/mixins/pushConnection.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
IPushData,
|
||||
IPushDataExecutionFinished,
|
||||
IPushDataNodeExecuteAfter,
|
||||
IPushDataNodeExecuteBefore,
|
||||
IPushDataTestWebhook,
|
||||
} from '../../Interface';
|
||||
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
export const pushConnection = mixins(
|
||||
nodeHelpers,
|
||||
showMessage,
|
||||
)
|
||||
.extend({
|
||||
data () {
|
||||
return {
|
||||
eventSource: null as EventSource | null,
|
||||
reconnectTimeout: null as NodeJS.Timeout | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sessionId (): string {
|
||||
return this.$store.getters.sessionId;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pushAutomaticReconnect (): void {
|
||||
if (this.reconnectTimeout !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.pushConnect();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect to server to receive data via EventSource
|
||||
*/
|
||||
pushConnect (): void {
|
||||
// Make sure existing event-source instances get
|
||||
// always removed that we do not end up with multiple ones
|
||||
this.pushDisconnect();
|
||||
|
||||
const connectionUrl = `${this.$store.getters.getRestUrl}/push?sessionId=${this.sessionId}`;
|
||||
|
||||
this.eventSource = new EventSource(connectionUrl);
|
||||
this.eventSource.addEventListener('message', this.pushMessageReceived, false);
|
||||
|
||||
this.eventSource.addEventListener('open', () => {
|
||||
this.$store.commit('setPushConnectionActive', true);
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
}, false);
|
||||
|
||||
this.eventSource.addEventListener('error', () => {
|
||||
this.pushDisconnect();
|
||||
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
this.$store.commit('setPushConnectionActive', false);
|
||||
this.pushAutomaticReconnect();
|
||||
}, false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Close connection to server
|
||||
*/
|
||||
pushDisconnect (): void {
|
||||
if (this.eventSource !== null) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
|
||||
this.$store.commit('setPushConnectionActive', false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Process a newly received message
|
||||
*
|
||||
* @param {Event} event The event data with the message data
|
||||
* @returns {void}
|
||||
*/
|
||||
pushMessageReceived (event: Event): void {
|
||||
let receivedData: IPushData;
|
||||
try {
|
||||
// @ts-ignore
|
||||
receivedData = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
console.error('The received push data is not valid JSON.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (['executionFinished', 'nodeExecuteAfter', 'nodeExecuteBefore'].includes(receivedData.type)) {
|
||||
if (this.$store.getters.isActionActive('workflowRunning') === false) {
|
||||
// No workflow is running so ignore the messages
|
||||
return;
|
||||
}
|
||||
// Deactivated for now because sometimes the push messages arrive
|
||||
// before the execution id gets received
|
||||
// const pushData = receivedData.data as IPushDataNodeExecuteBefore;
|
||||
// if (this.$store.getters.activeExecutionId !== pushData.executionId) {
|
||||
// // The data is not for the currently active execution so ignore it.
|
||||
// // Should normally not happen but who knows...
|
||||
// return;
|
||||
// }
|
||||
}
|
||||
|
||||
if (receivedData.type === 'executionFinished') {
|
||||
// The workflow finished executing
|
||||
const pushData = receivedData.data as IPushDataExecutionFinished;
|
||||
|
||||
const runDataExecuted = pushData.data;
|
||||
|
||||
if (runDataExecuted.finished !== true) {
|
||||
// There was a problem with executing the workflow
|
||||
this.$showMessage({
|
||||
title: 'Problem executing workflow',
|
||||
message: 'There was a problem executing the workflow!',
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
// Workflow did execute without a problem
|
||||
this.$showMessage({
|
||||
title: 'Workflow got executed',
|
||||
message: 'Workflow did get executed successfully!',
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
// It does not push the runData as it got already pushed with each
|
||||
// node that did finish. For that reason copy in here the data
|
||||
// which we already have.
|
||||
runDataExecuted.data.resultData.runData = this.$store.getters.getWorkflowRunData;
|
||||
|
||||
this.$store.commit('setExecutingNode', null);
|
||||
this.$store.commit('setWorkflowExecutionData', runDataExecuted);
|
||||
this.$store.commit('removeActiveAction', 'workflowRunning');
|
||||
|
||||
// Set the node execution issues on all the nodes which produced an error so that
|
||||
// it can be displayed in the node-view
|
||||
this.updateNodesExecutionIssues();
|
||||
} else if (receivedData.type === 'nodeExecuteAfter') {
|
||||
// A node finished to execute. Add its data
|
||||
const pushData = receivedData.data as IPushDataNodeExecuteAfter;
|
||||
this.$store.commit('addNodeExecutionData', pushData);
|
||||
} else if (receivedData.type === 'nodeExecuteBefore') {
|
||||
// A node started to be executed. Set it as executing.
|
||||
const pushData = receivedData.data as IPushDataNodeExecuteBefore;
|
||||
this.$store.commit('setExecutingNode', pushData.nodeName);
|
||||
} else if (receivedData.type === 'testWebhookDeleted') {
|
||||
// A test-webhook got deleted
|
||||
const pushData = receivedData.data as IPushDataTestWebhook;
|
||||
|
||||
if (pushData.workflowId === this.$store.getters.workflowId) {
|
||||
this.$store.commit('setExecutionWaitingForWebhook', false);
|
||||
this.$store.commit('removeActiveAction', 'workflowRunning');
|
||||
}
|
||||
} else if (receivedData.type === 'testWebhookReceived') {
|
||||
// A test-webhook did get called
|
||||
const pushData = receivedData.data as IPushDataTestWebhook;
|
||||
|
||||
if (pushData.workflowId === this.$store.getters.workflowId) {
|
||||
this.$store.commit('setExecutionWaitingForWebhook', false);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
292
packages/editor-ui/src/components/mixins/restApi.ts
Normal file
292
packages/editor-ui/src/components/mixins/restApi.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import Vue from 'vue';
|
||||
import { parse } from 'flatted';
|
||||
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import {
|
||||
IActivationError,
|
||||
ICredentialsDecryptedResponse,
|
||||
ICredentialsResponse,
|
||||
IExecutionsCurrentSummaryExtended,
|
||||
IExecutionDeleteFilter,
|
||||
IExecutionPushResponse,
|
||||
IExecutionResponse,
|
||||
IExecutionFlattedResponse,
|
||||
IExecutionsListResponse,
|
||||
IExecutionsStopData,
|
||||
IN8nUISettings,
|
||||
IStartRunData,
|
||||
IWorkflowDb,
|
||||
IWorkflowShortResponse,
|
||||
IRestApi,
|
||||
IWorkflowData,
|
||||
IWorkflowDataUpdate,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialType,
|
||||
IDataObject,
|
||||
INodeCredentials,
|
||||
INodePropertyOptions,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Unflattens the Execution data.
|
||||
*
|
||||
* @export
|
||||
* @param {IExecutionFlattedResponse} fullExecutionData The data to unflatten
|
||||
* @returns {IExecutionResponse}
|
||||
*/
|
||||
function unflattenExecutionData (fullExecutionData: IExecutionFlattedResponse): IExecutionResponse {
|
||||
// Unflatten the data
|
||||
const returnData: IExecutionResponse = {
|
||||
...fullExecutionData,
|
||||
workflowData: fullExecutionData.workflowData as IWorkflowDb,
|
||||
data: parse(fullExecutionData.data),
|
||||
};
|
||||
|
||||
returnData.finished = returnData.finished ? returnData.finished : false;
|
||||
|
||||
if (fullExecutionData.id) {
|
||||
returnData.id = fullExecutionData.id;
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export class ReponseError extends Error {
|
||||
// The HTTP status code of response
|
||||
httpStatusCode?: number;
|
||||
|
||||
// The error code in the resonse
|
||||
errorCode?: number;
|
||||
|
||||
// The stack trace of the server
|
||||
serverStackTrace?: string;
|
||||
|
||||
/**
|
||||
* Creates an instance of ReponseError.
|
||||
* @param {string} message The error message
|
||||
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
|
||||
* @param {number} [httpStatusCode] The HTTP status code the response should have
|
||||
* @param {string} [stack] The stack trace
|
||||
* @memberof ReponseError
|
||||
*/
|
||||
constructor (message: string, errorCode?: number, httpStatusCode?: number, stack?: string) {
|
||||
super(message);
|
||||
this.name = 'ReponseError';
|
||||
|
||||
if (errorCode) {
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
if (httpStatusCode) {
|
||||
this.httpStatusCode = httpStatusCode;
|
||||
}
|
||||
if (stack) {
|
||||
this.serverStackTrace = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const restApi = Vue.extend({
|
||||
methods: {
|
||||
restApi (): IRestApi {
|
||||
const self = this;
|
||||
return {
|
||||
async makeRestApiRequest (method: string, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
|
||||
try {
|
||||
const options: AxiosRequestConfig = {
|
||||
method,
|
||||
url: endpoint,
|
||||
baseURL: self.$store.getters.getRestUrl,
|
||||
headers: {
|
||||
sessionid: self.$store.getters.sessionId,
|
||||
},
|
||||
};
|
||||
if (['PATCH', 'POST', 'PUT'].includes(method)) {
|
||||
options.data = data;
|
||||
} else {
|
||||
options.params = data;
|
||||
}
|
||||
|
||||
const response = await axios.request(options);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (error.message === 'Network Error') {
|
||||
throw new ReponseError('API-Server can not be reached. It is probably down.');
|
||||
}
|
||||
|
||||
const errorResponseData = error.response.data;
|
||||
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
|
||||
throw new ReponseError(errorResponseData.message, errorResponseData.code, error.response.status, errorResponseData.stack);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getActiveWorkflows: (): Promise<string[]> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/active`);
|
||||
},
|
||||
getActivationError: (id: string): Promise<IActivationError | undefined> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/active/error/${id}`);
|
||||
},
|
||||
getCurrentExecutions: (filter: object): Promise<IExecutionsCurrentSummaryExtended[]> => {
|
||||
let sendData = {};
|
||||
if (filter) {
|
||||
sendData = {
|
||||
filter,
|
||||
};
|
||||
}
|
||||
return self.restApi().makeRestApiRequest('GET', `/executions-current`, sendData);
|
||||
},
|
||||
stopCurrentExecution: (executionId: string): Promise<IExecutionsStopData> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/executions-current/${executionId}/stop`);
|
||||
},
|
||||
getSettings: (): Promise<IN8nUISettings> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/settings`);
|
||||
},
|
||||
|
||||
// Returns all node-types
|
||||
getNodeTypes: (): Promise<INodeTypeDescription[]> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/node-types`);
|
||||
},
|
||||
|
||||
// Returns all the parameter options from the server
|
||||
getNodeParameterOptions: (nodeType: string, methodName: string, credentials?: INodeCredentials): Promise<INodePropertyOptions[]> => {
|
||||
const sendData = {
|
||||
nodeType,
|
||||
methodName,
|
||||
credentials,
|
||||
};
|
||||
|
||||
return self.restApi().makeRestApiRequest('GET', '/node-parameter-options', sendData);
|
||||
},
|
||||
|
||||
// Removes a test webhook
|
||||
removeTestWebhook: (workflowId: string): Promise<boolean> => {
|
||||
return self.restApi().makeRestApiRequest('DELETE', `/test-webhook/${workflowId}`);
|
||||
},
|
||||
|
||||
// Execute a workflow
|
||||
runWorkflow: async (startRunData: IStartRunData): Promise<IExecutionPushResponse> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/workflows/run`, startRunData);
|
||||
},
|
||||
|
||||
// Creates new credentials
|
||||
createNewWorkflow: (sendData: IWorkflowData): Promise<IWorkflowDb> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/workflows`, sendData);
|
||||
},
|
||||
|
||||
// Updates an existing workflow
|
||||
updateWorkflow: (id: string, data: IWorkflowDataUpdate): Promise<IWorkflowDb> => {
|
||||
return self.restApi().makeRestApiRequest('PATCH', `/workflows/${id}`, data);
|
||||
},
|
||||
|
||||
// Deletes a workflow
|
||||
deleteWorkflow: (name: string): Promise<void> => {
|
||||
return self.restApi().makeRestApiRequest('DELETE', `/workflows/${name}`);
|
||||
},
|
||||
|
||||
// Returns the workflow with the given name
|
||||
getWorkflow: (id: string): Promise<IWorkflowDb> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/workflows/${id}`);
|
||||
},
|
||||
|
||||
// Returns all saved workflows
|
||||
getWorkflows: (filter?: object): Promise<IWorkflowShortResponse[]> => {
|
||||
let sendData;
|
||||
if (filter) {
|
||||
sendData = {
|
||||
filter,
|
||||
};
|
||||
}
|
||||
return self.restApi().makeRestApiRequest('GET', `/workflows`, sendData);
|
||||
},
|
||||
|
||||
// Returns a workflow from a given URL
|
||||
getWorkflowFromUrl: (url: string): Promise<IWorkflowDb> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/workflows/from-url`, { url });
|
||||
},
|
||||
|
||||
// Creates a new workflow
|
||||
createNewCredentials: (sendData: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/credentials`, sendData);
|
||||
},
|
||||
|
||||
// Deletes a credentials
|
||||
deleteCredentials: (id: string): Promise<void> => {
|
||||
return self.restApi().makeRestApiRequest('DELETE', `/credentials/${id}`);
|
||||
},
|
||||
|
||||
// Updates existing credentials
|
||||
updateCredentials: (id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
|
||||
return self.restApi().makeRestApiRequest('PATCH', `/credentials/${id}`, data);
|
||||
},
|
||||
|
||||
// Returns the credentials with the given id
|
||||
getCredentials: (id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> => {
|
||||
let sendData;
|
||||
if (includeData) {
|
||||
sendData = {
|
||||
includeData,
|
||||
};
|
||||
}
|
||||
return self.restApi().makeRestApiRequest('GET', `/credentials/${id}`, sendData);
|
||||
},
|
||||
|
||||
// Returns all saved credentials
|
||||
getAllCredentials: (filter?: object): Promise<ICredentialsResponse[]> => {
|
||||
let sendData;
|
||||
if (filter) {
|
||||
sendData = {
|
||||
filter,
|
||||
};
|
||||
}
|
||||
|
||||
return self.restApi().makeRestApiRequest('GET', `/credentials`, sendData);
|
||||
},
|
||||
|
||||
// Returns all credential types
|
||||
getCredentialTypes: (): Promise<ICredentialType[]> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/credential-types`);
|
||||
},
|
||||
|
||||
// Returns the execution with the given name
|
||||
getExecution: async (id: string): Promise<IExecutionResponse> => {
|
||||
const response = await self.restApi().makeRestApiRequest('GET', `/executions/${id}`);
|
||||
return unflattenExecutionData(response);
|
||||
},
|
||||
|
||||
// Deletes executions
|
||||
deleteExecutions: (sendData: IExecutionDeleteFilter): Promise<void> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/executions/delete`, sendData);
|
||||
},
|
||||
|
||||
// Returns the execution with the given name
|
||||
retryExecution: (id: string): Promise<IExecutionResponse> => {
|
||||
return self.restApi().makeRestApiRequest('POST', `/executions/${id}/retry`);
|
||||
},
|
||||
|
||||
// Returns all saved executions
|
||||
// TODO: For sure needs some kind of default filter like last day, with max 10 results, ...
|
||||
getPastExecutions: (filter: object, limit: number, lastStartedAt?: number): Promise<IExecutionsListResponse> => {
|
||||
let sendData = {};
|
||||
if (filter) {
|
||||
sendData = {
|
||||
filter,
|
||||
lastStartedAt,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return self.restApi().makeRestApiRequest('GET', `/executions`, sendData);
|
||||
},
|
||||
|
||||
// Returns all the available timezones
|
||||
getTimezones: (): Promise<IDataObject> => {
|
||||
return self.restApi().makeRestApiRequest('GET', `/options/timezones`);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/editor-ui/src/components/mixins/showMessage.ts
Normal file
26
packages/editor-ui/src/components/mixins/showMessage.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
import { Notification } from 'element-ui';
|
||||
import { ElNotificationOptions } from 'element-ui/types/notification';
|
||||
|
||||
// export const showMessage = {
|
||||
export const showMessage = Vue.extend({
|
||||
methods: {
|
||||
$showMessage (messageData: ElNotificationOptions) {
|
||||
messageData.dangerouslyUseHTMLString = true;
|
||||
if (messageData.position === undefined) {
|
||||
messageData.position = 'bottom-right';
|
||||
}
|
||||
|
||||
return Notification(messageData);
|
||||
},
|
||||
$showError (error: Error, title: string, message: string) {
|
||||
this.$showMessage({
|
||||
title,
|
||||
message: `${message}<br /><i>${error.message}</i>`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
432
packages/editor-ui/src/components/mixins/workflowHelpers.ts
Normal file
432
packages/editor-ui/src/components/mixins/workflowHelpers.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||
|
||||
import {
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeIssues,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
IWorfklowIssues,
|
||||
INodeCredentials,
|
||||
Workflow,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
IExecutionResponse,
|
||||
INodeTypesMaxCount,
|
||||
INodeUi,
|
||||
IWorkflowData,
|
||||
} from '../../Interface';
|
||||
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
export const workflowHelpers = mixins(
|
||||
nodeHelpers,
|
||||
restApi,
|
||||
showMessage,
|
||||
)
|
||||
.extend({
|
||||
methods: {
|
||||
// Returns connectionInputData to be able to execute an expression.
|
||||
connectionInputData (parentNode: string[], inputName: string, runIndex: number, inputIndex: number): INodeExecutionData[] | null {
|
||||
let connectionInputData = null;
|
||||
|
||||
if (parentNode.length) {
|
||||
// Add the input data to be able to also resolve the short expression format
|
||||
// which does not use the node name
|
||||
const parentNodeName = parentNode[0];
|
||||
|
||||
const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null;
|
||||
if (workflowRunData === null) {
|
||||
return null;
|
||||
}
|
||||
if (!workflowRunData[parentNodeName] ||
|
||||
workflowRunData[parentNodeName].length <= runIndex ||
|
||||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
|
||||
workflowRunData[parentNodeName][runIndex].data === undefined ||
|
||||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName) ||
|
||||
workflowRunData[parentNodeName][runIndex].data![inputName].length <= inputIndex
|
||||
) {
|
||||
connectionInputData = [];
|
||||
} else {
|
||||
connectionInputData = workflowRunData[parentNodeName][runIndex].data![inputName][inputIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return connectionInputData;
|
||||
},
|
||||
|
||||
// Returns a shallow copy of the nodes which means that all the data on the lower
|
||||
// levels still only gets referenced but the top level object is a different one.
|
||||
// This has the advantage that it is very fast and does not cause problems with vuex
|
||||
// when the workflow replaces the node-parameters.
|
||||
getNodes (): INodeUi[] {
|
||||
const nodes = this.$store.getters.allNodes;
|
||||
const returnNodes: INodeUi[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
returnNodes.push(Object.assign({}, node));
|
||||
}
|
||||
|
||||
return returnNodes;
|
||||
},
|
||||
|
||||
// Returns data about nodeTypes which ahve a "maxNodes" limit set.
|
||||
// For each such type does it return how high the limit is, how many
|
||||
// already exist and the name of this nodes.
|
||||
getNodeTypesMaxCount (): INodeTypesMaxCount {
|
||||
const nodes = this.$store.getters.allNodes;
|
||||
|
||||
const returnData: INodeTypesMaxCount = {};
|
||||
|
||||
const nodeTypes = this.$store.getters.allNodeTypes;
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (nodeType.maxNodes !== undefined) {
|
||||
returnData[nodeType.name] = {
|
||||
exist: 0,
|
||||
max: nodeType.maxNodes,
|
||||
nodeNames: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (returnData[node.type] !== undefined) {
|
||||
returnData[node.type].exist += 1;
|
||||
returnData[node.type].nodeNames.push(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
},
|
||||
|
||||
// Returns how many nodes of the given type currently exist
|
||||
getNodeTypeCount (nodeType: string): number {
|
||||
const nodes = this.$store.getters.allNodes;
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === nodeType) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
},
|
||||
|
||||
// Checks if everything in the workflow is complete and ready to be executed
|
||||
checkReadyForExecution (workflow: Workflow) {
|
||||
let node: INode;
|
||||
let nodeType: INodeType | undefined;
|
||||
let nodeIssues: INodeIssues | null = null;
|
||||
const workflowIssues: IWorfklowIssues = {};
|
||||
|
||||
for (const nodeName of Object.keys(workflow.nodes)) {
|
||||
nodeIssues = null;
|
||||
node = workflow.nodes[nodeName];
|
||||
|
||||
if (node.disabled === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodeType = workflow.nodeTypes.getByName(node.type);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
// Node type is not known
|
||||
nodeIssues = {
|
||||
typeUnknown: true,
|
||||
};
|
||||
} else {
|
||||
nodeIssues = this.getNodeIssues(nodeType.description, node, ['execution']);
|
||||
}
|
||||
|
||||
if (nodeIssues !== null) {
|
||||
workflowIssues[node.name] = nodeIssues;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(workflowIssues).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return workflowIssues;
|
||||
},
|
||||
|
||||
// Returns a workflow instance.
|
||||
getWorkflow (copyData?: boolean): Workflow {
|
||||
const nodes = this.getNodes();
|
||||
const connections = this.$store.getters.allConnections;
|
||||
|
||||
const nodeTypes = {
|
||||
init: async () => { },
|
||||
getAll: () => {
|
||||
// Does not get used in Workflow so no need to return it
|
||||
return [];
|
||||
},
|
||||
getByName: (nodeType: string) => {
|
||||
const nodeTypeDescription = this.$store.getters.nodeType(nodeType);
|
||||
|
||||
if (nodeTypeDescription === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
description: nodeTypeDescription,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let workflowId = this.$store.getters.workflowId;
|
||||
if (workflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
workflowId = undefined;
|
||||
}
|
||||
|
||||
if (copyData === true) {
|
||||
return new Workflow(workflowId, JSON.parse(JSON.stringify(nodes)), JSON.parse(JSON.stringify(connections)), false, nodeTypes);
|
||||
} else {
|
||||
return new Workflow(workflowId, nodes, connections, false, nodeTypes);
|
||||
}
|
||||
},
|
||||
|
||||
// Returns the currently loaded workflow as JSON.
|
||||
getWorkflowDataToSave (): Promise<IWorkflowData> {
|
||||
const workflowNodes = this.$store.getters.allNodes;
|
||||
const workflowConnections = this.$store.getters.allConnections;
|
||||
|
||||
let nodeData;
|
||||
|
||||
const nodes = [];
|
||||
for (let nodeIndex = 0; nodeIndex < workflowNodes.length; nodeIndex++) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
nodeData = this.getNodeDataToSave(workflowNodes[nodeIndex]);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
|
||||
nodes.push(nodeData);
|
||||
}
|
||||
|
||||
const data: IWorkflowData = {
|
||||
name: this.$store.getters.workflowName,
|
||||
nodes,
|
||||
connections: workflowConnections,
|
||||
active: this.$store.getters.isActive,
|
||||
settings: this.$store.getters.workflowSettings,
|
||||
};
|
||||
|
||||
const workflowId = this.$store.getters.workflowId;
|
||||
if (workflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
data.id = workflowId;
|
||||
}
|
||||
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
|
||||
// Returns all node-types
|
||||
getNodeDataToSave (node: INodeUi): INodeUi {
|
||||
const skipKeys = [
|
||||
'color',
|
||||
'continueOnFail',
|
||||
'credentials',
|
||||
'disabled',
|
||||
'issues',
|
||||
'notes',
|
||||
'parameters',
|
||||
'status',
|
||||
];
|
||||
|
||||
// @ts-ignore
|
||||
const nodeData: INodeUi = {
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
for (const key in node) {
|
||||
if (key.charAt(0) !== '_' && skipKeys.indexOf(key) === -1) {
|
||||
// @ts-ignore
|
||||
nodeData[key] = node[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Get the data of the node type that we can get the default values
|
||||
// TODO: Later also has to care about the node-type-version as defaults could be different
|
||||
const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription;
|
||||
|
||||
if (nodeType !== null) {
|
||||
// Node-Type is known so we can save the parameters correctly
|
||||
|
||||
const nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, false, false);
|
||||
nodeData.parameters = nodeParameters !== null ? nodeParameters : {};
|
||||
|
||||
// Add the node credentials if there are some set and if they should be displayed
|
||||
if (node.credentials !== undefined && nodeType.credentials !== undefined) {
|
||||
const saveCredenetials: INodeCredentials = {};
|
||||
for (const nodeCredentialTypeName of Object.keys(node.credentials)) {
|
||||
const credentialTypeDescription = nodeType.credentials
|
||||
.find((credentialTypeDescription) => credentialTypeDescription.name === nodeCredentialTypeName);
|
||||
|
||||
if (credentialTypeDescription === undefined) {
|
||||
// Credential type is not know so do not save
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.displayParameter(node.parameters, credentialTypeDescription, '') === false) {
|
||||
// Credential should not be displayed so do also not save
|
||||
continue;
|
||||
}
|
||||
|
||||
saveCredenetials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
|
||||
}
|
||||
|
||||
// Set credential property only if it has content
|
||||
if (Object.keys(saveCredenetials).length !== 0) {
|
||||
nodeData.credentials = saveCredenetials;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the node color only if it is different to the default color
|
||||
if (node.color && node.color !== nodeType.defaults.color) {
|
||||
nodeData.color = node.color;
|
||||
}
|
||||
} else {
|
||||
// Node-Type is not known so save the data as it is
|
||||
nodeData.credentials = node.credentials;
|
||||
nodeData.parameters = node.parameters;
|
||||
if (nodeData.color !== undefined) {
|
||||
nodeData.color = node.color;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the disabled property and continueOnFail only when is set
|
||||
if (node.disabled === true) {
|
||||
nodeData.disabled = true;
|
||||
}
|
||||
if (node.continueOnFail === true) {
|
||||
nodeData.continueOnFail = true;
|
||||
}
|
||||
|
||||
// Save the notes only if when they contain data
|
||||
if (![undefined, ''].includes(node.notes)) {
|
||||
nodeData.notes = node.notes;
|
||||
}
|
||||
|
||||
return nodeData;
|
||||
},
|
||||
|
||||
// Executes the given expression and returns its value
|
||||
resolveExpression (expression: string) {
|
||||
const inputIndex = 0;
|
||||
const itemIndex = 0;
|
||||
const runIndex = 0;
|
||||
const inputName = 'main';
|
||||
const activeNode = this.$store.getters.activeNode;
|
||||
const workflow = this.getWorkflow();
|
||||
const parentNode = workflow.getParentNodes(activeNode.name, inputName, 1);
|
||||
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
|
||||
let connectionInputData = this.connectionInputData(parentNode, inputName, runIndex, inputIndex);
|
||||
|
||||
let runExecutionData: IRunExecutionData;
|
||||
if (executionData === null) {
|
||||
runExecutionData = {
|
||||
resultData: {
|
||||
runData: {},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
runExecutionData = executionData.data;
|
||||
}
|
||||
|
||||
if (connectionInputData === null) {
|
||||
connectionInputData = [];
|
||||
}
|
||||
|
||||
return workflow.getParameterValue(expression, runExecutionData, runIndex, itemIndex, activeNode.name, connectionInputData, true);
|
||||
},
|
||||
|
||||
// Saves the currently loaded workflow to the database.
|
||||
async saveCurrentWorkflow (withNewName = false) {
|
||||
const currentWorkflow = this.$route.params.name;
|
||||
let workflowName: string | null | undefined = '';
|
||||
if (currentWorkflow === undefined || withNewName === true) {
|
||||
// Currently no workflow name is set to get it from user
|
||||
workflowName = await this.$prompt(
|
||||
'Enter workflow name',
|
||||
'Name',
|
||||
{
|
||||
confirmButtonText: 'Save',
|
||||
cancelButtonText: 'Cancel',
|
||||
}
|
||||
)
|
||||
.then((data) => {
|
||||
// @ts-ignore
|
||||
return data.value;
|
||||
})
|
||||
.catch(() => {
|
||||
// User did cancel
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (workflowName === undefined) {
|
||||
// User did cancel
|
||||
return;
|
||||
} else if (['', null].includes(workflowName)) {
|
||||
// User did not enter a name
|
||||
this.$showMessage({
|
||||
title: 'Name missing',
|
||||
message: `No name for the workflow got entered and could so not be saved!`,
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.$store.commit('addActiveAction', 'workflowSaving');
|
||||
|
||||
let workflowData: IWorkflowData = await this.getWorkflowDataToSave();
|
||||
|
||||
if (currentWorkflow === undefined || withNewName === true) {
|
||||
// Workflow is new or is supposed to get saved under a new name
|
||||
// so create a new etnry in database
|
||||
workflowData.name = workflowName as string;
|
||||
workflowData = await this.restApi().createNewWorkflow(workflowData);
|
||||
|
||||
this.$store.commit('setWorkflowName', workflowData.name);
|
||||
this.$store.commit('setWorkflowId', workflowData.id);
|
||||
} else {
|
||||
// Workflow exists already so update it
|
||||
await this.restApi().updateWorkflow(currentWorkflow, workflowData);
|
||||
}
|
||||
|
||||
this.$router.push({
|
||||
name: 'NodeViewExisting',
|
||||
params: { name: workflowData.id as string, action: 'workflowSave' },
|
||||
});
|
||||
|
||||
this.$store.commit('removeActiveAction', 'workflowSaving');
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Workflow saved',
|
||||
message: `The workflow "${workflowData.name}" got saved!`,
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
this.$store.commit('removeActiveAction', 'workflowSaving');
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Problem saving workflow',
|
||||
message: `There was a problem saving the workflow: "${e.message}"`,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
175
packages/editor-ui/src/components/mixins/workflowRun.ts
Normal file
175
packages/editor-ui/src/components/mixins/workflowRun.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
IExecutionPushResponse,
|
||||
IExecutionResponse,
|
||||
IStartRunData,
|
||||
} from '@/Interface';
|
||||
|
||||
import {
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
export const workflowRun = mixins(
|
||||
restApi,
|
||||
workflowHelpers,
|
||||
).extend({
|
||||
methods: {
|
||||
// Starts to executes a workflow on server.
|
||||
async runWorkflowApi (runData: IStartRunData): Promise<IExecutionPushResponse> {
|
||||
if (this.$store.getters.pushConnectionActive === false) {
|
||||
// Do not start if the connection to server is not active
|
||||
// because then it can not receive the data as it executes.
|
||||
throw new Error('No active connection to server. It is maybe down.');
|
||||
}
|
||||
|
||||
this.$store.commit('addActiveAction', 'workflowRunning');
|
||||
|
||||
let response: IExecutionPushResponse;
|
||||
|
||||
try {
|
||||
response = await this.restApi().runWorkflow(runData);
|
||||
} catch (error) {
|
||||
this.$store.commit('removeActiveAction', 'workflowRunning');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.executionId !== undefined) {
|
||||
this.$store.commit('setActiveExecutionId', response.executionId);
|
||||
}
|
||||
|
||||
if (response.waitingForWebhook === true) {
|
||||
this.$store.commit('setExecutionWaitingForWebhook', true);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async runWorkflow (nodeName: string): Promise<IExecutionPushResponse | undefined> {
|
||||
if (this.$store.getters.isActionActive('workflowRunning') === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = this.getWorkflow();
|
||||
|
||||
try {
|
||||
// Check first if the workflow has any issues before execute it
|
||||
const issuesExist = this.$store.getters.nodesIssuesExist;
|
||||
if (issuesExist === true) {
|
||||
// If issues exist get all of the issues of all nodes
|
||||
const workflowIssues = this.checkReadyForExecution(workflow);
|
||||
if (workflowIssues !== null) {
|
||||
const errorMessages = [];
|
||||
let nodeIssues: string[];
|
||||
for (const nodeName of Object.keys(workflowIssues)) {
|
||||
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
|
||||
for (const nodeIssue of nodeIssues) {
|
||||
errorMessages.push(`${nodeName}: ${nodeIssue}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Workflow can not be executed',
|
||||
message: 'The workflow has issues. Please fix them first:<br /> - ' + errorMessages.join('<br /> - '),
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the direct parents of the node
|
||||
const directParentNodes = workflow.getParentNodes(nodeName, 'main', 1);
|
||||
|
||||
const runData = this.$store.getters.getWorkflowRunData;
|
||||
|
||||
let newRunData: IRunData | undefined;
|
||||
|
||||
const startNodes: string[] = [];
|
||||
|
||||
if (runData !== null && Object.keys(runData).length !== 0) {
|
||||
newRunData = {};
|
||||
|
||||
// Go over the direct parents of the node
|
||||
for (const directParentNode of directParentNodes) {
|
||||
// Go over the parents of that node so that we can get a start
|
||||
// node for each of the branches
|
||||
const parentNodes = workflow.getParentNodes(directParentNode, 'main');
|
||||
|
||||
// Add also the direct parent to be checked
|
||||
parentNodes.push(directParentNode);
|
||||
|
||||
for (const parentNode of parentNodes) {
|
||||
if (runData[parentNode] === undefined || runData[parentNode].length === 0) {
|
||||
// When we hit a node which has no data we stop and set it
|
||||
// as a start node the execution from and then go on with other
|
||||
// direct input nodes
|
||||
startNodes.push(parentNode);
|
||||
break;
|
||||
}
|
||||
newRunData[parentNode] = runData[parentNode].slice(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newRunData).length === 0) {
|
||||
// If there is no data for any of the parent nodes make sure
|
||||
// that run data is empty that it runs regularly
|
||||
newRunData = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
startNodes.push(nodeName);
|
||||
}
|
||||
|
||||
const workflowData = await this.getWorkflowDataToSave();
|
||||
|
||||
const startRunData: IStartRunData = {
|
||||
workflowData,
|
||||
runData: newRunData,
|
||||
startNodes,
|
||||
};
|
||||
if (nodeName) {
|
||||
startRunData.destinationNode = nodeName;
|
||||
}
|
||||
|
||||
// Init the execution data to represent the start of the execution
|
||||
// that data which gets reused is already set and data of newly executed
|
||||
// nodes can be added as it gets pushed in
|
||||
const executionData: IExecutionResponse = {
|
||||
id: '__IN_PROGRESS__',
|
||||
finished: false,
|
||||
mode: 'manual',
|
||||
startedAt: new Date().getTime(),
|
||||
stoppedAt: 0,
|
||||
workflowId: workflow.id,
|
||||
data: {
|
||||
resultData: {
|
||||
runData: newRunData || {},
|
||||
startNodes,
|
||||
workflowData,
|
||||
},
|
||||
} as IRunExecutionData,
|
||||
workflowData: {
|
||||
id: this.$store.getters.workflowId,
|
||||
name: workflowData.name!,
|
||||
active: workflowData.active!,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...workflowData,
|
||||
},
|
||||
};
|
||||
this.$store.commit('setWorkflowExecutionData', executionData);
|
||||
|
||||
return await this.runWorkflowApi(startRunData);
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem running workflow', 'There was a problem running the workflow:');
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user