diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 2cf7b6ba1a50f..62027afc37e77 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -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') { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index a32172b690aeb..f30ec43f21a49 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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; diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index a894d0f65b7f8..d2683b6c7021e 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -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'); diff --git a/packages/editor-ui/src/components/NodeSettings.vue b/packages/editor-ui/src/components/NodeSettings.vue index b3ea3ed24592b..87c39cf8a7a3e 100644 --- a/packages/editor-ui/src/components/NodeSettings.vue +++ b/packages/editor-ui/src/components/NodeSettings.vue @@ -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, diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 84b69bc57d4de..021f69ef021e4 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -434,4 +434,5 @@ export enum STORES { VERSIONS = 'versions', NODE_CREATOR = 'nodeCreator', WEBHOOKS = 'webhooks', + HISTORY = 'history', } diff --git a/packages/editor-ui/src/mixins/history.ts b/packages/editor-ui/src/mixins/history.ts new file mode 100644 index 0000000000000..0e5f975b4f4dc --- /dev/null +++ b/packages/editor-ui/src/mixins/history.ts @@ -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 }); + } + } + }, + }, +}); diff --git a/packages/editor-ui/src/mixins/nodeBase.ts b/packages/editor-ui/src/mixins/nodeBase.ts index ce0341be8f14a..36b02a4d00c07 100644 --- a/packages/editor-ui/src/mixins/nodeBase.ts +++ b/packages/editor-ui/src/mixins/nodeBase.ts @@ -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', diff --git a/packages/editor-ui/src/mixins/nodeHelpers.ts b/packages/editor-ui/src/mixins/nodeHelpers.ts index b282e27740083..229db6953eaa4 100644 --- a/packages/editor-ui/src/mixins/nodeHelpers.ts +++ b/packages/editor-ui/src/mixins/nodeHelpers.ts @@ -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 diff --git a/packages/editor-ui/src/models/history.ts b/packages/editor-ui/src/models/history.ts new file mode 100644 index 0000000000000..641aa9503b0a8 --- /dev/null +++ b/packages/editor-ui/src/models/history.ts @@ -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; +} + +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 { + return new Promise(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 { + return new Promise(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 { + return new Promise(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 { + return new Promise(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 { + return new Promise(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 { + return new Promise(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 { + return new Promise(resolve => { + this.eventBus.$root.$emit('revertRenameNode', { currentName: this.currentName, newName: this.newName }); + resolve(); + }); + } +} diff --git a/packages/editor-ui/src/stores/history.ts b/packages/editor-ui/src/stores/history.ts new file mode 100644 index 0000000000000..4285d9fb03eac --- /dev/null +++ b/packages/editor-ui/src/stores/history.ts @@ -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; + }, + }, +}); diff --git a/packages/editor-ui/src/stores/n8nRootStore.ts b/packages/editor-ui/src/stores/n8nRootStore.ts index 1d6f9276de174..2b5e9c1fc4463 100644 --- a/packages/editor-ui/src/stores/n8nRootStore.ts +++ b/packages/editor-ui/src/stores/n8nRootStore.ts @@ -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. */ diff --git a/packages/editor-ui/src/utils/canvasUtils.ts b/packages/editor-ui/src/utils/canvasUtils.ts index cd89f6676cb99..ef879f2eb8e9e 100644 --- a/packages/editor-ui/src/utils/canvasUtils.ts +++ b/packages/editor-ui/src/utils/canvasUtils.ts @@ -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; +}; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 3d1f1e7d43972..ee82979a8fef1 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -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 { + 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);