refactor(editor): Refactor utils files and mixins (#4654)

*  Added `utils` module. Moved `canvasHelpers` and old `utils.ts` file to it
*  Moved rest of utils and helpers
*  Fixing sytax errors
* 🔨 Refactoring new utils files
* 🔨 Organizing imports, adding comments and a bit more refactoring
* ✔️ Fixing tests
* 🔨 Moving mixins to `src`
This commit is contained in:
Milorad FIlipović
2022-11-23 13:41:53 +01:00
committed by GitHub
parent 67983e8f94
commit 5059c57f4a
167 changed files with 748 additions and 674 deletions

View File

@@ -0,0 +1,222 @@
/**
* 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';
import { debounce } from 'lodash';
export const copyPaste = Vue.extend({
data () {
return {
copyPasteElementsGotCreated: false,
hiddenInput: null as null | Element,
onPaste: null as null | Function,
onBeforePaste: null as null | Function,
};
},
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');
this.hiddenInput = hiddenInput;
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);
this.onBeforePaste = () => {
// @ts-ignore
if (hiddenInput.is(':focus')) {
this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement);
}
};
// @ts-ignore
document.addEventListener('beforepaste', this.onBeforePaste, 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);
}
});
this.onPaste = debounce((e) => {
const event = 'paste';
// 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-fields 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();
}
}
}, 1000, { leading: true });
// Set clipboard event listeners on the document.
// @ts-ignore
document.addEventListener('paste', this.onPaste);
},
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
}
},
},
beforeDestroy() {
if (this.hiddenInput) {
this.hiddenInput.remove();
}
if (this.onPaste) {
// @ts-ignore
document.removeEventListener('paste', this.onPaste);
}
if (this.onBeforePaste) {
// @ts-ignore
document.removeEventListener('beforepaste', this.onBeforePaste);
}
},
});

View File

@@ -0,0 +1,24 @@
import { debounce } from 'lodash';
import Vue from 'vue';
export const debounceHelper = Vue.extend({
data () {
return {
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
};
},
methods: {
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const { trailing, debounceTime } = inputParameters.shift();
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, trailing ? { trailing } : { leading: true } );
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
},
});

View File

@@ -0,0 +1,31 @@
import Vue from 'vue';
export const deviceSupportHelpers = Vue.extend({
data() {
return {
// @ts-ignore msMaxTouchPoints is deprecated but must fix tablet bugs before fixing this.. otherwise breaks touchscreen computers
isTouchDevice: 'ontouchstart' in window || navigator.msMaxTouchPoints,
isMacOs: /(ipad|iphone|ipod|mac)/i.test(navigator.platform), // TODO: `platform` deprecated
};
},
computed: {
// TODO: Check if used anywhere
controlKeyCode(): string {
if (this.isMacOs) {
return 'Meta';
}
return 'Control';
},
},
methods: {
isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
if (this.isTouchDevice === true) {
return true;
}
if (this.isMacOs) {
return e.metaKey;
}
return e.ctrlKey;
},
},
});

View File

@@ -0,0 +1,42 @@
import Vue from 'vue';
function broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
// @ts-ignore
(this as Vue).$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
// @ts-ignore
// eslint-disable-next-line prefer-spread
child.$emit.apply(child, [eventName].concat(params));
} else {
// @ts-ignore
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default Vue.extend({
methods: {
$dispatch(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
// @ts-ignore
// eslint-disable-next-line prefer-spread
parent.$emit.apply(parent, [eventName].concat(params));
}
},
$broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
broadcast.call(this, componentName, eventName, params);
},
},
});

View File

@@ -0,0 +1,75 @@
import { IExecutionsSummary } from "@/Interface";
import { useWorkflowsStore } from "@/stores/workflows";
import dateFormat from "dateformat";
import { mapStores } from "pinia";
import mixins from "vue-typed-mixins";
import { genericHelpers } from "./genericHelpers";
export interface IExecutionUIData {
name: string;
label: string;
startTime: string;
runningTime: string;
}
export const executionHelpers = mixins(genericHelpers).extend({
computed: {
...mapStores(
useWorkflowsStore,
),
executionId(): string {
return this.$route.params.executionId;
},
workflowName (): string {
return this.workflowsStore.workflowName;
},
currentWorkflow (): string {
return this.$route.params.name || this.workflowsStore.workflowId;
},
executions(): IExecutionsSummary[] {
return this.workflowsStore.currentWorkflowExecutions;
},
activeExecution(): IExecutionsSummary | null {
return this.workflowsStore.activeWorkflowExecution;
},
},
methods: {
getExecutionUIDetails(execution: IExecutionsSummary): IExecutionUIData {
const status = {
name: 'unknown',
startTime: this.formatDate(new Date(execution.startedAt)),
label: 'Status unknown',
runningTime: '',
};
if (execution.waitTill) {
status.name = 'waiting';
status.label = this.$locale.baseText('executionsList.waiting');
} else if (execution.stoppedAt === undefined) {
status.name = 'running';
status.label = this.$locale.baseText('executionsList.running');
status.runningTime = this.displayTimer(new Date().getTime() - new Date(execution.startedAt).getTime(), true);
} else if (execution.finished) {
status.name = 'success';
status.label = this.$locale.baseText('executionsList.succeeded');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
} else if (execution.stoppedAt !== null) {
status.name = 'error';
status.label = this.$locale.baseText('executionsList.error');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
}
return status;
},
formatDate(date: Date) {
if (date.getFullYear() === new Date().getFullYear()) {
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm');
}
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm yyyy');
},
},
});

View File

@@ -0,0 +1,41 @@
import { IExternalHooks, IRootState } from '@/Interface';
import { store } from '@/store';
import { useWebhooksStore } from '@/stores/webhooks';
import { IDataObject } from 'n8n-workflow';
import { Store } from 'pinia';
import Vue from 'vue';
export async function runExternalHook(
eventName: string,
store: Store,
metadata?: IDataObject,
) {
// @ts-ignore
if (!window.n8nExternalHooks) {
return;
}
const [resource, operator] = eventName.split('.');
// @ts-ignore
if (window.n8nExternalHooks[resource] && window.n8nExternalHooks[resource][operator]) {
// @ts-ignore
const hookMethods = window.n8nExternalHooks[resource][operator];
for (const hookmethod of hookMethods) {
await hookmethod(store, metadata);
}
}
}
export const externalHooks = Vue.extend({
methods: {
$externalHooks(): IExternalHooks {
return {
run: async (eventName: string, metadata?: IDataObject): Promise<void> => {
await runExternalHook.call(this, eventName, useWebhooksStore(), metadata);
},
};
},
},
});

View File

@@ -0,0 +1,73 @@
import { showMessage } from '@/mixins/showMessage';
import { VIEWS } from '@/constants';
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 {
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW].includes(this.$route.name as VIEWS);
},
},
methods: {
displayTimer (msPassed: number, showMs = false): string {
if (msPassed < 60000) {
if (!showMs) {
return `${Math.floor(msPassed / 1000)} ${this.$locale.baseText('genericHelpers.sec')}`;
}
return `${msPassed / 1000} ${this.$locale.baseText('genericHelpers.sec')}`;
}
const secondsPassed = Math.floor(msPassed / 1000);
const minutesPassed = Math.floor(secondsPassed / 60);
const secondsLeft = (secondsPassed - (minutesPassed * 60)).toString().padStart(2, '0');
return `${minutesPassed}:${secondsLeft} ${this.$locale.baseText('genericHelpers.min')}`;
},
editAllowedCheck (): boolean {
if (this.isReadOnly) {
this.$showMessage({
// title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'),
message: this.$locale.baseText('genericHelpers.showMessage.message'),
type: 'info',
duration: 0,
});
return false;
}
return true;
},
startLoading (text?: string) {
if (this.loadingService !== null) {
return;
}
// @ts-ignore
this.loadingService = this.$loading(
{
lock: true,
text: text || this.$locale.baseText('genericHelpers.loading'),
spinner: 'el-icon-loading',
background: 'rgba(255, 255, 255, 0.8)',
},
);
},
setLoadingText (text: string) {
this.loadingService.text = text;
},
stopLoading () {
if (this.loadingService !== null) {
this.loadingService.close();
this.loadingService = null;
}
},
},
});

View File

@@ -0,0 +1,55 @@
/**
* Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using
* unsafe onclick attribute
*/
import Vue from 'vue';
export const globalLinkActions = Vue.extend({
data(): {[key: string]: {[key: string]: Function}} {
return {
customActions: {},
};
},
mounted() {
window.addEventListener('click', this.delegateClick);
this.$root.$on('registerGlobalLinkAction', this.registerCustomAction);
},
destroyed() {
window.removeEventListener('click', this.delegateClick);
this.$root.$off('registerGlobalLinkAction', this.registerCustomAction);
},
computed: {
availableActions(): {[key: string]: Function} {
return {
reload: this.reload,
...this.customActions,
};
},
},
methods: {
registerCustomAction(key: string, action: Function) {
this.customActions[key] = action;
},
unregisterCustomAction(key: string) {
Vue.delete(this.customActions, key);
},
delegateClick(e: MouseEvent) {
const clickedElement = e.target;
if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
const actionAttribute = clickedElement.getAttribute('data-action');
if(actionAttribute && typeof this.availableActions[actionAttribute] === 'function') {
e.preventDefault();
this.availableActions[actionAttribute]();
}
},
reload() {
if (window.top) {
window.top.location.reload();
} else {
window.location.reload();
}
},
},
});

View File

@@ -0,0 +1,209 @@
import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/utils';
import { VIEWS } from '@/constants';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
export const mouseSelect = mixins(
deviceSupportHelpers,
).extend({
data () {
return {
selectActive: false,
selectBox: document.createElement('span'),
};
},
mounted () {
this.createSelectBox();
},
computed: {
...mapStores(
useUIStore,
useWorkflowsStore,
),
isDemo (): boolean {
return this.$route.name === VIEWS.DEMO;
},
},
methods: {
createSelectBox () {
this.selectBox.id = 'select-box';
this.selectBox.style.margin = '0px auto';
this.selectBox.style.border = '2px dotted #FF0000';
// Positioned absolutely within #node-view. This is consistent with how nodes are positioned.
this.selectBox.style.position = 'absolute';
this.selectBox.style.zIndex = '100';
this.selectBox.style.visibility = 'hidden';
this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect);
const nodeViewEl = this.$el.querySelector('#node-view') as HTMLDivElement;
nodeViewEl.appendChild(this.selectBox);
},
isCtrlKeyPressed (e: MouseEvent | KeyboardEvent): boolean {
if (this.isTouchDevice === true) {
return true;
}
if (this.isMacOs) {
return e.metaKey;
}
return e.ctrlKey;
},
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
const sidebarOffset = this.isDemo ? 0 : this.uiStore.sidebarMenuCollapsed ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT;
// @ts-ignore
return getRelativePosition(x - sidebarOffset, y - headerOffset, this.nodeViewScale, this.uiStore.nodeViewOffsetPosition);
},
showSelectBox (event: MouseEvent) {
const [x, y] = this.getMousePositionWithinNodeView(event);
this.selectBox = Object.assign(this.selectBox, {x, y});
// @ts-ignore
this.selectBox.style.left = this.selectBox.x + 'px';
// @ts-ignore
this.selectBox.style.top = this.selectBox.y + '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) {
const [x, y] = this.getMousePositionWithinNodeView(event);
return {
// @ts-ignore
x: Math.min(x, this.selectBox.x),
// @ts-ignore
y: Math.min(y, this.selectBox.y),
// @ts-ignore
width: Math.abs(x - this.selectBox.x),
// @ts-ignore
height: Math.abs(y - this.selectBox.y),
};
},
getNodesInSelection (event: MouseEvent): INodeUi[] {
const returnNodes: INodeUi[] = [];
const selectionBox = this.getSelectionBox(event);
// Go through all nodes and check if they are selected
this.workflowsStore.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 (this.isCtrlKeyPressed(e) === 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.uiStore.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 (this.isTouchDevice === true) {
// @ts-ignore
if (e.target && e.target.id.includes('node-view')) {
// Deselect all nodes
this.deselectAllNodes();
}
}
// If it is not active return directly.
// 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);
});
if (selectedNodes.length === 1) {
this.uiStore.lastSelectedNode = selectedNodes[0].name;
}
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);
},
nodeDeselected (node: INodeUi) {
this.uiStore.removeNodeFromSelection(node);
// @ts-ignore
this.instance.removeFromDragSelection(node.id);
},
nodeSelected (node: INodeUi) {
this.uiStore.addSelectedNode(node);
// @ts-ignore
this.instance.addToDragSelection(node.id);
},
deselectAllNodes () {
// @ts-ignore
this.instance.clearDragSelection();
this.uiStore.resetSelectedNodes();
this.uiStore.lastSelectedNode = null;
this.uiStore.lastSelectedNodeOutputIndex = null;
// @ts-ignore
this.lastSelectedConnection = null;
// @ts-ignore
this.newNodeInsertPosition = null;
},
},
});

