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:
committed by
GitHub
parent
67983e8f94
commit
5059c57f4a
222
packages/editor-ui/src/mixins/copyPaste.ts
Normal file
222
packages/editor-ui/src/mixins/copyPaste.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
24
packages/editor-ui/src/mixins/debounce.ts
Normal file
24
packages/editor-ui/src/mixins/debounce.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/editor-ui/src/mixins/deviceSupportHelpers.ts
Normal file
31
packages/editor-ui/src/mixins/deviceSupportHelpers.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
42
packages/editor-ui/src/mixins/emitter.ts
Normal file
42
packages/editor-ui/src/mixins/emitter.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
75
packages/editor-ui/src/mixins/executionsHelpers.ts
Normal file
75
packages/editor-ui/src/mixins/executionsHelpers.ts
Normal 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');
|
||||
},
|
||||
},
|
||||
});
|
||||
41
packages/editor-ui/src/mixins/externalHooks.ts
Normal file
41
packages/editor-ui/src/mixins/externalHooks.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
73
packages/editor-ui/src/mixins/genericHelpers.ts
Normal file
73
packages/editor-ui/src/mixins/genericHelpers.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
55
packages/editor-ui/src/mixins/globalLinkActions.ts
Normal file
55
packages/editor-ui/src/mixins/globalLinkActions.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
209
packages/editor-ui/src/mixins/mouseSelect.ts
Normal file
209
packages/editor-ui/src/mixins/mouseSelect.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
90
packages/editor-ui/src/mixins/moveNodeWorkflow.ts
Normal file
90
packages/editor-ui/src/mixins/moveNodeWorkflow.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
50
packages/editor-ui/src/mixins/newVersions.ts
Normal file
50
packages/editor-ui/src/mixins/newVersions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
370
packages/editor-ui/src/mixins/nodeBase.ts
Normal file
370
packages/editor-ui/src/mixins/nodeBase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
531
packages/editor-ui/src/mixins/nodeHelpers.ts
Normal file
531
packages/editor-ui/src/mixins/nodeHelpers.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
88
packages/editor-ui/src/mixins/pinData.ts
Normal file
88
packages/editor-ui/src/mixins/pinData.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
457
packages/editor-ui/src/mixins/pushConnection.ts
Normal file
457
packages/editor-ui/src/mixins/pushConnection.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
188
packages/editor-ui/src/mixins/restApi.ts
Normal file
188
packages/editor-ui/src/mixins/restApi.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
225
packages/editor-ui/src/mixins/showMessage.ts
Normal file
225
packages/editor-ui/src/mixins/showMessage.ts
Normal 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');
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/editor-ui/src/mixins/titleChange.ts
Normal file
31
packages/editor-ui/src/mixins/titleChange.ts
Normal 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`;
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
30
packages/editor-ui/src/mixins/userHelpers.ts
Normal file
30
packages/editor-ui/src/mixins/userHelpers.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
115
packages/editor-ui/src/mixins/workflowActivate.ts
Normal file
115
packages/editor-ui/src/mixins/workflowActivate.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
919
packages/editor-ui/src/mixins/workflowHelpers.ts
Normal file
919
packages/editor-ui/src/mixins/workflowHelpers.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
259
packages/editor-ui/src/mixins/workflowRun.ts
Normal file
259
packages/editor-ui/src/mixins/workflowRun.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user