Initial commit to release

This commit is contained in:
Jan Oberhauser
2019-06-23 12:35:23 +02:00
commit 9cb9804eee
257 changed files with 42436 additions and 0 deletions

View 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
}
},
},
});

View 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;
}
},
},
});

View 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);
},
},
});

View 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);
},
},
});

View 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);
}
},
},
});

View 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;
},
},
});

View 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();
},
},
});

View 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);
}
}
},
},
});

View 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`);
},
};
},
},
});

View 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,
});
},
},
});

View 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',
});
}
},
},
});

View 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 />&nbsp;&nbsp;- ' + errorMessages.join('<br />&nbsp;&nbsp;- '),
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;
}
},
},
});