View File

@@ -0,0 +1,90 @@
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import { getMousePosition } from '@/utils';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
export const moveNodeWorkflow = mixins(
deviceSupportHelpers,
).extend({
data () {
return {
moveLastPosition: [0, 0],
};
},
computed: {
...mapStores(useUIStore),
},
methods: {
moveWorkflow (e: MouseEvent) {
const offsetPosition = this.uiStore.nodeViewOffsetPosition;
const [x, y] = getMousePosition(e);
const nodeViewOffsetPositionX = offsetPosition[0] + (x - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (y - this.moveLastPosition[1]);
this.uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY];
// Update the last position
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
},
mouseDownMoveWorkflow (e: MouseEvent) {
if (this.isCtrlKeyPressed(e) === 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.uiStore.isActionActive('dragActive')) {
// If a node does currently get dragged we do not activate the selection
return;
}
this.uiStore.nodeViewMoveInProgress = true;
const [x, y] = getMousePosition(e);
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
// @ts-ignore
this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow);
},
mouseUpMoveWorkflow (e: MouseEvent) {
if (this.uiStore.nodeViewMoveInProgress === false) {
// If it is not active return directly.
// Else normal node dragging will not work.
return;
}
// @ts-ignore
this.$el.removeEventListener('mousemove', this.mouseMoveNodeWorkflow);
this.uiStore.nodeViewMoveInProgress = false;
// Nothing else to do. Simply leave the node view at the current offset
},
mouseMoveNodeWorkflow (e: MouseEvent) {
// @ts-ignore
if (e.target && !e.target.id.includes('node-view')) {
return;
}
if (this.uiStore.isActionActive('dragActive')) {
return;
}
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,50 @@
import mixins from 'vue-typed-mixins';
import { showMessage } from './showMessage';
import { VERSIONS_MODAL_KEY } from '@/constants';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useVersionsStore } from '@/stores/versions';
export const newVersions = mixins(
showMessage,
).extend({
computed: {
...mapStores(
useUIStore,
useVersionsStore,
),
},
methods: {
async checkForNewVersions() {
const enabled = this.versionsStore.areNotificationsEnabled;
if (!enabled) {
return;
}
await this.versionsStore.fetchVersions();
const currentVersion = this.versionsStore.currentVersion;
const nextVersions = this.versionsStore.nextVersions;
if (currentVersion && currentVersion.hasSecurityIssue && nextVersions.length) {
const fixVersion = currentVersion.securityIssueFixVersion;
let message = `Please update to latest version.`;
if (fixVersion) {
message = `Please update to version ${fixVersion} or higher.`;
}
message = `${message} <a class="primary-color">More info</a>`;
this.$showToast({
title: 'Critical update available',
message,
onClick: () => {
this.uiStore.openModal(VERSIONS_MODAL_KEY);
},
closeOnClick: true,
customClass: 'clickable',
type: 'warning',
duration: 0,
});
}
},
},
});

View File

@@ -0,0 +1,370 @@
import { PropType } from "vue";
import mixins from 'vue-typed-mixins';
import { IJsPlumbInstance, IEndpointOptions, INodeUi, XYPosition } from '@/Interface';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import { NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import {
ANCHOR_POSITIONS,
GRID_SIZE,
getInputEndpointUUID,
getOutputEndpointUUID,
getInputEndpointStyle,
getOutputEndpointStyle,
getInputNameOverlay,
getOutputNameOverlay,
getStyleTokenValue,
} from '@/utils';
import {
INodeTypeDescription,
} from 'n8n-workflow';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from "@/stores/workflows";
import { useNodeTypesStore } from "@/stores/nodeTypes";
export const nodeBase = mixins(
deviceSupportHelpers,
).extend({
mounted () {
// Initialize the node
if (this.data !== null) {
try {
this.__addNode(this.data);
} catch(error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
computed: {
...mapStores(
useNodeTypesStore,
useUIStore,
useWorkflowsStore,
),
data (): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
nodeId (): string {
return this.data?.id || '';
},
},
props: {
name: {
type: String,
},
instance: {
type: Object as PropType<IJsPlumbInstance>,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
},
methods: {
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs
let index;
const indexData: {
[key: string]: number;
} = {};
nodeTypeData.inputs.forEach((inputName: string, i: number) => {
// 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
const anchorPosition = ANCHOR_POSITIONS.input[nodeTypeData.inputs.length][index];
const newEndpointData: IEndpointOptions = {
uuid: getInputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: getInputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: getInputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: false,
isTarget: !this.isReadOnly && nodeTypeData.inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
nodeId: this.nodeId,
type: inputName,
index,
},
enabled: !this.isReadOnly, // enabled in default case to allow dragging
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
dropOptions: {
tolerance: 'touch',
hoverClass: 'dropHover',
},
};
if (nodeTypeData.inputNames) {
// Apply input names if they got set
newEndpointData.overlays = [
getInputNameOverlay(nodeTypeData.inputNames[index]),
];
}
const endpoint = this.instance.addEndpoint(this.nodeId, newEndpointData);
if(!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.inputs.length,
};
}
// TODO: Activate again if it makes sense. Currently makes problems when removing
// connection on which the input has a name. It does not get hidden because
// the endpoint to which it connects when letting it go over the node is
// different to the regular one (have different ids). So that seems to make
// problems when hiding the input-name.
// 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.nodeId, newEndpointData);
// }
});
},
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
let index;
const indexData: {
[key: string]: number;
} = {};
nodeTypeData.outputs.forEach((inputName: string, i: number) => {
// 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
const anchorPosition = ANCHOR_POSITIONS.output[nodeTypeData.outputs.length][index];
const newEndpointData: IEndpointOptions = {
uuid: getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: getOutputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: getOutputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
parameters: {
nodeId: this.nodeId,
type: inputName,
index,
},
cssClass: 'dot-output-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', {width: 1, height: 1, strokeWidth: 0}],
};
if (nodeTypeData.outputNames) {
// Apply output names if they got set
newEndpointData.overlays = [
getOutputNameOverlay(nodeTypeData.outputNames[index]),
];
}
const endpoint = this.instance.addEndpoint(this.nodeId, {...newEndpointData});
if(!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.outputs.length,
};
}
if (!this.isReadOnly) {
const plusEndpointData: IEndpointOptions = {
uuid: getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'N8nPlus',
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
endpointStyle: {
fill: getStyleTokenValue('--color-xdark'),
outlineStroke: 'none',
hover: false,
showOutputLabel: nodeTypeData.outputs.length === 1,
size: nodeTypeData.outputs.length >= 3 ? 'small' : 'medium',
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
endpointHoverStyle: {
fill: getStyleTokenValue('--color-primary'),
outlineStroke: 'none',
hover: true, // hack to distinguish hover state
},
parameters: {
nodeId: this.nodeId,
type: inputName,
index,
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', {width: 1, height: 1, strokeWidth: 0}],
};
const plusEndpoint = this.instance.addEndpoint(this.nodeId, plusEndpointData);
if(!Array.isArray(plusEndpoint)) {
plusEndpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.outputs.length,
};
}
}
});
},
__makeInstanceDraggable(node: INodeUi) {
// TODO: This caused problems with displaying old information
// https://github.com/jsplumb/katavorio/wiki
// https://jsplumb.github.io/jsplumb/home.html
// Make nodes draggable
this.instance.draggable(this.nodeId, {
grid: [GRID_SIZE, GRID_SIZE],
start: (params: { e: MouseEvent }) => {
if (this.isReadOnly === true) {
// Do not allow to move nodes in readOnly mode
return false;
}
// @ts-ignore
this.dragging = true;
const isSelected = this.uiStore.isNodeSelected(this.data.name);
const nodeName = this.data.name;
if (this.data.type === STICKY_NODE_TYPE && !isSelected) {
setTimeout(() => {
this.$emit('nodeSelected', nodeName, false, true);
}, 0);
}
if (params.e && !isSelected) {
// 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.uiStore.resetSelectedNodes();
}
this.uiStore.addActiveAction('dragActive');
return true;
},
stop: (params: { e: MouseEvent }) => {
// @ts-ignore
this.dragging = false;
if (this.uiStore.isActionActive('dragActive')) {
const moveNodes = this.uiStore.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 newNodePosition: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const element = document.getElementById(node.id);
if (element === null) {
return;
}
newNodePosition = [
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: newNodePosition,
},
};
this.workflowsStore.updateNodeProperties(updateInformation);
});
this.$emit('moved', node);
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
});
},
__addNode (node: INodeUi) {
let nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.nodeTypesStore.getNodeType(NO_OP_NODE_TYPE);
}
this.__addInputEndpoints(node, nodeTypeData);
this.__addOutputEndpoints(node, nodeTypeData);
this.__makeInstanceDraggable(node);
},
touchEnd(e: MouseEvent) {
if (this.isTouchDevice) {
if (this.uiStore.isActionActive('dragActive')) {
this.uiStore.removeActiveAction('dragActive');
}
}
},
mouseLeftClick (e: MouseEvent) {
// @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('no-select-on-click')) {
return;
}
}
if (!this.isTouchDevice) {
if (this.uiStore.isActionActive('dragActive')) {
this.uiStore.removeActiveAction('dragActive');
} else {
if (!this.isCtrlKeyPressed(e)) {
this.$emit('deselectAllNodes');
}
if (this.uiStore.isNodeSelected(this.data.name)) {
this.$emit('deselectNode', this.name);
} else {
this.$emit('nodeSelected', this.name);
}
}
}
},
},
});

