feat(editor): Add undo/redo support for canvas actions (#4787)

*  Added history store and mixin
*  Implemented node position change undo/redo
*  Implemented move nodes bulk command
*  Not clearing the redo stack after pushing the bulk command
* 🔨 Implemented commands using classes
* 🔥 Removed unnecessary interfaces and actions
* 🔥 Removing unused constants
* 🔨 Refactoring classes file
*  Adding eventBus to command obects
*  Added undo/redo support for adding and removing nodes
*  Implemented initial add/remove connections undo support
*  Covering some corner cases with reconnecting nodes
*  Adding undo support for reconnecting nodes
*  Fixing going back and forward between undo and redo
*  Implemented async command revert
*  Preventing push to undo if bulk redo/undo is in progress
*  Handling re-connecting nodes and stopped pushing empty bulk actions to undo stack
*  Handling adding a node between two connected nodes
*  Handling the case of removing multiple connections on the same index. Adding debounce to undo/redo keyboard calls
*  Removing unnecessary timeouts, adding missing awaits, refactoring
*  Resetting history when opening new workflow, fixing incorrect bulk recording when inserting node
* ✔️ Fixing lint error
*  Minor refactoring + some temporary debugging logs
*  Preserving node properties when undoing it's removal, removing some unused repaint code
*  Added undo/redo support for import workflow and node enable/disable
* 🔥 Removing some unused constant
*  Added undo/redo support for renaming nodes
*  Fixing rename history recording
*  Added undo/redo support for duplicating nodes
* 📈 Implemented telemetry events
* 🔨 A bit of refactoring
*  Fixing edgecases in removing connection and moving nodes
*  Handling case of adding duplicate nodes when going back and forward in history
*  Recording connections added directly to store
*  Moving main history reset after wf is opened
* 🔨 Simplifying rename recording
* 📈 Adding NDV telemetry event, updating existing event name case
* 📈 Updating telemetry events
*  Fixing duplicate connections on undo/redo
*  Stopping undo events from firing constantly on keydown
* 📈 Updated telemetry event for hitting undo in NDV
*  Adding undo support for disabling nodes using keyboard shortcuts
*  Preventing adding duplicate connection commands to history
*  Clearing redo stack when new change is added
*  Preventing adding connection actions to undo stack while redoing them
* 👌 Addressing PR comments part 1
* 👌 Moving undo logic for disabling nodes to `NodeView`
* 👌 Implemented command comparing logic
*  Fix for not clearing redo stack on every user action
*  Fixing recording when moving nodes
*  Fixing undo for moving connections
*  Fixing tracking new nodes after latest merge
*  Fixing broken bulk delete
*  Preventing undo/redo when not on main node view tab
* 👌 Addressing PR comments
* 👌 Addressing PR comment
This commit is contained in:
Milorad FIlipović 2022-12-09 15:07:37 +01:00 committed by GitHub
parent 38d7300d2a
commit b2aba48dfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 764 additions and 86 deletions

View file

@ -45,11 +45,13 @@ import { useUsersStore } from './stores/users';
import { useRootStore } from './stores/n8nRootStore';
import { useTemplatesStore } from './stores/templates';
import { useNodeTypesStore } from './stores/nodeTypes';
import { historyHelper } from '@/mixins/history';
export default mixins(
showMessage,
userHelpers,
restApi,
historyHelper,
).extend({
name: 'App',
components: {
@ -191,7 +193,6 @@ export default mixins(
this.loading = false;
this.trackPage();
// TODO: Un-comment once front-end hooks are updated to work with pinia store
this.$externalHooks().run('app.mount');
if (this.defaultLocale !== 'en') {

View file

@ -38,6 +38,7 @@ import {
INodeActionTypeDescription,
} from 'n8n-workflow';
import { FAKE_DOOR_FEATURES } from './constants';
import { BulkCommand, Undoable } from '@/models/history';
export * from 'n8n-design-system/src/types';
@ -164,7 +165,7 @@ export interface IUpdateInformation {
export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: {
[key: string]: IDataObject;
[key: string]: IDataObject | XYPosition;
};
}
@ -1317,6 +1318,12 @@ export interface CurlToJSONResponse {
"parameters.sendBody": boolean;
}
export interface HistoryState {
redoStack: Undoable[];
undoStack: Undoable[];
currentBulkAction: BulkCommand | null;
bulkInProgress: boolean;
}
export type Basic = string | number | boolean;
export type Primitives = Basic | bigint | symbol;

View file

@ -105,6 +105,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers';
import { pinData } from '@/mixins/pinData';
import {
IDataObject,
INodeTypeDescription,
ITaskData,
NodeHelpers,
@ -117,13 +118,14 @@ import mixins from 'vue-typed-mixins';
import { get } from 'lodash';
import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils';
import { IExecutionsSummary, INodeUi, XYPosition } from '@/Interface';
import { IExecutionsSummary, INodeUi, INodeUpdatePropertiesInformation, XYPosition } from '@/Interface';
import { debounceHelper } from '@/mixins/debounce';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { EnableNodeToggleCommand } from '@/models/history';
export default mixins(
externalHooks,
@ -433,8 +435,11 @@ export default mixins(
: nodeSubtitle;
},
disableNode () {
this.disableNodes([this.data]);
this.$telemetry.track('User clicked node hover button', { node_type: this.data.type, button_name: 'disable', workflow_id: this.workflowsStore.workflowId });
if (this.data !== null) {
this.disableNodes([this.data]);
this.historyStore.pushCommandToUndo(new EnableNodeToggleCommand(this.data.name, !this.data.disabled, this.data.disabled === true, this));
this.$telemetry.track('User clicked node hover button', { node_type: this.data.type, button_name: 'disable', workflow_id: this.workflowsStore.workflowId });
}
},
executeNode () {
this.$emit('runWorkflow', this.data.name, 'Node.executeNode');

View file

@ -165,6 +165,8 @@ import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useHistoryStore } from '@/stores/history';
import { RenameNodeCommand } from '@/models/history';
export default mixins(externalHooks, nodeHelpers).extend({
name: 'NodeSettings',
@ -179,6 +181,7 @@ export default mixins(externalHooks, nodeHelpers).extend({
},
computed: {
...mapStores(
useHistoryStore,
useNodeTypesStore,
useNDVStore,
useUIStore,
@ -498,6 +501,9 @@ export default mixins(externalHooks, nodeHelpers).extend({
this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
},
nameChanged(name: string) {
if (this.node) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(this.node.name, name, this));
}
// @ts-ignore
this.valueChanged({
value: name,

View file

@ -434,4 +434,5 @@ export enum STORES {
VERSIONS = 'versions',
NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks',
HISTORY = 'history',
}

View file

@ -0,0 +1,117 @@
import { MAIN_HEADER_TABS } from './../constants';
import { useNDVStore } from '@/stores/ndv';
import { BulkCommand, Undoable } from '@/models/history';
import { useHistoryStore } from '@/stores/history';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { mapStores } from 'pinia';
import mixins from 'vue-typed-mixins';
import { Command } from '@/models/history';
import { debounceHelper } from '@/mixins/debounce';
import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers';
import Vue from 'vue';
import { getNodeViewTab } from '@/utils';
const UNDO_REDO_DEBOUNCE_INTERVAL = 100;
export const historyHelper = mixins(debounceHelper, deviceSupportHelpers).extend({
computed: {
...mapStores(
useNDVStore,
useHistoryStore,
useUIStore,
useWorkflowsStore,
),
isNDVOpen(): boolean {
return this.ndvStore.activeNodeName !== null;
},
},
mounted() {
document.addEventListener('keydown', this.handleKeyDown);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyDown);
},
methods: {
handleKeyDown(event: KeyboardEvent) {
const currentNodeViewTab = getNodeViewTab(this.$route);
if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return;
if (this.isCtrlKeyPressed(event) && event.key === 'z') {
event.preventDefault();
if (!this.isNDVOpen) {
if (event.shiftKey) {
this.callDebounced('redo', { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, trailing: true });
} else {
this.callDebounced('undo', { debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL, trailing: true });
}
} else if (!event.shiftKey) {
this.trackUndoAttempt(event);
}
}
},
async undo() {
const command = this.historyStore.popUndoableToUndo();
if (!command) {
return;
}
if (command instanceof BulkCommand) {
this.historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands: Command[] = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
}
this.historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands));
await Vue.nextTick();
this.historyStore.bulkInProgress = false;
}
if (command instanceof Command) {
await command.revert();
this.historyStore.pushUndoableToRedo(command.getReverseCommand());
this.uiStore.stateIsDirty = true;
}
this.trackCommand(command, 'undo');
},
async redo() {
const command = this.historyStore.popUndoableToRedo();
if (!command) {
return;
}
if (command instanceof BulkCommand) {
this.historyStore.bulkInProgress = true;
const commands = command.commands;
const reverseCommands = [];
for (let i = commands.length - 1; i >= 0; i--) {
await commands[i].revert();
reverseCommands.push(commands[i].getReverseCommand());
}
this.historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false);
await Vue.nextTick();
this.historyStore.bulkInProgress = false;
}
if (command instanceof Command) {
await command.revert();
this.historyStore.pushCommandToUndo(command.getReverseCommand(), false);
this.uiStore.stateIsDirty = true;
}
this.trackCommand(command, 'redo');
},
trackCommand(command: Undoable, type: 'undo'|'redo'): void {
if (command instanceof Command) {
this.$telemetry.track(`User hit ${type}`, { commands_length: 1, commands: [ command.name ] });
} else if (command instanceof BulkCommand) {
this.$telemetry.track(`User hit ${type}`, { commands_length: command.commands.length, commands: command.commands.map(c => c.name) });
}
},
trackUndoAttempt(event: KeyboardEvent) {
if (this.isNDVOpen && !event.shiftKey) {
const activeNode = this.ndvStore.activeNode;
if (activeNode) {
this.$telemetry.track(`User hit undo in NDV`, { node_type: activeNode.type });
}
}
},
},
});

View file

@ -13,6 +13,8 @@ import { useWorkflowsStore } from "@/stores/workflows";
import { useNodeTypesStore } from "@/stores/nodeTypes";
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { getStyleTokenValue } from "@/utils";
import { useHistoryStore } from "@/stores/history";
import { MoveNodeCommand } from "@/models/history";
export const nodeBase = mixins(
deviceSupportHelpers,
@ -33,6 +35,7 @@ export const nodeBase = mixins(
useNodeTypesStore,
useUIStore,
useWorkflowsStore,
useHistoryStore,
),
data (): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
@ -281,6 +284,9 @@ export const nodeBase = mixins(
moveNodes.push(this.data);
}
if(moveNodes.length > 1) {
this.historyStore.startRecordingUndo();
}
// 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
@ -304,11 +310,16 @@ export const nodeBase = mixins(
position: newNodePosition,
},
};
this.workflowsStore.updateNodeProperties(updateInformation);
const oldPosition = node.position;
if (oldPosition[0] !== newNodePosition[0] || oldPosition[1] !== newNodePosition[1]) {
this.historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newNodePosition, this));
this.workflowsStore.updateNodeProperties(updateInformation);
this.$emit('moved', node);
}
});
this.$emit('moved', node);
if(moveNodes.length > 1) {
this.historyStore.stopRecordingUndo();
}
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',

View file

@ -1,3 +1,5 @@
import { EnableNodeToggleCommand } from './../models/history';
import { useHistoryStore } from '@/stores/history';
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
@ -51,6 +53,7 @@ export const nodeHelpers = mixins(
computed: {
...mapStores(
useCredentialsStore,
useHistoryStore,
useNodeTypesStore,
useSettingsStore,
useWorkflowsStore,
@ -431,13 +434,17 @@ export const nodeHelpers = mixins(
return returnData;
},
disableNodes(nodes: INodeUi[]) {
disableNodes(nodes: INodeUi[], trackHistory = false) {
if (trackHistory) {
this.historyStore.startRecordingUndo();
}
for (const node of nodes) {
const oldState = node.disabled;
// Toggle disabled flag
const updateInformation = {
name: node.name,
properties: {
disabled: !node.disabled,
disabled: !oldState,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
@ -447,6 +454,12 @@ export const nodeHelpers = mixins(
this.workflowsStore.clearNodeExecutionData(node.name);
this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new EnableNodeToggleCommand(node.name, oldState === true, node.disabled === true, this));
}
}
if (trackHistory) {
this.historyStore.stopRecordingUndo();
}
},
// @ts-ignore

View file

@ -0,0 +1,265 @@
import { INodeUi } from '@/Interface';
import { IConnection } from 'n8n-workflow';
import Vue from "vue";
import { XYPosition } from "../Interface";
// Command names don't serve any particular purpose in the app
// but they make it easier to identify each command on stack
// when debugging
export enum COMMANDS {
MOVE_NODE = 'moveNode',
ADD_NODE = 'addNode',
REMOVE_NODE = 'removeNode',
ADD_CONNECTION = 'addConnection',
REMOVE_CONNECTION = 'removeConnection',
ENABLE_NODE_TOGGLE = 'enableNodeToggle',
RENAME_NODE = 'renameNode',
}
// Triggering multiple canvas actions in sequence leaves
// canvas out of sync with store state, so we are adding
// this timeout in between canvas actions
// (0 is usually enough but leaving this just in case)
const CANVAS_ACTION_TIMEOUT = 10;
export abstract class Undoable { }
export abstract class Command extends Undoable {
readonly name: string;
eventBus: Vue;
constructor (name: string, eventBus: Vue) {
super();
this.name = name;
this.eventBus = eventBus;
}
abstract getReverseCommand(): Command;
abstract isEqualTo(anotherCommand: Command): boolean;
abstract revert(): Promise<void>;
}
export class BulkCommand extends Undoable {
commands: Command[];
constructor (commands: Command[]) {
super();
this.commands = commands;
}
}
export class MoveNodeCommand extends Command {
nodeName: string;
oldPosition: XYPosition;
newPosition: XYPosition;
constructor (nodeName: string, oldPosition: XYPosition, newPosition: XYPosition, eventBus: Vue) {
super(COMMANDS.MOVE_NODE, eventBus);
this.nodeName = nodeName;
this.newPosition = newPosition;
this.oldPosition = oldPosition;
}
getReverseCommand(): Command {
return new MoveNodeCommand(
this.nodeName,
this.newPosition,
this.oldPosition,
this.eventBus,
);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof MoveNodeCommand &&
anotherCommand.nodeName === this.nodeName &&
anotherCommand.oldPosition[0] === this.oldPosition[0] &&
anotherCommand.oldPosition[1] === this.oldPosition[1] &&
anotherCommand.newPosition[0] === this.newPosition[0] &&
anotherCommand.newPosition[1] === this.newPosition[1]
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('nodeMove', { nodeName: this.nodeName, position: this.oldPosition });
resolve();
});
}
}
export class AddNodeCommand extends Command {
node: INodeUi;
constructor (node: INodeUi, eventBus: Vue) {
super(COMMANDS.ADD_NODE, eventBus);
this.node = node;
}
getReverseCommand(): Command {
return new RemoveNodeCommand(this.node, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof AddNodeCommand &&
anotherCommand.node.name === this.node.name
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('revertAddNode', { node: this.node });
resolve();
});
}
}
export class RemoveNodeCommand extends Command {
node: INodeUi;
constructor (node: INodeUi, eventBus: Vue) {
super(COMMANDS.REMOVE_NODE, eventBus);
this.node = node;
}
getReverseCommand(): Command {
return new AddNodeCommand(this.node, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof AddNodeCommand &&
anotherCommand.node.name === this.node.name
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('revertRemoveNode', { node: this.node });
resolve();
});
}
}
export class AddConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection], eventBus: Vue) {
super(COMMANDS.ADD_CONNECTION, eventBus);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new RemoveConnectionCommand(this.connectionData, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof AddConnectionCommand &&
anotherCommand.connectionData[0].node === this.connectionData[0].node &&
anotherCommand.connectionData[1].node === this.connectionData[1].node &&
anotherCommand.connectionData[0].index === this.connectionData[0].index &&
anotherCommand.connectionData[1].index === this.connectionData[1].index
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('revertAddConnection', { connection: this.connectionData });
resolve();
});
}
}
export class RemoveConnectionCommand extends Command {
connectionData: [IConnection, IConnection];
constructor(connectionData: [IConnection, IConnection], eventBus: Vue) {
super(COMMANDS.REMOVE_CONNECTION, eventBus);
this.connectionData = connectionData;
}
getReverseCommand(): Command {
return new AddConnectionCommand(this.connectionData, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof RemoveConnectionCommand &&
anotherCommand.connectionData[0].node === this.connectionData[0].node &&
anotherCommand.connectionData[1].node === this.connectionData[1].node &&
anotherCommand.connectionData[0].index === this.connectionData[0].index &&
anotherCommand.connectionData[1].index === this.connectionData[1].index
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
setTimeout(() => {
this.eventBus.$root.$emit('revertRemoveConnection', { connection: this.connectionData });
resolve();
}, CANVAS_ACTION_TIMEOUT);
});
}
}
export class EnableNodeToggleCommand extends Command {
nodeName: string;
oldState: boolean;
newState: boolean;
constructor(nodeName: string, oldState: boolean, newState: boolean, eventBus: Vue) {
super(COMMANDS.ENABLE_NODE_TOGGLE, eventBus);
this.nodeName = nodeName;
this.newState = newState;
this.oldState = oldState;
}
getReverseCommand(): Command {
return new EnableNodeToggleCommand(this.nodeName, this.newState, this.oldState, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof EnableNodeToggleCommand &&
anotherCommand.nodeName === this.nodeName
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('enableNodeToggle', { nodeName: this.nodeName, isDisabled: this.oldState });
resolve();
});
}
}
export class RenameNodeCommand extends Command {
currentName: string;
newName: string;
constructor(currentName: string, newName: string, eventBus: Vue) {
super(COMMANDS.RENAME_NODE, eventBus);
this.currentName = currentName;
this.newName = newName;
}
getReverseCommand(): Command {
return new RenameNodeCommand(this.newName, this.currentName, this.eventBus);
}
isEqualTo(anotherCommand: Command): boolean {
return (
anotherCommand instanceof RenameNodeCommand &&
anotherCommand.currentName === this.currentName &&
anotherCommand.newName === this.newName
);
}
async revert(): Promise<void> {
return new Promise<void>(resolve => {
this.eventBus.$root.$emit('revertRenameNode', { currentName: this.currentName, newName: this.newName });
resolve();
});
}
}

View file

@ -0,0 +1,88 @@
import { AddConnectionCommand, COMMANDS, RemoveConnectionCommand } from './../models/history';
import { BulkCommand, Command, Undoable, MoveNodeCommand } from "@/models/history";
import { STORES } from "@/constants";
import { HistoryState } from "@/Interface";
import { defineStore } from "pinia";
const STACK_LIMIT = 100;
export const useHistoryStore = defineStore(STORES.HISTORY, {
state: (): HistoryState => ({
undoStack: [],
redoStack: [],
currentBulkAction: null,
bulkInProgress: false,
}),
actions: {
popUndoableToUndo(): Undoable | undefined {
if (this.undoStack.length > 0) {
return this.undoStack.pop();
}
return undefined;
},
pushCommandToUndo(undoable: Command, clearRedo = true): void {
if (!this.bulkInProgress) {
if (this.currentBulkAction) {
const alreadyIn = this.currentBulkAction.commands.find(c => c.isEqualTo(undoable)) !== undefined;
if (!alreadyIn) {
this.currentBulkAction.commands.push(undoable);
}
} else {
this.undoStack.push(undoable);
}
this.checkUndoStackLimit();
if (clearRedo) {
this.clearRedoStack();
}
}
},
pushBulkCommandToUndo(undoable: BulkCommand, clearRedo = true): void {
this.undoStack.push(undoable);
this.checkUndoStackLimit();
if (clearRedo) {
this.clearRedoStack();
}
},
checkUndoStackLimit() {
if (this.undoStack.length > STACK_LIMIT) {
this.undoStack.shift();
}
},
checkRedoStackLimit() {
if (this.redoStack.length > STACK_LIMIT) {
this.redoStack.shift();
}
},
clearUndoStack() {
this.undoStack = [];
},
clearRedoStack() {
this.redoStack = [];
},
reset() {
this.clearRedoStack();
this.clearUndoStack();
},
popUndoableToRedo(): Undoable | undefined {
if (this.redoStack.length > 0) {
return this.redoStack.pop();
}
return undefined;
},
pushUndoableToRedo(undoable: Undoable): void {
this.redoStack.push(undoable);
this.checkRedoStackLimit();
},
startRecordingUndo() {
this.currentBulkAction = new BulkCommand([]);
},
stopRecordingUndo() {
if (this.currentBulkAction && this.currentBulkAction.commands.length > 0) {
this.undoStack.push(this.currentBulkAction);
this.checkUndoStackLimit();
}
this.currentBulkAction = null;
},
},
});

View file

@ -56,7 +56,6 @@ export const useRootStore = defineStore(STORES.ROOT, {
sessionId: this.sessionId,
};
},
// TODO: Waiting for nodeTypes store
/**
* Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc.
*/

View file

@ -1,5 +1,8 @@
import { MAIN_HEADER_TABS, VIEWS } from "@/constants";
import { IZoomConfig } from "@/Interface";
import { useWorkflowsStore } from "@/stores/workflows";
import { OnConnectionBindInfo } from "jsplumb";
import { IConnection } from "n8n-workflow";
import { Route } from "vue-router";
/*
@ -89,3 +92,26 @@ export const getNodeViewTab = (route: Route): string|null => {
}
return null;
};
export const getConnectionInfo = (connection: OnConnectionBindInfo): [IConnection, IConnection] | null => {
const sourceInfo = connection.sourceEndpoint.getParameters();
const targetInfo = connection.targetEndpoint.getParameters();
const sourceNode = useWorkflowsStore().getNodeById(sourceInfo.nodeId);
const targetNode = useWorkflowsStore().getNodeById(targetInfo.nodeId);
if (sourceNode && targetNode) {
return[
{
node: sourceNode.name,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: targetNode.name,
type: targetInfo.type,
index: targetInfo.index,
},
];
}
return null;
};

View file

@ -41,7 +41,7 @@
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
@removeNode="(name) => removeNode(name, true)"
@runWorkflow="onRunNode"
@moved="onNodeMoved"
@run="onNodeRun"
@ -64,7 +64,7 @@
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
@removeNode="(name) => removeNode(name, true)"
:key="`${nodeData.id}_sticky`"
:name="nodeData.name"
:isReadOnly="isReadOnly"
@ -233,7 +233,9 @@ import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
import { useCanvasStore } from '@/stores/canvas';
import useWorkflowsEEStore from "@/stores/workflows.ee";
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { getAccountAge, getNodeViewTab } from '@/utils';
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
import { useHistoryStore } from '@/stores/history';
import { AddConnectionCommand, AddNodeCommand, MoveNodeCommand, RemoveConnectionCommand, RemoveNodeCommand, RenameNodeCommand } from '@/models/history';
interface AddNodeOptions {
position?: XYPosition;
@ -407,6 +409,7 @@ export default mixins(
useUsersStore,
useNodeCreatorStore,
useWorkflowsEEStore,
useHistoryStore,
),
nativelyNumberSuffixedDefaults(): string[] {
return this.rootStore.nativelyNumberSuffixedDefaults;
@ -545,6 +548,10 @@ export default mixins(
showTriggerMissingTooltip: false,
workflowData: null as INewWorkflowData | null,
isProductionExecutionPreview: false,
// jsplumb automatically deletes all loose connections which is in turn recorded
// in undo history as a user action.
// This should prevent automatically removed connections from populating undo stack
suspendRecordingDetachedConnections: false,
};
},
beforeDestroy() {
@ -1117,7 +1124,7 @@ export default mixins(
if (!this.editAllowedCheck()) {
return;
}
this.disableNodes(this.uiStore.getSelectedNodes);
this.disableNodes(this.uiStore.getSelectedNodes, true);
},
deleteSelectedNodes() {
@ -1127,9 +1134,13 @@ export default mixins(
const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => {
return node.name;
});
this.historyStore.startRecordingUndo();
nodesToDelete.forEach((nodeName: string) => {
this.removeNode(nodeName);
this.removeNode(nodeName, true, false);
});
setTimeout(() => {
this.historyStore.stopRecordingUndo();
}, 200);
},
selectAllNodes() {
@ -1173,12 +1184,13 @@ export default mixins(
this.nodeSelectedByName(lastSelectedNode.name);
},
pushDownstreamNodes(sourceNodeName: string, margin: number) {
pushDownstreamNodes(sourceNodeName: string, margin: number, recordHistory = false) {
const sourceNode = this.workflowsStore.nodesByName[sourceNodeName];
const workflow = this.getCurrentWorkflow();
const childNodes = workflow.getChildNodes(sourceNodeName);
for (const nodeName of childNodes) {
const node = this.workflowsStore.nodesByName[nodeName] as INodeUi;
const oldPosition = node.position;
if (node.position[0] < sourceNode.position[0]) {
continue;
@ -1193,6 +1205,10 @@ export default mixins(
this.workflowsStore.updateNodeProperties(updateInformation);
this.onNodeMoved(node);
if (recordHistory && oldPosition[0] !== node.position[0] || oldPosition[1] !== node.position[1]) {
this.historyStore.pushCommandToUndo(new MoveNodeCommand(nodeName, oldPosition, node.position, this), recordHistory);
}
}
},
@ -1621,7 +1637,7 @@ export default mixins(
return newNodeData;
},
async injectNode (nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true) {
async injectNode (nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true, trackHistory = false) {
const nodeTypeData: INodeTypeDescription | null = this.nodeTypesStore.getNodeType(nodeTypeName);
if (nodeTypeData === null) {
@ -1653,7 +1669,7 @@ export default mixins(
if (lastSelectedConnection) { // set when injecting into a connection
const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection);
if (diffX <= NodeViewUtils.MAX_X_TO_PUSH_DOWNSTREAM_NODES) {
this.pushDownstreamNodes(lastSelectedNode.name, NodeViewUtils.PUSH_NODES_OFFSET);
this.pushDownstreamNodes(lastSelectedNode.name, NodeViewUtils.PUSH_NODES_OFFSET, trackHistory);
}
}
@ -1706,7 +1722,7 @@ export default mixins(
newNodeData.webhookId = uuid();
}
await this.addNodes([newNodeData]);
await this.addNodes([newNodeData], undefined, trackHistory);
this.uiStore.stateIsDirty = true;
@ -1728,13 +1744,15 @@ export default mixins(
}
// Automatically deselect all nodes and select the current one and also active
// current node
this.deselectAllNodes();
const preventDetailOpen = window.posthog?.getFeatureFlag && window.posthog?.getFeatureFlag('prevent-ndv-auto-open') === 'prevent';
if(showDetail && !preventDetailOpen) {
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
});
// current node. But only if it's added manually by the user (not by undo/redo mechanism)
if(trackHistory) {
this.deselectAllNodes();
const preventDetailOpen = window.posthog?.getFeatureFlag && window.posthog?.getFeatureFlag('prevent-ndv-auto-open') === 'prevent';
if(showDetail && !preventDetailOpen) {
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
});
}
}
return newNodeData;
@ -1751,7 +1769,7 @@ export default mixins(
return undefined;
},
connectTwoNodes(sourceNodeName: string, sourceNodeOutputIndex: number, targetNodeName: string, targetNodeOuputIndex: number) {
connectTwoNodes(sourceNodeName: string, sourceNodeOutputIndex: number, targetNodeName: string, targetNodeOuputIndex: number, trackHistory = false) {
if (this.getConnection(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex)) {
return;
}
@ -1771,7 +1789,7 @@ export default mixins(
this.__addConnection(connectionData, true);
},
async addNode(nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true) {
async addNode(nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true, trackHistory = false) {
if (!this.editAllowedCheck()) {
return;
}
@ -1780,7 +1798,9 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode;
const lastSelectedNodeOutputIndex = this.uiStore.lastSelectedNodeOutputIndex;
const newNodeData = await this.injectNode(nodeTypeName, options, showDetail);
this.historyStore.startRecordingUndo();
const newNodeData = await this.injectNode(nodeTypeName, options, showDetail, trackHistory);
if (!newNodeData) {
return;
}
@ -1792,16 +1812,17 @@ export default mixins(
await Vue.nextTick();
if (lastSelectedConnection && lastSelectedConnection.__meta) {
this.__deleteJSPlumbConnection(lastSelectedConnection);
this.__deleteJSPlumbConnection(lastSelectedConnection, trackHistory);
const targetNodeName = lastSelectedConnection.__meta.targetNodeName;
const targetOutputIndex = lastSelectedConnection.__meta.targetOutputIndex;
this.connectTwoNodes(newNodeData.name, 0, targetNodeName, targetOutputIndex);
this.connectTwoNodes(newNodeData.name, 0, targetNodeName, targetOutputIndex, trackHistory);
}
// Connect active node to the newly created one
this.connectTwoNodes(lastSelectedNode.name, outputIndex, newNodeData.name, 0);
this.connectTwoNodes(lastSelectedNode.name, outputIndex, newNodeData.name, 0, trackHistory);
}
this.historyStore.stopRecordingUndo();
},
initNodeView() {
this.instance.importDefaults({
@ -1847,7 +1868,7 @@ export default mixins(
const sourceNodeName = sourceNode.name;
const outputIndex = connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0, true);
this.pullConnActiveNodeName = null;
}
return;
@ -1988,21 +2009,26 @@ export default mixins(
NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint);
const connectionData: [IConnection, IConnection] = [
{
node: sourceNodeName,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: targetNodeName,
type: targetInfo.type,
index: targetInfo.index,
},
];
this.workflowsStore.addConnection({
connection: [
{
node: sourceNodeName,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: targetNodeName,
type: targetInfo.type,
index: targetInfo.index,
},
],
connection: connectionData,
setStateDirty: true,
});
if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData, this));
}
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
@ -2040,19 +2066,31 @@ export default mixins(
}
});
this.instance.bind('connectionDetached', (info) => {
this.instance.bind('connectionDetached', async (info) => {
try {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
NodeViewUtils.resetInputLabelPosition(info.targetEndpoint);
info.connection.removeOverlays();
this.__removeConnectionByConnectionInfo(info, false);
this.__removeConnectionByConnectionInfo(info, false, false);
if (this.pullConnActiveNodeName) { // establish new connection when dragging connection from one node to another
this.historyStore.startRecordingUndo();
const sourceNode = this.workflowsStore.getNodeById(info.connection.sourceId);
const sourceNodeName = sourceNode.name;
const outputIndex = info.connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
if (connectionInfo) {
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, this));
}
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0, true);
this.pullConnActiveNodeName = null;
await this.$nextTick();
this.historyStore.stopRecordingUndo();
} else if (!this.historyStore.bulkInProgress && !this.suspendRecordingDetachedConnections && connectionInfo) {
// Ff connection being detached by user, save this in history
// but skip if it's detached as a side effect of bulk undo/redo or node rename process
const removeCommand = new RemoveConnectionCommand(connectionInfo, this);
this.historyStore.pushCommandToUndo(removeCommand);
}
} catch (e) {
console.error(e); // eslint-disable-line no-console
@ -2180,6 +2218,7 @@ export default mixins(
}
}
this.uiStore.nodeViewInitialized = true;
this.historyStore.reset();
this.workflowsStore.activeWorkflowExecution = null;
this.stopLoading();
}),
@ -2246,6 +2285,7 @@ export default mixins(
await this.newWorkflow();
}
}
this.historyStore.reset();
this.uiStore.nodeViewInitialized = true;
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
@ -2313,23 +2353,38 @@ export default mixins(
},
__removeConnection(connection: [IConnection, IConnection], removeVisualConnection = false) {
if (removeVisualConnection) {
const sourceId = this.workflowsStore.getNodeByName(connection[0].node);
const targetId = this.workflowsStore.getNodeByName(connection[1].node);
const sourceNode = this.workflowsStore.getNodeByName(connection[0].node);
const targetNode = this.workflowsStore.getNodeByName(connection[1].node);
if (!sourceNode || !targetNode) {
return;
}
// @ts-ignore
const connections = this.instance.getConnections({
source: sourceId,
target: targetId,
source: sourceNode.id,
target: targetNode.id,
});
// @ts-ignore
connections.forEach((connectionInstance) => {
this.__deleteJSPlumbConnection(connectionInstance);
if (connectionInstance.__meta) {
// Only delete connections from specific indexes (if it can be determined by meta)
if (
connectionInstance.__meta.sourceOutputIndex === connection[0].index &&
connectionInstance.__meta.targetOutputIndex === connection[1].index
) {
this.__deleteJSPlumbConnection(connectionInstance);
}
} else {
this.__deleteJSPlumbConnection(connectionInstance);
}
});
}
this.workflowsStore.removeConnection({ connection });
},
__deleteJSPlumbConnection(connection: Connection) {
__deleteJSPlumbConnection(connection: Connection, trackHistory = false) {
// Make sure to remove the overlay else after the second move
// it visibly stays behind free floating without a connection.
connection.removeOverlays();
@ -2341,31 +2396,24 @@ export default mixins(
const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId);
endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint
}
if (trackHistory && connection.__meta) {
const connectionData: [IConnection, IConnection] = [
{ index: connection.__meta?.sourceOutputIndex, node: connection.__meta.sourceNodeName, type: 'main' },
{ index: connection.__meta?.targetOutputIndex, node: connection.__meta.targetNodeName, type: 'main' },
];
const removeCommand = new RemoveConnectionCommand(connectionData, this);
this.historyStore.pushCommandToUndo(removeCommand);
}
},
__removeConnectionByConnectionInfo(info: OnConnectionBindInfo, removeVisualConnection = false) {
const sourceInfo = info.sourceEndpoint.getParameters();
const sourceNode = this.workflowsStore.getNodeById(sourceInfo.nodeId);
const targetInfo = info.targetEndpoint.getParameters();
const targetNode = this.workflowsStore.getNodeById(targetInfo.nodeId);
if (sourceNode && targetNode) {
const connectionInfo = [
{
node: sourceNode.name,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: targetNode.name,
type: targetInfo.type,
index: targetInfo.index,
},
] as [IConnection, IConnection];
__removeConnectionByConnectionInfo(info: OnConnectionBindInfo, removeVisualConnection = false, trackHistory = false) {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
if (connectionInfo) {
if (removeVisualConnection) {
this.__deleteJSPlumbConnection(info.connection);
this.__deleteJSPlumbConnection(info.connection, trackHistory);
} else if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, this));
}
this.workflowsStore.removeConnection({ connection: connectionInfo });
}
},
@ -2414,7 +2462,7 @@ export default mixins(
);
}
await this.addNodes([newNodeData]);
await this.addNodes([newNodeData], [], true);
const pinData = this.workflowsStore.pinDataByNodeName(nodeName);
if (pinData) {
@ -2562,7 +2610,7 @@ export default mixins(
});
});
},
removeNode(nodeName: string) {
removeNode(nodeName: string, trackHistory = false, trackBulk = true) {
if (!this.editAllowedCheck()) {
return;
}
@ -2572,6 +2620,10 @@ export default mixins(
return;
}
if (trackHistory && trackBulk) {
this.historyStore.startRecordingUndo();
}
// "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes: string[] = [];
@ -2625,7 +2677,7 @@ export default mixins(
const targetNodeOuputIndex = conn2.__meta.targetOutputIndex;
setTimeout(() => {
this.connectTwoNodes(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex);
this.connectTwoNodes(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex, trackHistory);
if (waitForNewConnection) {
this.instance.setSuspendDrawing(false, true);
@ -2659,7 +2711,16 @@ export default mixins(
// Remove node from selected index if found in it
this.uiStore.removeNodeFromSelection(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node, this));
}
}, 0); // allow other events to finish like drag stop
if (trackHistory && trackBulk) {
const recordingTimeout = waitForNewConnection ? 100 : 0;
setTimeout(() => {
this.historyStore.stopRecordingUndo();
}, recordingTimeout);
}
},
valueChanged(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
@ -2694,14 +2755,19 @@ export default mixins(
const promptResponse = await promptResponsePromise as MessageBoxInputData;
this.renameNode(currentName, promptResponse.value);
this.renameNode(currentName, promptResponse.value, true);
} catch (e) { }
},
async renameNode(currentName: string, newName: string) {
async renameNode(currentName: string, newName: string, trackHistory=false) {
if (currentName === newName) {
return;
}
this.suspendRecordingDetachedConnections = true;
if (trackHistory) {
this.historyStore.startRecordingUndo();
}
const activeNodeName = this.activeNode && this.activeNode.name;
const isActive = activeNodeName === currentName;
if (isActive) {
@ -2717,6 +2783,10 @@ export default mixins(
const workflow = this.getCurrentWorkflow(true);
workflow.renameNode(currentName, newName);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, this));
}
// Update also last selected node and execution data
this.workflowsStore.renameNodeSelectedAndExecution({ old: currentName, new: newName });
@ -2730,7 +2800,7 @@ export default mixins(
await Vue.nextTick();
// Add the new updated nodes
await this.addNodes(Object.values(workflow.nodes), workflow.connectionsBySourceNode);
await this.addNodes(Object.values(workflow.nodes), workflow.connectionsBySourceNode, false);
// Make sure that the node is selected again
this.deselectAllNodes();
@ -2740,6 +2810,11 @@ export default mixins(
this.ndvStore.activeNodeName = newName;
this.renamingActive = false;
}
if (trackHistory) {
this.historyStore.stopRecordingUndo();
}
this.suspendRecordingDetachedConnections = false;
},
deleteEveryEndpoint() {
// Check as it does not exist on first load
@ -2802,7 +2877,7 @@ export default mixins(
}
});
},
async addNodes(nodes: INodeUi[], connections?: IConnections) {
async addNodes(nodes: INodeUi[], connections?: IConnections, trackHistory = false) {
if (!nodes || !nodes.length) {
return;
}
@ -2859,6 +2934,9 @@ export default mixins(
}
this.workflowsStore.addNode(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new AddNodeCommand(node, this));
}
});
// Wait for the node to be rendered
@ -3012,7 +3090,9 @@ export default mixins(
}
// Add the nodes with the changed node names, expressions and connections
await this.addNodes(Object.values(tempWorkflow.nodes), tempWorkflow.connectionsBySourceNode);
this.historyStore.startRecordingUndo();
await this.addNodes(Object.values(tempWorkflow.nodes), tempWorkflow.connectionsBySourceNode, true);
this.historyStore.stopRecordingUndo();
this.uiStore.stateIsDirty = true;
@ -3259,7 +3339,7 @@ export default mixins(
},
onAddNode(nodeTypes: Array<{ nodeTypeName: string; position: XYPosition }>, dragAndDrop: boolean) {
nodeTypes.forEach(({ nodeTypeName, position }, index) => {
this.addNode(nodeTypeName, { position, dragAndDrop }, nodeTypes.length === 1 || index > 0);
this.addNode(nodeTypeName, { position, dragAndDrop }, nodeTypes.length === 1 || index > 0, true);
if(index === 0) return;
// If there's more than one node, we want to connect them
// this has to be done in mutation subscriber to make sure both nodes already
@ -3287,6 +3367,51 @@ export default mixins(
await this.saveCurrentWorkflow();
callback?.();
},
setSuspendRecordingDetachedConnections(suspend: boolean) {
this.suspendRecordingDetachedConnections = suspend;
},
onMoveNode({nodeName, position}: { nodeName: string, position: XYPosition }): void {
this.workflowsStore.updateNodeProperties({ name: nodeName, properties: { position }});
setTimeout(() => {
const node = this.workflowsStore.getNodeByName(nodeName);
if (node) {
this.instance.repaintEverything();
this.onNodeMoved(node);
}
}, 0);
},
onRevertAddNode({node}: {node: INodeUi}): void {
this.removeNode(node.name, false);
},
async onRevertRemoveNode({node}: {node: INodeUi}): Promise<void> {
const prevNode = this.workflowsStore.workflow.nodes.find(n => n.id === node.id);
if (prevNode) {
return;
}
// For some reason, returning node to canvas with old id
// makes it's endpoint to render at wrong position
node.id = uuid();
await this.addNodes([node]);
},
onRevertAddConnection({ connection }: { connection: [IConnection, IConnection]}) {
this.suspendRecordingDetachedConnections = true;
this.__removeConnection(connection, true);
this.suspendRecordingDetachedConnections = false;
},
async onRevertRemoveConnection({ connection }: { connection: [IConnection, IConnection]}) {
this.suspendRecordingDetachedConnections = true;
this.__addConnection(connection, true);
this.suspendRecordingDetachedConnections = false;
},
async onRevertNameChange({ currentName, newName }: { currentName: string, newName: string }) {
await this.renameNode(newName, currentName);
},
onRevertEnableToggle({ nodeName, isDisabled }: { nodeName: string, isDisabled: boolean }) {
const node = this.workflowsStore.getNodeByName(nodeName);
if (node) {
this.disableNodes([node]);
}
},
},
async mounted() {
this.$titleReset();
@ -3389,6 +3514,13 @@ export default mixins(
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.$root.$on('nodeMove', this.onMoveNode);
this.$root.$on('revertAddNode', this.onRevertAddNode);
this.$root.$on('revertRemoveNode', this.onRevertRemoveNode);
this.$root.$on('revertAddConnection', this.onRevertAddConnection);
this.$root.$on('revertRemoveConnection', this.onRevertRemoveConnection);
this.$root.$on('revertRenameNode', this.onRevertNameChange);
this.$root.$on('enableNodeToggle', this.onRevertEnableToggle);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
@ -3404,6 +3536,13 @@ export default mixins(
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.$root.$off('nodeMove', this.onMoveNode);
this.$root.$off('revertAddNode', this.onRevertAddNode);
this.$root.$off('revertRemoveNode', this.onRevertRemoveNode);
this.$root.$off('revertAddConnection', this.onRevertAddConnection);
this.$root.$off('revertRemoveConnection', this.onRevertRemoveConnection);
this.$root.$off('revertRenameNode', this.onRevertNameChange);
this.$root.$off('enableNodeToggle', this.onRevertEnableToggle);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);