View File

@@ -0,0 +1,531 @@
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
EnterpriseEditionFeature,
} from '@/constants';
import {
IBinaryKeyData,
ICredentialType,
INodeCredentialDescription,
NodeHelpers,
INodeCredentialsDetails,
INodeExecutionData,
INodeIssues,
INodeIssueObjectProperty,
INodeParameters,
INodeProperties,
INodeTypeDescription,
IRunData,
ITaskDataConnections,
INode,
INodePropertyOptions,
IDataObject,
} from 'n8n-workflow';
import {
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IUser,
} from '@/Interface';
import { restApi } from '@/mixins/restApi';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
import { isObjectLiteral } from '@/utils';
import {getCredentialPermissions} from "@/permissions";
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/settings';
import { useUsersStore } from '@/stores/users';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useCredentialsStore } from '@/stores/credentials';
export const nodeHelpers = mixins(
restApi,
)
.extend({
computed: {
...mapStores(
useCredentialsStore,
useNodeTypesStore,
useSettingsStore,
useWorkflowsStore,
),
},
methods: {
hasProxyAuth (node: INodeUi): boolean {
return Object.keys(node.parameters).includes('nodeCredentialType');
},
isCustomApiCallSelected (nodeValues: INodeParameters): boolean {
const { parameters } = nodeValues;
if (!isObjectLiteral(parameters)) return false;
return (
parameters.resource !== undefined && parameters.resource.includes(CUSTOM_API_CALL_KEY) ||
parameters.operation !== undefined && parameters.operation.includes(CUSTOM_API_CALL_KEY)
);
},
// 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, node: INodeUi | null) {
return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node);
},
// Returns all the issues of the node
getNodeIssues (nodeType: INodeTypeDescription | null, node: INodeUi, ignoreIssues?: string[]): INodeIssues | null {
const pinDataNodeNames = Object.keys(this.workflowsStore.getPinData || {});
let nodeIssues: INodeIssues | null = null;
ignoreIssues = ignoreIssues || [];
if (node.disabled === true || pinDataNodeNames.includes(node.name)) {
// Ignore issues on disabled and pindata nodes
return null;
}
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 = this.workflowsStore.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;
},
reportUnsetCredential(credentialType: ICredentialType) {
return {
credentials: {
[credentialType.name]: [
this.$locale.baseText(
'nodeHelpers.credentialsUnset',
{
interpolate: {
credentialType: credentialType.displayName,
},
},
),
],
},
};
},
// Updates the execution issues.
updateNodesExecutionIssues () {
const nodes = this.workflowsStore.allNodes;
for (const node of nodes) {
this.workflowsStore.setNodeIssue({
node: node.name,
type: 'execution',
value: this.hasNodeExecutionIssues(node) ? true : null,
});
}
},
// Updates the credential-issues of the node
updateNodeCredentialIssues(node: INodeUi): void {
const fullNodeIssues: INodeIssues | null = this.getNodeCredentialIssues(node);
let newIssues: INodeIssueObjectProperty | null = null;
if (fullNodeIssues !== null) {
newIssues = fullNodeIssues.credentials!;
}
this.workflowsStore.setNodeIssue({
node: node.name,
type: 'credentials',
value: newIssues,
});
},
// Updates the parameter-issues of the node
updateNodeParameterIssues(node: INodeUi, nodeType?: INodeTypeDescription): void {
if (nodeType === undefined) {
nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
}
if (nodeType === null) {
// Could not find nodeType so can not update issues
return;
}
// All data got updated everywhere so update now the issues
const fullNodeIssues: INodeIssues | null = NodeHelpers.getNodeParametersIssues(nodeType!.properties, node);
let newIssues: INodeIssueObjectProperty | null = null;
if (fullNodeIssues !== null) {
newIssues = fullNodeIssues.parameters!;
}
this.workflowsStore.setNodeIssue({
node: node.name,
type: 'parameters',
value: newIssues,
});
},
// Returns all the credential-issues of the node
getNodeCredentialIssues (node: INodeUi, nodeType?: INodeTypeDescription): INodeIssues | null {
if (node.disabled) {
// Node is disabled
return null;
}
if (!nodeType) {
nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
}
if (!nodeType?.credentials) {
// 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: INodeCredentialsDetails;
const {
authentication,
genericAuthType,
nodeCredentialType,
} = node.parameters as HttpRequestNode.V2.AuthParams;
if (
authentication === 'genericCredentialType' &&
genericAuthType !== '' &&
selectedCredsAreUnusable(node, genericAuthType)
) {
const credential = this.credentialsStore.getCredentialTypeByName(genericAuthType);
return this.reportUnsetCredential(credential);
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
node.credentials !== undefined
) {
const stored = this.credentialsStore.getCredentialsByType(nodeCredentialType);
if (selectedCredsDoNotExist(node, nodeCredentialType, stored)) {
const credential = this.credentialsStore.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
selectedCredsAreUnusable(node, nodeCredentialType)
) {
const credential = this.credentialsStore.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
for (const credentialTypeDescription of nodeType.credentials) {
// Check if credentials should be displayed else ignore
if (!this.displayParameter(node.parameters, credentialTypeDescription, '', node)) {
continue;
}
// Get the display name of the credential type
credentialType = this.credentialsStore.getCredentialTypeByName(credentialTypeDescription.name);
if (credentialType === null) {
credentialDisplayName = credentialTypeDescription.name;
} else {
credentialDisplayName = credentialType.displayName;
}
if (!node.credentials || !node.credentials?.[credentialTypeDescription.name]) {
// Credentials are not set
if (credentialTypeDescription.required) {
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.notSet', { interpolate: { type: credentialDisplayName } })];
}
} else {
// If they are set check if the value is valid
selectedCredentials = node.credentials[credentialTypeDescription.name] as INodeCredentialsDetails;
if (typeof selectedCredentials === 'string') {
selectedCredentials = {
id: null,
name: selectedCredentials,
};
}
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser || {} as IUser;
userCredentials = this.credentialsStore.getCredentialsByType(credentialTypeDescription.name)
.filter((credential: ICredentialsResponse) => {
const permissions = getCredentialPermissions(currentUser, credential);
return permissions.use;
});
if (userCredentials === null) {
userCredentials = [];
}
if (selectedCredentials.id) {
const idMatch = userCredentials.find((credentialData) => credentialData.id === selectedCredentials.id);
if (idMatch) {
continue;
}
}
const nameMatches = userCredentials.filter((credentialData) => credentialData.name === selectedCredentials.name);
if (nameMatches.length > 1) {
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.notIdentified', { interpolate: { name: selectedCredentials.name, type: credentialDisplayName } }), this.$locale.baseText('nodeIssues.credentials.notIdentified.hint')];
continue;
}
if (nameMatches.length === 0) {
if (!this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.doNotExist', { interpolate: { name: selectedCredentials.name, type: credentialDisplayName } }), this.$locale.baseText('nodeIssues.credentials.doNotExist.hint')];
}
}
}
}
// 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.workflowsStore.allNodes;
let issues: INodeIssues | null;
for (const node of nodes) {
issues = this.getNodeCredentialIssues(node);
this.workflowsStore.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.workflowsStore.getWorkflowExecution === null) {
return [];
}
const executionData = this.workflowsStore.getWorkflowExecution.data;
if (!executionData || !executionData.resultData) { // unknown status
return [];
}
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 || connectionsData.main[outputIndex] === null) {
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;
},
disableNodes(nodes: INodeUi[]) {
for (const node of nodes) {
// Toggle disabled flag
const updateInformation = {
name: node.name,
properties: {
disabled: !node.disabled,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
this.$telemetry.track('User set node enabled status', { node_type: node.type, is_enabled: node.disabled, workflow_id: this.workflowsStore.workflowId });
this.workflowsStore.updateNodeProperties(updateInformation);
this.workflowsStore.clearNodeExecutionData(node.name);
this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(node);
}
},
// @ts-ignore
getNodeSubtitle (data, nodeType, workflow): string | undefined {
if (!data) {
return undefined;
}
if (data.notesInFlow) {
return data.notes;
}
if (nodeType !== null && nodeType.subtitle !== undefined) {
return workflow.expression.getSimpleParameterValue(data as INode, nodeType.subtitle, 'internal', PLACEHOLDER_FILLED_AT_EXECUTION_TIME) as string | undefined;
}
if (data.parameters.operation !== undefined) {
const operation = data.parameters.operation as string;
if (nodeType === null) {
return operation;
}
const operationData:INodeProperties = nodeType.properties.find((property: INodeProperties) => {
return property.name === 'operation';
});
if (operationData === undefined) {
return operation;
}
if (operationData.options === undefined) {
return operation;
}
const optionData = operationData.options.find((option) => {
return (option as INodePropertyOptions).value === data.parameters.operation;
});
if (optionData === undefined) {
return operation;
}
return optionData.name;
}
return undefined;
},
},
});
/**
* Whether the node has no selected credentials, or none of the node's
* selected credentials are of the specified type.
*/
function selectedCredsAreUnusable(node: INodeUi, credentialType: string) {
return !node.credentials || !Object.keys(node.credentials).includes(credentialType);
}
/**
* Whether the node's selected credentials of the specified type
* can no longer be found in the database.
*/
function selectedCredsDoNotExist(
node: INodeUi,
nodeCredentialType: string,
storedCredsByType: ICredentialsResponse[] | null,
) {
if (!node.credentials || !storedCredsByType) return false;
const selectedCredsByType = node.credentials[nodeCredentialType];
if (!selectedCredsByType) return false;
return !storedCredsByType.find((c) => c.id === selectedCredsByType.id);
}
declare namespace HttpRequestNode {
namespace V2 {
type AuthParams = {
authentication: 'none' | 'genericCredentialType' | 'predefinedCredentialType';
genericAuthType: string;
nodeCredentialType: string;
};
}
}

View File

@@ -0,0 +1,88 @@
import Vue from 'vue';
import { INodeUi } from '@/Interface';
import { IPinData } from 'n8n-workflow';
import { stringSizeInBytes } from '@/utils';
import { MAX_WORKFLOW_PINNED_DATA_SIZE, PIN_DATA_NODE_TYPES_DENYLIST } from '@/constants';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
export interface IPinDataContext {
node: INodeUi;
$showError(error: Error, title: string): void;
}
export const pinData = (Vue as Vue.VueConstructor<Vue & IPinDataContext>).extend({
computed: {
...mapStores(useWorkflowsStore),
pinData (): IPinData[string] | undefined {
return this.node ? this.workflowsStore.pinDataByNodeName(this.node!.name) : undefined;
},
hasPinData (): boolean {
return !!this.node && typeof this.pinData !== 'undefined';
},
isPinDataNodeType(): boolean {
return !!this.node && !PIN_DATA_NODE_TYPES_DENYLIST.includes(this.node.type);
},
},
methods: {
isValidPinDataJSON(data: string): boolean {
try {
JSON.parse(data);
return true;
} catch (error) {
const title = this.$locale.baseText('runData.editOutputInvalid');
const toRemove = new RegExp(/JSON\.parse:|of the JSON data/, 'g');
const message = error.message.replace(toRemove, '').trim();
const positionMatchRegEx = /at position (\d+)/;
const positionMatch = error.message.match(positionMatchRegEx);
error.message = message.charAt(0).toUpperCase() + message.slice(1);
error.message = error.message.replace(
'Unexpected token \' in JSON',
this.$locale.baseText('runData.editOutputInvalid.singleQuote'),
);
if (positionMatch) {
const position = parseInt(positionMatch[1], 10);
const lineBreaksUpToPosition = (data.slice(0, position).match(/\n/g) || []).length;
error.message = error.message.replace(positionMatchRegEx,
this.$locale.baseText('runData.editOutputInvalid.atPosition', {
interpolate: {
position: `${position}`,
},
}),
);
error.message = `${
this.$locale.baseText('runData.editOutputInvalid.onLine', {
interpolate: {
line: `${lineBreaksUpToPosition + 1}`,
},
})
} ${error.message}`;
}
this.$showError(error, title);
return false;
}
},
isValidPinDataSize(data: string | object): boolean {
if (typeof data === 'object') data = JSON.stringify(data);
if (this.workflowsStore.pinDataSize + stringSizeInBytes(data) > MAX_WORKFLOW_PINNED_DATA_SIZE) {
this.$showError(
new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')),
this.$locale.baseText('ndv.pinData.error.tooLarge.title'),
);
return false;
}
return true;
},
},
});

View File

@@ -0,0 +1,457 @@
import {
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
IPushData,
} from '../../Interface';
import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { showMessage } from '@/mixins/showMessage';
import { titleChange } from '@/mixins/titleChange';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import {
ExpressionError,
IDataObject,
INodeTypeNameVersion,
IWorkflowBase,
SubworkflowOperationError,
TelemetryHelpers,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { getTriggerNodeServiceName } from '@/utils';
import { codeNodeEditorEventBus } from '@/event-bus/code-node-editor-event-bus';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useCredentialsStore } from '@/stores/credentials';
export const pushConnection = mixins(
externalHooks,
nodeHelpers,
showMessage,
titleChange,
workflowHelpers,
)
.extend({
data () {
return {
eventSource: null as EventSource | null,
reconnectTimeout: null as NodeJS.Timeout | null,
retryTimeout: null as NodeJS.Timeout | null,
pushMessageQueue: [] as Array<{ event: Event, retriesLeft: number }>,
};
},
computed: {
...mapStores(
useCredentialsStore,
useNodeTypesStore,
useUIStore,
useWorkflowsStore,
),
sessionId (): string {
return this.rootStore.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.rootStore.getRestUrl}/push?sessionId=${this.sessionId}`;
this.eventSource = new EventSource(connectionUrl, { withCredentials: true });
this.eventSource.addEventListener('message', this.pushMessageReceived, false);
this.eventSource.addEventListener('open', () => {
this.rootStore.pushConnectionActive = 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.rootStore.pushConnectionActive = false;
this.pushAutomaticReconnect();
}, false);
},
/**
* Close connection to server
*/
pushDisconnect (): void {
if (this.eventSource !== null) {
this.eventSource.close();
this.eventSource = null;
this.rootStore.pushConnectionActive = false;
}
},
/**
* Sometimes the push message is faster as the result from
* the REST API so we do not know yet what execution ID
* is currently active. So internally resend the message
* a few more times
*
*/
queuePushMessage (event: Event, retryAttempts: number) {
this.pushMessageQueue.push({ event, retriesLeft: retryAttempts });
if (this.retryTimeout === null) {
this.retryTimeout = setTimeout(this.processWaitingPushMessages, 20);
}
},
/**
* Process the push messages which are waiting in the queue
*/
processWaitingPushMessages () {
if (this.retryTimeout !== null) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
const queueLength = this.pushMessageQueue.length;
for (let i = 0; i < queueLength; i++) {
const messageData = this.pushMessageQueue.shift();
if (this.pushMessageReceived(messageData!.event, true) === false) {
// Was not successful
messageData!.retriesLeft -= 1;
if (messageData!.retriesLeft > 0) {
// If still retries are left add it back and stop execution
this.pushMessageQueue.unshift(messageData!);
}
break;
}
}
if (this.pushMessageQueue.length !== 0 && this.retryTimeout === null) {
this.retryTimeout = setTimeout(this.processWaitingPushMessages, 25);
}
},
/**
* Process a newly received message
*
* @param {Event} event The event data with the message data
* @param {boolean} [isRetry] If it is a retry
*/
pushMessageReceived (event: Event, isRetry?: boolean): boolean {
const retryAttempts = 5;
let receivedData: IPushData;
try {
// @ts-ignore
receivedData = JSON.parse(event.data);
} catch (error) {
return false;
}
if (receivedData.type === 'sendConsoleMessage') {
const pushData = receivedData.data;
console.log(pushData.source, ...pushData.messages); // eslint-disable-line no-console
return true;
}
if (!['testWebhookReceived'].includes(receivedData.type) && isRetry !== true && this.pushMessageQueue.length) {
// If there are already messages in the queue add the new one that all of them
// get executed in order
this.queuePushMessage(event, retryAttempts);
return false;
}
if (receivedData.type === 'nodeExecuteAfter' || receivedData.type === 'nodeExecuteBefore') {
if (!this.uiStore.isActionActive('workflowRunning')) {
// No workflow is running so ignore the messages
return false;
}
const pushData = receivedData.data;
if (this.workflowsStore.activeExecutionId !== pushData.executionId) {
// The data is not for the currently active execution or
// we do not have the execution id yet.
if (isRetry !== true) {
this.queuePushMessage(event, retryAttempts);
}
return false;
}
}
if (receivedData.type === 'executionFinished') {
// The workflow finished executing
const pushData = receivedData.data;
this.workflowsStore.finishActiveExecution(pushData);
if (!this.uiStore.isActionActive('workflowRunning')) {
// No workflow is running so ignore the messages
return false;
}
if (this.workflowsStore.activeExecutionId !== pushData.executionId) {
// The workflow which did finish execution did either not get started
// by this session or we do not have the execution id yet.
if (isRetry !== true) {
this.queuePushMessage(event, retryAttempts);
}
return false;
}
const runDataExecuted = pushData.data;
const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data);
const lineNumber = runDataExecuted &&
runDataExecuted.data &&
runDataExecuted.data.resultData &&
runDataExecuted.data.resultData.error &&
runDataExecuted.data.resultData.error.lineNumber;
codeNodeEditorEventBus.$emit('error-line-number', lineNumber || 'final');
const workflow = this.getCurrentWorkflow();
if (runDataExecuted.waitTill !== undefined) {
const activeExecutionId = this.workflowsStore.activeExecutionId;
const workflowSettings = this.workflowsStore.workflowSettings;
const saveManualExecutions = this.rootStore.saveManualExecutions;
const isSavingExecutions= workflowSettings.saveManualExecutions === undefined ? saveManualExecutions : workflowSettings.saveManualExecutions;
let action;
if (!isSavingExecutions) {
this.$root.$emit('registerGlobalLinkAction', 'open-settings', async () => {
if (this.workflowsStore.isNewWorkflow) await this.saveAsNewWorkflow();
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
});
action = '<a data-action="open-settings">Turn on saving manual executions</a> and run again to see what happened after this node.';
}
else {
action = `<a href="/workflow/${workflow.id}/executions/${activeExecutionId}">View the execution</a> to see what happened after this node.`;
}
// Workflow did start but had been put to wait
this.$titleSet(workflow.name as string, 'IDLE');
this.$showToast({
title: 'Workflow started waiting',
message: `${action} <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">More info</a>`,
type: 'success',
duration: 0,
});
} else if (runDataExecuted.finished !== true) {
this.$titleSet(workflow.name as string, 'ERROR');
if (
runDataExecuted.data.resultData.error?.name === 'ExpressionError' &&
(runDataExecuted.data.resultData.error as ExpressionError).context.functionality === 'pairedItem'
) {
const error = runDataExecuted.data.resultData.error as ExpressionError;
this.getWorkflowDataToSave().then((workflowData) => {
const eventData: IDataObject = {
caused_by_credential: false,
error_message: error.description,
error_title: error.message,
error_type: error.context.type,
node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph),
workflow_id: this.workflowsStore.workflowId,
};
if (error.context.nodeCause && ['no pairing info', 'invalid pairing info'].includes(error.context.type as string)) {
const node = workflow.getNode(error.context.nodeCause as string);
if (node) {
eventData.is_pinned = !!workflow.getPinDataOfNode(node.name);
eventData.mode = node.parameters.mode;
eventData.node_type = node.type;
eventData.operation = node.parameters.operation;
eventData.resource = node.parameters.resource;
}
}
this.$telemetry.track('Instance FE emitted paired item error', eventData);
});
}
if (runDataExecuted.data.resultData.error?.name === 'SubworkflowOperationError') {
const error = runDataExecuted.data.resultData.error as SubworkflowOperationError;
this.workflowsStore.subWorkflowExecutionError = error;
this.$showMessage({
title: error.message,
message: error.description,
type: 'error',
duration: 0,
});
} else {
let title: string;
if (runDataExecuted.data.resultData.lastNodeExecuted) {
title = `Problem in node ${runDataExecuted.data.resultData.lastNodeExecuted}`;
} else {
title = 'Problem executing workflow';
}
this.$showMessage({
title,
message: runDataExecutedErrorMessage,
type: 'error',
duration: 0,
});
}
} else {
// Workflow did execute without a problem
this.$titleSet(workflow.name as string, 'IDLE');
const execution = this.workflowsStore.getWorkflowExecution;
if (execution && execution.executedNode) {
const node = this.workflowsStore.getNodeByName(execution.executedNode);
const nodeType = node && this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
const nodeOutput = execution && execution.executedNode && execution.data && execution.data.resultData && execution.data.resultData.runData && execution.data.resultData.runData[execution.executedNode];
if (node && nodeType && !nodeOutput) {
this.$showMessage({
title: this.$locale.baseText('pushConnection.pollingNode.dataNotFound', {
interpolate: {
service: getTriggerNodeServiceName(nodeType),
},
}),
message: this.$locale.baseText('pushConnection.pollingNode.dataNotFound.message', {
interpolate: {
service: getTriggerNodeServiceName(nodeType),
},
}),
type: 'success',
});
} else {
this.$showMessage({
title: this.$locale.baseText('pushConnection.nodeExecutedSuccessfully'),
type: 'success',
});
}
}
else {
this.$showMessage({
title: this.$locale.baseText('pushConnection.workflowExecutedSuccessfully'),
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.
if (this.workflowsStore.getWorkflowRunData) {
runDataExecuted.data.resultData.runData = this.workflowsStore.getWorkflowRunData;
}
this.workflowsStore.executingNode = null;
this.workflowsStore.setWorkflowExecutionData(runDataExecuted as IExecutionResponse);
this.uiStore.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();
const lastNodeExecuted: string | undefined = runDataExecuted.data.resultData.lastNodeExecuted;
let itemsCount = 0;
if(lastNodeExecuted && runDataExecuted.data.resultData.runData[lastNodeExecuted as string] && !runDataExecutedErrorMessage) {
itemsCount = runDataExecuted.data.resultData.runData[lastNodeExecuted as string][0].data!.main[0]!.length;
}
this.$externalHooks().run('pushConnection.executionFinished', {
itemsCount,
nodeName: runDataExecuted.data.resultData.lastNodeExecuted,
errorMessage: runDataExecutedErrorMessage,
runDataExecutedStartData: runDataExecuted.data.startData,
resultDataError: runDataExecuted.data.resultData.error,
});
} else if (receivedData.type === 'executionStarted') {
const pushData = receivedData.data;
const executionData: IExecutionsCurrentSummaryExtended = {
id: pushData.executionId,
finished: false,
mode: pushData.mode,
startedAt: pushData.startedAt,
retryOf: pushData.retryOf,
workflowId: pushData.workflowId,
workflowName: pushData.workflowName,
};
this.workflowsStore.addActiveExecution(executionData);
} else if (receivedData.type === 'nodeExecuteAfter') {
// A node finished to execute. Add its data
const pushData = receivedData.data;
this.workflowsStore.addNodeExecutionData(pushData);
} else if (receivedData.type === 'nodeExecuteBefore') {
// A node started to be executed. Set it as executing.
const pushData = receivedData.data;
this.workflowsStore.executingNode = pushData.nodeName;
} else if (receivedData.type === 'testWebhookDeleted') {
// A test-webhook was deleted
const pushData = receivedData.data;
if (pushData.workflowId === this.workflowsStore.workflowId) {
this.workflowsStore.executionWaitingForWebhook = false;
this.uiStore.removeActiveAction('workflowRunning');
}
} else if (receivedData.type === 'testWebhookReceived') {
// A test-webhook did get called
const pushData = receivedData.data;
if (pushData.workflowId === this.workflowsStore.workflowId) {
this.workflowsStore.executionWaitingForWebhook = false;
this.workflowsStore.activeExecutionId = pushData.executionId;
}
this.processWaitingPushMessages();
} else if (receivedData.type === 'reloadNodeType') {
this.nodeTypesStore.getNodeTypes();
this.nodeTypesStore.getFullNodesProperties([receivedData.data]);
} else if (receivedData.type === 'removeNodeType') {
const pushData = receivedData.data;
const nodesToBeRemoved: INodeTypeNameVersion[] = [pushData];
// Force reload of all credential types
this.credentialsStore.fetchCredentialTypes(false)
.then(() => {
this.nodeTypesStore.removeNodeTypes(nodesToBeRemoved);
});
}
return true;
},
},
});

View File

@@ -0,0 +1,188 @@
import Vue from 'vue';
import { parse } from 'flatted';
import { Method } from 'axios';
import {
IActivationError,
IExecutionsCurrentSummaryExtended,
IExecutionDeleteFilter,
IExecutionPushResponse,
IExecutionResponse,
IExecutionFlattedResponse,
IExecutionsListResponse,
IExecutionsStopData,
IStartRunData,
IWorkflowDb,
IWorkflowShortResponse,
IRestApi,
IWorkflowDataUpdate,
INodeTranslationHeaders,
} from '@/Interface';
import {
IDataObject,
ILoadOptions,
INodeCredentials,
INodeParameters,
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
} from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/n8nRootStore';
/**
* Unflattens the Execution data.
*
* @param {IExecutionFlattedResponse} fullExecutionData The data to unflatten
*/
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 const restApi = Vue.extend({
computed: {
...mapStores(
useRootStore,
),
},
methods: {
restApi (): IRestApi {
const self = this;
return {
async makeRestApiRequest (method: Method, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
return makeRestApiRequest(self.rootStore.getRestApiContext, method, endpoint, data);
},
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`);
},
getCredentialTranslation: (credentialType): Promise<object> => {
return self.restApi().makeRestApiRequest('GET', '/credential-translation', { credentialType });
},
// 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 a new workflow
createNewWorkflow: (sendData: IWorkflowDataUpdate): 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 });
},
// Returns the execution with the given name
getExecution: async (id: string): Promise<IExecutionResponse | undefined> => {
const response = await self.restApi().makeRestApiRequest('GET', `/executions/${id}`);
return response && 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, loadWorkflow?: boolean): Promise<boolean> => {
let sendData;
if (loadWorkflow === true) {
sendData = {
loadWorkflow: true,
};
}
return self.restApi().makeRestApiRequest('POST', `/executions/${id}/retry`, sendData);
},
// 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, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse> => {
let sendData = {};
if (filter) {
sendData = {
filter,
firstId,
lastId,
limit,
};
}
return self.restApi().makeRestApiRequest('GET', `/executions`, sendData);
},
// Returns all the available timezones
getTimezones: (): Promise<IDataObject> => {
return self.restApi().makeRestApiRequest('GET', `/options/timezones`);
},
// Binary data
getBinaryBufferString: (dataPath: string): Promise<string> => {
return self.restApi().makeRestApiRequest('GET', `/data/${dataPath}`);
},
};
},
},
});

View File

@@ -0,0 +1,225 @@
// @ts-ignore
import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types/notification';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/mixins/externalHooks';
import {IExecuteContextData, IRunExecutionData} from 'n8n-workflow';
import type { ElMessageBoxOptions } from 'element-ui/types/message-box';
import type { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message';
import { sanitizeHtml } from '@/utils';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
let stickyNotificationQueue: ElNotificationComponent[] = [];
export const showMessage = mixins(externalHooks).extend({
computed: {
...mapStores(
useWorkflowsStore,
),
},
methods: {
$showMessage(
messageData: Omit<ElNotificationOptions, 'message'> & { message?: string },
track = true,
) {
messageData.dangerouslyUseHTMLString = true;
messageData.message = messageData.message ? sanitizeHtml(messageData.message) : messageData.message;
if (messageData.position === undefined) {
messageData.position = 'bottom-right';
}
const notification = this.$notify(messageData as ElNotificationOptions);
if (messageData.duration === 0) {
stickyNotificationQueue.push(notification);
}
if (messageData.type === 'error' && track) {
this.$telemetry.track('Instance FE emitted error', {
error_title: messageData.title,
error_message: messageData.message,
caused_by_credential: this.causedByCredential(messageData.message),
workflow_id: this.workflowsStore.workflowId,
});
}
return notification;
},
$showToast(config: {
title: string,
message: string,
onClick?: () => void,
onClose?: () => void,
duration?: number,
customClass?: string,
closeOnClick?: boolean,
type?: MessageType,
}) {
// eslint-disable-next-line prefer-const
let notification: ElNotificationComponent;
if (config.closeOnClick) {
const cb = config.onClick;
config.onClick = () => {
if (notification) {
notification.close();
}
if (cb) {
cb();
}
};
}
notification = this.$showMessage({
title: config.title,
message: config.message,
onClick: config.onClick,
onClose: config.onClose,
duration: config.duration,
customClass: config.customClass,
type: config.type,
});
return notification;
},
$showAlert(config: ElMessageOptions): ElMessageComponent {
return this.$message(config);
},
$getExecutionError(data: IRunExecutionData | IExecuteContextData) {
const error = data.resultData.error;
let errorMessage: string;
if (data.resultData.lastNodeExecuted && error) {
errorMessage = error.message || error.description;
} else {
errorMessage = 'There was a problem executing the workflow!';
if (error && error.message) {
let nodeName: string | undefined;
if ('node' in error) {
nodeName = typeof error.node === 'string'
? error.node
: error.node!.name;
}
const receivedError = nodeName
? `${nodeName}: ${error.message}`
: error.message;
errorMessage = `There was a problem executing the workflow:<br /><strong>"${receivedError}"</strong>`;
}
}
return errorMessage;
},
$showError(e: Error | unknown, title: string, message?: string) {
const error = e as Error;
const messageLine = message ? `${message}<br/>` : '';
this.$showMessage({
title,
message: `
${messageLine}
<i>${error.message}</i>
${this.collapsableDetails(error)}`,
type: 'error',
duration: 0,
}, false);
this.$externalHooks().run('showMessage.showError', {
title,
message,
errorMessage: error.message,
});
this.$telemetry.track('Instance FE emitted error', {
error_title: title,
error_description: message,
error_message: error.message,
caused_by_credential: this.causedByCredential(error.message),
workflow_id: this.workflowsStore.workflowId,
});
},
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise<boolean> {
try {
const options: ElMessageBoxOptions = {
confirmButtonText: confirmButtonText || this.$locale.baseText('showMessage.ok'),
cancelButtonText: cancelButtonText || this.$locale.baseText('showMessage.cancel'),
dangerouslyUseHTMLString: true,
...(type && { type }),
};
const sanitizedMessage = sanitizeHtml(message);
await this.$confirm(sanitizedMessage, headline, options);
return true;
} catch (e) {
return false;
}
},
async confirmModal (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string, showClose = false): Promise<string> {
try {
const options: ElMessageBoxOptions = {
confirmButtonText: confirmButtonText || this.$locale.baseText('showMessage.ok'),
cancelButtonText: cancelButtonText || this.$locale.baseText('showMessage.cancel'),
dangerouslyUseHTMLString: true,
showClose,
...(type && { type }),
};
const sanitizedMessage = sanitizeHtml(message);
await this.$confirm(sanitizedMessage, headline, options);
return 'confirmed';
} catch (e) {
return e as string;
}
},
clearAllStickyNotifications() {
stickyNotificationQueue.map((notification: ElNotificationComponent) => {
if (notification) {
notification.close();
}
});
stickyNotificationQueue = [];
},
// @ts-ignore
collapsableDetails({ description, node }: Error) {
if (!description) return '';
const errorDescription =
description.length > 500
? `${description.slice(0, 500)}...`
: description;
return `
<br>
<br>
<details>
<summary
style="color: #ff6d5a; font-weight: bold; cursor: pointer;"
>
${this.$locale.baseText('showMessage.showDetails')}
</summary>
<p>${node.name}: ${errorDescription}</p>
</details>
`;
},
/**
* Whether a workflow execution error was caused by a credential issue, as reflected by the error message.
*/
causedByCredential(message: string | undefined) {
if (!message) return false;
return message.includes('Credentials for') && message.includes('are not set');
},
},
});

View File

@@ -0,0 +1,31 @@
import Vue from 'vue';
import {
WorkflowTitleStatus,
} from '../../Interface';
export const titleChange = Vue.extend({
methods: {
/**
* Change title of n8n tab
*
* @param {string} workflow Name of workflow
* @param {WorkflowTitleStatus} status Status of workflow
*/
$titleSet(workflow: string, status: WorkflowTitleStatus) {
let icon = '⚠️';
if (status === 'EXECUTING') {
icon = '🔄';
} else if (status === 'IDLE') {
icon = '▶️';
}
window.document.title = `n8n - ${icon} ${workflow}`;
},
$titleReset() {
document.title = `n8n - Workflow Automation`;
},
},
});

View File

@@ -0,0 +1,30 @@
import { IPermissions, IUser } from '@/Interface';
import { isAuthorized } from '@/utils';
import { useUsersStore } from '@/stores/users';
import Vue from 'vue';
import { Route } from 'vue-router';
export const userHelpers = Vue.extend({
methods: {
canUserAccessRouteByName(name: string): boolean {
const {route} = this.$router.resolve({name});
return this.canUserAccessRoute(route);
},
canUserAccessCurrentRoute(): boolean {
return this.canUserAccessRoute(this.$route);
},
canUserAccessRoute(route: Route): boolean {
const permissions: IPermissions = route.meta && route.meta.permissions;
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser;
if (permissions && isAuthorized(permissions, currentUser)) {
return true;
}
return false;
},
},
});

View File

@@ -0,0 +1,115 @@
import { externalHooks } from '@/mixins/externalHooks';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { showMessage } from '@/mixins/showMessage';
import mixins from 'vue-typed-mixins';
import { LOCAL_STORAGE_ACTIVATION_FLAG, PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_ACTIVE_MODAL_KEY } from '@/constants';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useSettingsStore } from '@/stores/settings';
import { useWorkflowsStore } from '@/stores/workflows';
export const workflowActivate = mixins(
externalHooks,
workflowHelpers,
showMessage,
)
.extend({
data() {
return {
updatingWorkflowActivation: false,
};
},
computed: {
...mapStores(
useSettingsStore,
useUIStore,
useWorkflowsStore,
),
},
methods: {
async activateCurrentWorkflow(telemetrySource?: string) {
const workflowId = this.workflowsStore.workflowId;
return this.updateWorkflowActivation(workflowId, true, telemetrySource);
},
async updateWorkflowActivation(workflowId: string | undefined, newActiveState: boolean, telemetrySource?: string) {
this.updatingWorkflowActivation = true;
const nodesIssuesExist = this.workflowsStore.nodesIssuesExist as boolean;
let currWorkflowId: string | undefined = workflowId;
if (!currWorkflowId || currWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
const saved = await this.saveCurrentWorkflow();
if (!saved) {
this.updatingWorkflowActivation = false;
return;
}
currWorkflowId = this.workflowsStore.workflowId as string;
}
const isCurrentWorkflow = currWorkflowId === this.workflowsStore.workflowId;
const activeWorkflows = this.workflowsStore.activeWorkflows;
const isWorkflowActive = activeWorkflows.includes(currWorkflowId);
const telemetryPayload = {
workflow_id: currWorkflowId,
is_active: newActiveState,
previous_status: isWorkflowActive,
ndv_input: telemetrySource === 'ndv',
};
this.$telemetry.track('User set workflow active status', telemetryPayload);
this.$externalHooks().run('workflowActivate.updateWorkflowActivation', telemetryPayload);
try {
if (isWorkflowActive && newActiveState) {
this.$showMessage({
title: this.$locale.baseText('workflowActivator.workflowIsActive'),
type: 'success',
});
this.updatingWorkflowActivation = false;
return;
}
if (isCurrentWorkflow && nodesIssuesExist) {
this.$showMessage({
title: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title'),
message: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message'),
type: 'error',
});
this.updatingWorkflowActivation = false;
return;
}
await this.updateWorkflow({workflowId: currWorkflowId, active: newActiveState});
} catch (error) {
const newStateName = newActiveState === true ? 'activated' : 'deactivated';
this.$showError(
error,
this.$locale.baseText(
'workflowActivator.showError.title',
{ interpolate: { newStateName } },
) + ':',
);
this.updatingWorkflowActivation = false;
return;
}
const activationEventName = isCurrentWorkflow ? 'workflow.activeChangeCurrent' : 'workflow.activeChange';
this.$externalHooks().run(activationEventName, { workflowId: currWorkflowId, active: newActiveState });
this.$emit('workflowActiveChanged', { id: currWorkflowId, active: newActiveState });
this.updatingWorkflowActivation = false;
if (isCurrentWorkflow) {
if (newActiveState && window.localStorage.getItem(LOCAL_STORAGE_ACTIVATION_FLAG) !== 'true') {
this.uiStore.openModal(WORKFLOW_ACTIVE_MODAL_KEY);
}
else {
this.settingsStore.fetchPromptsData();
}
}
},
},
});

View File

@@ -0,0 +1,919 @@
import {
ERROR_TRIGGER_NODE_TYPE,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
WEBHOOK_NODE_TYPE,
VIEWS, EnterpriseEditionFeature,
} from '@/constants';
import {
IConnections,
IDataObject,
INode,
INodeExecutionData,
INodeIssues,
INodeParameters,
NodeParameterValue,
INodeCredentials,
INodeType,
INodeTypes,
INodeTypeData,
INodeTypeDescription,
IVersionedNodeType,
IPinData,
IRunData,
IRunExecutionData,
IWorfklowIssues,
IWorkflowDataProxyAdditionalKeys,
Workflow,
NodeHelpers,
IExecuteData,
INodeConnection,
IWebhookDescription,
deepCopy,
} from 'n8n-workflow';
import {
IExecutionResponse,
INodeTypesMaxCount,
INodeUi,
IWorkflowData,
IWorkflowDb,
IWorkflowDataUpdate,
XYPosition,
ITag,
IUpdateInformation,
TargetItem,
} from '../../Interface';
import { externalHooks } from '@/mixins/externalHooks';
import { restApi } from '@/mixins/restApi';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { showMessage } from '@/mixins/showMessage';
import { isEqual } from 'lodash';
import mixins from 'vue-typed-mixins';
import { v4 as uuid } from 'uuid';
import { getSourceItems } from '@/utils';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useRootStore } from '@/stores/n8nRootStore';
import { IWorkflowSettings } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv';
import { useTemplatesStore } from '@/stores/templates';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import useWorkflowsEEStore from "@/stores/workflows.ee";
import {useUsersStore} from "@/stores/users";
let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null;
export const workflowHelpers = mixins(
externalHooks,
nodeHelpers,
restApi,
showMessage,
)
.extend({
computed: {
...mapStores(
useNodeTypesStore,
useNDVStore,
useRootStore,
useTemplatesStore,
useWorkflowsStore,
useWorkflowsEEStore,
useUsersStore,
useUIStore,
),
},
methods: {
executeData(parentNode: string[], currentNode: string, inputName: string, runIndex: number): IExecuteData {
const executeData = {
node: {},
data: {},
source: null,
} as IExecuteData;
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 parentPinData = this.workflowsStore.getPinData![parentNodeName];
// populate `executeData` from `pinData`
if (parentPinData) {
executeData.data = { main: [parentPinData] };
executeData.source = { main: [{ previousNode: parentNodeName }] };
return executeData;
}
// populate `executeData` from `runData`
const workflowRunData = this.workflowsStore.getWorkflowRunData;
if (workflowRunData === null) {
return executeData;
}
if (!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex] ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
workflowRunData[parentNodeName][runIndex].data === undefined ||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
) {
executeData.data = {};
} else {
executeData.data = workflowRunData[parentNodeName][runIndex].data!;
if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) {
executeData.source = {
[inputName]: workflowRunData[currentNode][runIndex].source!,
};
} else {
// The current node did not get executed in UI yet so build data manually
executeData.source = {
[inputName]: [
{
previousNode: parentNodeName,
},
],
};
}
}
}
return executeData;
},
// Returns connectionInputData to be able to execute an expression.
connectionInputData (parentNode: string[], currentNode: string, inputName: string, runIndex: number, nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 }): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const executeData = this.executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (!Object.keys(executeData.data).length || executeData.data[inputName].length <= nodeConnection.sourceIndex) {
connectionInputData = [];
} else {
connectionInputData = executeData.data![inputName][nodeConnection.sourceIndex];
if (connectionInputData !== null) {
// Update the pairedItem information on items
connectionInputData = connectionInputData.map((item, itemIndex) => {
return {
...item,
pairedItem: {
item: itemIndex,
input: nodeConnection.destinationIndex,
},
};
});
}
}
}
const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
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.workflowsStore.allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
},
// Returns data about nodeTypes which have 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.workflowsStore.allNodes;
const returnData: INodeTypesMaxCount = {};
const nodeTypes = this.nodeTypesStore.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.workflowsStore.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, lastNodeName?: string) {
let node: INode;
let nodeType: INodeType | undefined;
let nodeIssues: INodeIssues | null = null;
const workflowIssues: IWorfklowIssues = {};
let checkNodes = Object.keys(workflow.nodes);
if (lastNodeName) {
checkNodes = workflow.getParentNodes(lastNodeName);
checkNodes.push(lastNodeName);
} else {
// As webhook nodes always take precedence check first
// if there are any
let checkWebhook: string[] = [];
for (const nodeName of Object.keys(workflow.nodes)) {
if (workflow.nodes[nodeName].disabled !== true && workflow.nodes[nodeName].type === WEBHOOK_NODE_TYPE) {
checkWebhook = [nodeName, ...checkWebhook, ...workflow.getChildNodes(nodeName)];
}
}
if (checkWebhook.length) {
checkNodes = checkWebhook;
} else {
// If no webhook nodes got found try to find another trigger node
const startNode = workflow.getStartNode();
if (startNode !== undefined) {
checkNodes = workflow.getChildNodes(startNode.name);
checkNodes.push(startNode.name);
}
}
}
for (const nodeName of checkNodes) {
nodeIssues = null;
node = workflow.nodes[nodeName];
if (node.disabled === true) {
// Ignore issues on disabled nodes
continue;
}
nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
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;
},
getNodeTypes (): INodeTypes {
const nodeTypes: INodeTypes = {
nodeTypes: {},
init: async (nodeTypes?: INodeTypeData): Promise<void> => { },
getAll: (): Array<INodeType | IVersionedNodeType> => {
// Does not get used in Workflow so no need to return it
return [];
},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = this.nodeTypesStore.getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger: ![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) && nodeTypeDescription.inputs.length === 0 && !nodeTypeDescription.webhooks || undefined,
};
},
};
return nodeTypes;
},
getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = this.getNodes();
const connections = this.workflowsStore.allConnections;
const cacheKey = JSON.stringify({nodes, connections});
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}
cachedWorkflowKey = cacheKey;
return this.getWorkflow(nodes, connections, copyData);
},
// Returns a workflow instance.
getWorkflow (nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = this.getNodeTypes();
let workflowId : string | undefined = this.workflowsStore.workflowId;
if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
const workflowName = this.workflowsStore.workflowName;
cachedWorkflow = new Workflow({
id: workflowId,
name: workflowName,
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData? deepCopy(connections): connections,
active: false,
nodeTypes,
settings: this.workflowsStore.workflowSettings,
pinData: this.workflowsStore.pinData,
});
return cachedWorkflow;
},
// Returns the currently loaded workflow as JSON.
getWorkflowDataToSave (): Promise<IWorkflowData> {
const workflowNodes = this.workflowsStore.allNodes;
const workflowConnections = this.workflowsStore.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.workflowsStore.workflowName,
nodes,
pinData: this.workflowsStore.getPinData,
connections: workflowConnections,
active: this.workflowsStore.isWorkflowActive,
settings: this.workflowsStore.workflow.settings,
tags: this.workflowsStore.workflowTags,
hash: this.workflowsStore.workflow.hash,
};
const workflowId = this.workflowsStore.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.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType !== null) {
// Node-Type is known so we can save the parameters correctly
const nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, false, false, node);
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 saveCredentials: INodeCredentials = {};
for (const nodeCredentialTypeName of Object.keys(node.credentials)) {
if (this.hasProxyAuth(node) || Object.keys(node.parameters).includes('genericAuthType')) {
saveCredentials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
continue;
}
const credentialTypeDescription = nodeType.credentials
// filter out credentials with same name in different node versions
.filter((c) => this.displayParameter(node.parameters, c, '', node))
.find((c) => c.name === nodeCredentialTypeName);
if (credentialTypeDescription === undefined) {
// Credential type is not know so do not save
continue;
}
if (this.displayParameter(node.parameters, credentialTypeDescription, '', node) === false) {
// Credential should not be displayed so do also not save
continue;
}
saveCredentials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
}
// Set credential property only if it has content
if (Object.keys(saveCredentials).length !== 0) {
nodeData.credentials = saveCredentials;
}
}
} 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;
},
getWebhookExpressionValue (webhookData: IWebhookDescription, key: string): string {
if (webhookData[key] === undefined) {
return 'empty';
}
try {
return this.resolveExpression(webhookData[key] as string) as string;
} catch (e) {
return this.$locale.baseText('nodeWebhooks.invalidExpression');
}
},
getWebhookUrl (webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
if (webhookData.restartWebhook === true) {
return '$execution.resumeUrl';
}
let baseUrl = this.rootStore.getWebhookUrl;
if (showUrlFor === 'test') {
baseUrl = this.rootStore.getWebhookTestUrl;
}
const workflowId = this.workflowsStore.workflowId;
const path = this.getWebhookExpressionValue(webhookData, 'path');
const isFullPath = this.getWebhookExpressionValue(webhookData, 'isFullPath') as unknown as boolean || false;
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath);
},
resolveParameter(parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], opts: {targetItem?: TargetItem, inputNodeName?: string, inputRunIndex?: number, inputBranchIndex?: number} = {}): IDataObject | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const inputName = 'main';
const activeNode = this.ndvStore.activeNode;
const workflow = this.getCurrentWorkflow();
const workflowRunData = this.workflowsStore.getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode?.name, inputName, 1);
const executionData = this.workflowsStore.getWorkflowExecution;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find((parentNodeName) => workflowRunData[parentNodeName]);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
let connectionInputData = this.connectionInputData(parentNode, activeNode.name, inputName, runIndexParent, nodeConnection);
let runExecutionData: IRunExecutionData;
if (executionData === null || !executionData.data) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: IPinData[string] = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [
pinData.map((data) => ({ json: data })),
],
},
},
],
},
},
};
}
});
if (connectionInputData === null) {
connectionInputData = [];
}
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (opts?.targetItem === undefined && workflowRunData !== null && workflowRunData[activeNode.name]) {
runIndexCurrent = workflowRunData[activeNode.name].length -1;
}
const executeData = this.executeData(parentNode, activeNode.name, inputName, runIndexCurrent);
return workflow.expression.getParameterValue(parameter, runExecutionData, runIndexCurrent, itemIndex, activeNode.name, connectionInputData, 'manual', this.rootStore.timezone, additionalKeys, executeData, false) as IDataObject;
},
resolveExpression(expression: string, siblingParameters: INodeParameters = {}, opts: {targetItem?: TargetItem, inputNodeName?: string, inputRunIndex?: number, inputBranchIndex?: number, c?: number} = {}) {
const parameters = {
'__xxxxxxx__': expression,
...siblingParameters,
};
const returnData: IDataObject | null = this.resolveParameter(parameters, opts);
if (!returnData) {
return null;
}
if (typeof returnData['__xxxxxxx__'] === 'object') {
const workflow = this.getCurrentWorkflow();
return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object);
}
return returnData['__xxxxxxx__'];
},
async updateWorkflow({workflowId, active}: {workflowId: string, active?: boolean}) {
let data: IWorkflowDataUpdate = {};
const isCurrentWorkflow = workflowId === this.workflowsStore.workflowId;
if (isCurrentWorkflow) {
data = await this.getWorkflowDataToSave();
} else {
const { hash } = await this.restApi().getWorkflow(workflowId);
data.hash = hash;
}
if (active !== undefined) {
data.active = active;
}
const workflow = await this.restApi().updateWorkflow(workflowId, data);
this.workflowsStore.setWorkflowHash(workflow.hash);
if (isCurrentWorkflow) {
this.workflowsStore.setActive(!!workflow.active);
this.uiStore.stateIsDirty = false;
}
if (workflow.active) {
this.workflowsStore.setWorkflowActive(workflowId);
} else {
this.workflowsStore.setWorkflowInactive(workflowId);
}
},
async saveCurrentWorkflow({id, name, tags}: {id?: string, name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = id || this.$route.params.name;
if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) {
return this.saveAsNewWorkflow({name, tags}, redirect);
}
// Workflow exists already so update it
try {
this.uiStore.addActiveAction('workflowSaving');
const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave();
if (name) {
workflowDataRequest.name = name.trim();
}
if (tags) {
workflowDataRequest.tags = tags;
}
workflowDataRequest.hash = this.workflowsStore.workflowHash;
const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
this.workflowsStore.setWorkflowHash(workflowData.hash);
if (name) {
this.workflowsStore.setWorkflowName({newName: workflowData.name, setStateDirty: false});
}
if (tags) {
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.workflowsStore.setWorkflowTagIds(tagIds);
}
this.uiStore.stateIsDirty = false;
this.uiStore.removeActiveAction('workflowSaving');
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} catch (error) {
this.uiStore.removeActiveAction('workflowSaving');
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: error.message,
type: 'error',
});
return false;
}
},
async saveAsNewWorkflow ({ name, tags, resetWebhookUrls, resetNodeIds, openInNewWindow, data }: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean, resetNodeIds?: boolean, data?: IWorkflowDataUpdate} = {}, redirect = true): Promise<boolean> {
try {
this.uiStore.addActiveAction('workflowSaving');
const workflowDataRequest: IWorkflowDataUpdate = data || await this.getWorkflowDataToSave();
// make sure that the new ones are not active
workflowDataRequest.active = false;
const changedNodes = {} as IDataObject;
if (resetNodeIds) {
workflowDataRequest.nodes = workflowDataRequest.nodes!.map(node => {
node.id = uuid();
return node;
});
}
if (resetWebhookUrls) {
workflowDataRequest.nodes = workflowDataRequest.nodes!.map(node => {
if (node.webhookId) {
node.webhookId = uuid();
changedNodes[node.name] = node.webhookId;
}
return node;
});
}
if (name) {
workflowDataRequest.name = name.trim();
}
if (tags) {
workflowDataRequest.tags = tags;
}
const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest);
this.workflowsStore.addWorkflow(workflowData);
this.workflowsStore.setWorkflowHash(workflowData.hash);
if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) && this.usersStore.currentUser) {
this.workflowsEEStore.setWorkflowOwnedBy({ workflowId: workflowData.id, ownedBy: this.usersStore.currentUser });
}
if (openInNewWindow) {
const routeData = this.$router.resolve({name: VIEWS.WORKFLOW, params: {name: workflowData.id}});
window.open(routeData.href, '_blank');
this.uiStore.removeActiveAction('workflowSaving');
return true;
}
this.workflowsStore.setActive(workflowData.active || false);
this.workflowsStore.setWorkflowId(workflowData.id);
this.workflowsStore.setWorkflowName({newName: workflowData.name, setStateDirty: false});
this.workflowsStore.setWorkflowSettings(workflowData.settings as IWorkflowSettings|| {});
this.uiStore.stateIsDirty = false;
Object.keys(changedNodes).forEach((nodeName) => {
const changes = {
key: 'webhookId',
value: changedNodes[nodeName],
name: nodeName,
};
this.workflowsStore.setNodeValue(changes);
});
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.workflowsStore.setWorkflowTagIds(tagIds);
const templateId = this.$route.query.templateId;
if (templateId) {
this.$telemetry.track('User saved new workflow from template', {
template_id: templateId,
workflow_id: workflowData.id,
wf_template_repo_session_id: this.templatesStore.previousSessionId,
});
}
if (redirect) {
this.$router.replace({
name: VIEWS.WORKFLOW,
params: { name: workflowData.id as string, action: 'workflowSave' },
});
}
this.uiStore.removeActiveAction('workflowSaving');
this.uiStore.stateIsDirty = false;
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} catch (e) {
this.uiStore.removeActiveAction('workflowSaving');
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: (e as Error).message,
type: 'error',
});
return false;
}
},
// Updates the position of all the nodes that the top-left node
// is at the given position
updateNodePositions (workflowData: IWorkflowData | IWorkflowDataUpdate, position: XYPosition): void {
if (workflowData.nodes === undefined) {
return;
}
// Find most top-left node
const minPosition = [99999999, 99999999];
for (const node of workflowData.nodes) {
if (node.position[1] < minPosition[1]) {
minPosition[0] = node.position[0];
minPosition[1] = node.position[1];
} else if (node.position[1] === minPosition[1]) {
if (node.position[0] < minPosition[0]) {
minPosition[0] = node.position[0];
minPosition[1] = node.position[1];
}
}
}
// Update the position on all nodes so that the
// most top-left one is at given position
const offsetPosition = [position[0] - minPosition[0], position[1] - minPosition[1]];
for (const node of workflowData.nodes) {
node.position[0] += offsetPosition[0];
node.position[1] += offsetPosition[1];
}
},
async dataHasChanged(id: string) {
const currentData = await this.getWorkflowDataToSave();
const data: IWorkflowDb = await this.restApi().getWorkflow(id);
if(data !== undefined) {
const x = {
nodes: data.nodes,
connections: data.connections,
settings: data.settings,
name: data.name,
};
const y = {
nodes: currentData.nodes,
connections: currentData.connections,
settings: currentData.settings,
name: currentData.name,
};
return !isEqual(x, y);
}
return true;
},
},
});

View File

@@ -0,0 +1,259 @@
import {
IExecutionPushResponse,
IExecutionResponse,
IStartRunData,
} from '@/Interface';
import {
IRunData,
IRunExecutionData,
IWorkflowBase,
NodeHelpers,
TelemetryHelpers,
} from 'n8n-workflow';
import { externalHooks } from '@/mixins/externalHooks';
import { restApi } from '@/mixins/restApi';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { showMessage } from '@/mixins/showMessage';
import mixins from 'vue-typed-mixins';
import { titleChange } from './titleChange';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useRootStore } from '@/stores/n8nRootStore';
export const workflowRun = mixins(
externalHooks,
restApi,
workflowHelpers,
showMessage,
titleChange,
).extend({
computed: {
...mapStores(
useRootStore,
useUIStore,
useWorkflowsStore,
),
},
methods: {
// Starts to executes a workflow on server.
async runWorkflowApi (runData: IStartRunData): Promise<IExecutionPushResponse> {
if (this.rootStore.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(
this.$locale.baseText('workflowRun.noActiveConnectionToTheServer'),
);
}
this.workflowsStore.subWorkflowExecutionError = null;
this.uiStore.addActiveAction('workflowRunning');
let response: IExecutionPushResponse;
try {
response = await this.restApi().runWorkflow(runData);
} catch (error) {
this.uiStore.removeActiveAction('workflowRunning');
throw error;
}
if (response.executionId !== undefined) {
this.workflowsStore.activeExecutionId = response.executionId;
}
if (response.waitingForWebhook === true) {
this.workflowsStore.executionWaitingForWebhook = true;
}
return response;
},
async runWorkflow (nodeName?: string, source?: string): Promise<IExecutionPushResponse | undefined> {
const workflow = this.getCurrentWorkflow();
if (this.uiStore.isActionActive('workflowRunning')) {
return;
}
this.$titleSet(workflow.name as string, 'EXECUTING');
this.clearAllStickyNotifications();
try {
// Check first if the workflow has any issues before execute it
const issuesExist = this.workflowsStore.nodesIssuesExist;
if (issuesExist === true) {
// If issues exist get all of the issues of all nodes
const workflowIssues = this.checkReadyForExecution(workflow, nodeName);
if (workflowIssues !== null) {
const errorMessages = [];
let nodeIssues: string[];
const trackNodeIssues: Array<{
node_type: string;
error: string;
}> = [];
const trackErrorNodeTypes: string[] = [];
for (const nodeName of Object.keys(workflowIssues)) {
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
let issueNodeType = 'UNKNOWN';
const issueNode = this.workflowsStore.getNodeByName(nodeName);
if (issueNode) {
issueNodeType = issueNode.type;
}
trackErrorNodeTypes.push(issueNodeType);
const trackNodeIssue = {
node_type: issueNodeType,
error: '',
caused_by_credential: !!workflowIssues[nodeName].credentials,
};
for (const nodeIssue of nodeIssues) {
errorMessages.push(`<strong>${nodeName}</strong>: ${nodeIssue}`);
trackNodeIssue.error = trackNodeIssue.error.concat(', ', nodeIssue);
}
trackNodeIssues.push(trackNodeIssue);
}
this.$showMessage({
title: this.$locale.baseText('workflowRun.showMessage.title'),
message: errorMessages.join('<br />'),
type: 'error',
duration: 0,
});
this.$titleSet(workflow.name as string, 'ERROR');
this.$externalHooks().run('workflowRun.runError', { errorMessages, nodeName });
this.getWorkflowDataToSave().then((workflowData) => {
this.$telemetry.track('Workflow execution preflight failed', {
workflow_id: workflow.id,
workflow_name: workflow.name,
execution_type: nodeName ? 'node' : 'workflow',
node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph),
error_node_types: JSON.stringify(trackErrorNodeTypes),
errors: JSON.stringify(trackNodeIssues),
});
});
return;
}
}
// Get the direct parents of the node
let directParentNodes: string[] = [];
if (nodeName !== undefined) {
directParentNodes = workflow.getParentNodes(nodeName, 'main', 1);
}
const runData = this.workflowsStore.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 enabled direct parent to be checked
if (workflow.nodes[directParentNode].disabled) continue;
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 && nodeName !== undefined) {
startNodes.push(nodeName);
}
const isNewWorkflow = this.workflowsStore.isNewWorkflow;
const hasWebhookNode = this.workflowsStore.currentWorkflowHasWebhookNode;
if (isNewWorkflow && hasWebhookNode) {
await this.saveCurrentWorkflow();
}
const workflowData = await this.getWorkflowDataToSave();
const startRunData: IStartRunData = {
workflowData,
runData: newRunData,
pinData: workflowData.pinData,
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(),
stoppedAt: undefined,
workflowId: workflow.id,
executedNode: nodeName,
data: {
resultData: {
runData: newRunData || {},
pinData: workflowData.pinData,
startNodes,
workflowData,
},
} as IRunExecutionData,
workflowData: {
id: this.workflowsStore.workflowId,
name: workflowData.name!,
active: workflowData.active!,
createdAt: 0,
updatedAt: 0,
...workflowData,
},
};
this.workflowsStore.setWorkflowExecutionData(executionData);
this.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
return runWorkflowApiResponse;
} catch (error) {
this.$titleSet(workflow.name as string, 'ERROR');
this.$showError(
error,
this.$locale.baseText('workflowRun.showError.title'),
);
return undefined;
}
},
},
});