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

View file

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

View file

@ -105,6 +105,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers';
import { pinData } from '@/mixins/pinData'; import { pinData } from '@/mixins/pinData';
import { import {
IDataObject,
INodeTypeDescription, INodeTypeDescription,
ITaskData, ITaskData,
NodeHelpers, NodeHelpers,
@ -117,13 +118,14 @@ import mixins from 'vue-typed-mixins';
import { get } from 'lodash'; import { get } from 'lodash';
import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils'; import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils';
import { IExecutionsSummary, INodeUi, XYPosition } from '@/Interface'; import { IExecutionsSummary, INodeUi, INodeUpdatePropertiesInformation, XYPosition } from '@/Interface';
import { debounceHelper } from '@/mixins/debounce'; import { debounceHelper } from '@/mixins/debounce';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui'; import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { EnableNodeToggleCommand } from '@/models/history';
export default mixins( export default mixins(
externalHooks, externalHooks,
@ -433,8 +435,11 @@ export default mixins(
: nodeSubtitle; : nodeSubtitle;
}, },
disableNode () { disableNode () {
if (this.data !== null) {
this.disableNodes([this.data]); 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 }); this.$telemetry.track('User clicked node hover button', { node_type: this.data.type, button_name: 'disable', workflow_id: this.workflowsStore.workflowId });
}
}, },
executeNode () { executeNode () {
this.$emit('runWorkflow', this.data.name, 'Node.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 { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useHistoryStore } from '@/stores/history';
import { RenameNodeCommand } from '@/models/history';
export default mixins(externalHooks, nodeHelpers).extend({ export default mixins(externalHooks, nodeHelpers).extend({
name: 'NodeSettings', name: 'NodeSettings',
@ -179,6 +181,7 @@ export default mixins(externalHooks, nodeHelpers).extend({
}, },
computed: { computed: {
...mapStores( ...mapStores(
useHistoryStore,
useNodeTypesStore, useNodeTypesStore,
useNDVStore, useNDVStore,
useUIStore, useUIStore,
@ -498,6 +501,9 @@ export default mixins(externalHooks, nodeHelpers).extend({
this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation }); this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
}, },
nameChanged(name: string) { nameChanged(name: string) {
if (this.node) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(this.node.name, name, this));
}
// @ts-ignore // @ts-ignore
this.valueChanged({ this.valueChanged({
value: name, value: name,

View file

@ -434,4 +434,5 @@ export enum STORES {
VERSIONS = 'versions', VERSIONS = 'versions',
NODE_CREATOR = 'nodeCreator', NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks', 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 { useNodeTypesStore } from "@/stores/nodeTypes";
import * as NodeViewUtils from '@/utils/nodeViewUtils'; import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { getStyleTokenValue } from "@/utils"; import { getStyleTokenValue } from "@/utils";
import { useHistoryStore } from "@/stores/history";
import { MoveNodeCommand } from "@/models/history";
export const nodeBase = mixins( export const nodeBase = mixins(
deviceSupportHelpers, deviceSupportHelpers,
@ -33,6 +35,7 @@ export const nodeBase = mixins(
useNodeTypesStore, useNodeTypesStore,
useUIStore, useUIStore,
useWorkflowsStore, useWorkflowsStore,
useHistoryStore,
), ),
data (): INodeUi | null { data (): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name); return this.workflowsStore.getNodeByName(this.name);
@ -281,6 +284,9 @@ export const nodeBase = mixins(
moveNodes.push(this.data); 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 // 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 // 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 // some dirty DOM query to get the new positions till I have more time to
@ -304,12 +310,17 @@ export const nodeBase = mixins(
position: newNodePosition, position: newNodePosition,
}, },
}; };
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.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', 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 { import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME, PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_KEY,
@ -51,6 +53,7 @@ export const nodeHelpers = mixins(
computed: { computed: {
...mapStores( ...mapStores(
useCredentialsStore, useCredentialsStore,
useHistoryStore,
useNodeTypesStore, useNodeTypesStore,
useSettingsStore, useSettingsStore,
useWorkflowsStore, useWorkflowsStore,
@ -431,13 +434,17 @@ export const nodeHelpers = mixins(
return returnData; return returnData;
}, },
disableNodes(nodes: INodeUi[]) { disableNodes(nodes: INodeUi[], trackHistory = false) {
if (trackHistory) {
this.historyStore.startRecordingUndo();
}
for (const node of nodes) { for (const node of nodes) {
const oldState = node.disabled;
// Toggle disabled flag // Toggle disabled flag
const updateInformation = { const updateInformation = {
name: node.name, name: node.name,
properties: { properties: {
disabled: !node.disabled, disabled: !oldState,
} as IDataObject, } as IDataObject,
} as INodeUpdatePropertiesInformation; } as INodeUpdatePropertiesInformation;
@ -447,6 +454,12 @@ export const nodeHelpers = mixins(
this.workflowsStore.clearNodeExecutionData(node.name); this.workflowsStore.clearNodeExecutionData(node.name);
this.updateNodeParameterIssues(node); this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(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 // @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, sessionId: this.sessionId,
}; };
}, },
// TODO: Waiting for nodeTypes store
/** /**
* Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc. * 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 { MAIN_HEADER_TABS, VIEWS } from "@/constants";
import { IZoomConfig } from "@/Interface"; import { IZoomConfig } from "@/Interface";
import { useWorkflowsStore } from "@/stores/workflows";
import { OnConnectionBindInfo } from "jsplumb";
import { IConnection } from "n8n-workflow";
import { Route } from "vue-router"; import { Route } from "vue-router";
/* /*
@ -89,3 +92,26 @@ export const getNodeViewTab = (route: Route): string|null => {
} }
return 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" @deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName" @deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName" @nodeSelected="nodeSelectedByName"
@removeNode="removeNode" @removeNode="(name) => removeNode(name, true)"
@runWorkflow="onRunNode" @runWorkflow="onRunNode"
@moved="onNodeMoved" @moved="onNodeMoved"
@run="onNodeRun" @run="onNodeRun"
@ -64,7 +64,7 @@
@deselectAllNodes="deselectAllNodes" @deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName" @deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName" @nodeSelected="nodeSelectedByName"
@removeNode="removeNode" @removeNode="(name) => removeNode(name, true)"
:key="`${nodeData.id}_sticky`" :key="`${nodeData.id}_sticky`"
:name="nodeData.name" :name="nodeData.name"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
@ -233,7 +233,9 @@ import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
import { useCanvasStore } from '@/stores/canvas'; import { useCanvasStore } from '@/stores/canvas';
import useWorkflowsEEStore from "@/stores/workflows.ee"; import useWorkflowsEEStore from "@/stores/workflows.ee";
import * as NodeViewUtils from '@/utils/nodeViewUtils'; 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 { interface AddNodeOptions {
position?: XYPosition; position?: XYPosition;
@ -407,6 +409,7 @@ export default mixins(
useUsersStore, useUsersStore,
useNodeCreatorStore, useNodeCreatorStore,
useWorkflowsEEStore, useWorkflowsEEStore,
useHistoryStore,
), ),
nativelyNumberSuffixedDefaults(): string[] { nativelyNumberSuffixedDefaults(): string[] {
return this.rootStore.nativelyNumberSuffixedDefaults; return this.rootStore.nativelyNumberSuffixedDefaults;
@ -545,6 +548,10 @@ export default mixins(
showTriggerMissingTooltip: false, showTriggerMissingTooltip: false,
workflowData: null as INewWorkflowData | null, workflowData: null as INewWorkflowData | null,
isProductionExecutionPreview: false, 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() { beforeDestroy() {
@ -1117,7 +1124,7 @@ export default mixins(
if (!this.editAllowedCheck()) { if (!this.editAllowedCheck()) {
return; return;
} }
this.disableNodes(this.uiStore.getSelectedNodes); this.disableNodes(this.uiStore.getSelectedNodes, true);
}, },
deleteSelectedNodes() { deleteSelectedNodes() {
@ -1127,9 +1134,13 @@ export default mixins(
const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => { const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => {
return node.name; return node.name;
}); });
this.historyStore.startRecordingUndo();
nodesToDelete.forEach((nodeName: string) => { nodesToDelete.forEach((nodeName: string) => {
this.removeNode(nodeName); this.removeNode(nodeName, true, false);
}); });
setTimeout(() => {
this.historyStore.stopRecordingUndo();
}, 200);
}, },
selectAllNodes() { selectAllNodes() {
@ -1173,12 +1184,13 @@ export default mixins(
this.nodeSelectedByName(lastSelectedNode.name); this.nodeSelectedByName(lastSelectedNode.name);
}, },
pushDownstreamNodes(sourceNodeName: string, margin: number) { pushDownstreamNodes(sourceNodeName: string, margin: number, recordHistory = false) {
const sourceNode = this.workflowsStore.nodesByName[sourceNodeName]; const sourceNode = this.workflowsStore.nodesByName[sourceNodeName];
const workflow = this.getCurrentWorkflow(); const workflow = this.getCurrentWorkflow();
const childNodes = workflow.getChildNodes(sourceNodeName); const childNodes = workflow.getChildNodes(sourceNodeName);
for (const nodeName of childNodes) { for (const nodeName of childNodes) {
const node = this.workflowsStore.nodesByName[nodeName] as INodeUi; const node = this.workflowsStore.nodesByName[nodeName] as INodeUi;
const oldPosition = node.position;
if (node.position[0] < sourceNode.position[0]) { if (node.position[0] < sourceNode.position[0]) {
continue; continue;
@ -1193,6 +1205,10 @@ export default mixins(
this.workflowsStore.updateNodeProperties(updateInformation); this.workflowsStore.updateNodeProperties(updateInformation);
this.onNodeMoved(node); 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; 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); const nodeTypeData: INodeTypeDescription | null = this.nodeTypesStore.getNodeType(nodeTypeName);
if (nodeTypeData === null) { if (nodeTypeData === null) {
@ -1653,7 +1669,7 @@ export default mixins(
if (lastSelectedConnection) { // set when injecting into a connection if (lastSelectedConnection) { // set when injecting into a connection
const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection); const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection);
if (diffX <= NodeViewUtils.MAX_X_TO_PUSH_DOWNSTREAM_NODES) { 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(); newNodeData.webhookId = uuid();
} }
await this.addNodes([newNodeData]); await this.addNodes([newNodeData], undefined, trackHistory);
this.uiStore.stateIsDirty = true; this.uiStore.stateIsDirty = true;
@ -1728,7 +1744,8 @@ export default mixins(
} }
// Automatically deselect all nodes and select the current one and also active // Automatically deselect all nodes and select the current one and also active
// current node // current node. But only if it's added manually by the user (not by undo/redo mechanism)
if(trackHistory) {
this.deselectAllNodes(); this.deselectAllNodes();
const preventDetailOpen = window.posthog?.getFeatureFlag && window.posthog?.getFeatureFlag('prevent-ndv-auto-open') === 'prevent'; const preventDetailOpen = window.posthog?.getFeatureFlag && window.posthog?.getFeatureFlag('prevent-ndv-auto-open') === 'prevent';
if(showDetail && !preventDetailOpen) { if(showDetail && !preventDetailOpen) {
@ -1736,6 +1753,7 @@ export default mixins(
this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE); this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
}); });
} }
}
return newNodeData; return newNodeData;
}, },
@ -1751,7 +1769,7 @@ export default mixins(
return undefined; 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)) { if (this.getConnection(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex)) {
return; return;
} }
@ -1771,7 +1789,7 @@ export default mixins(
this.__addConnection(connectionData, true); 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()) { if (!this.editAllowedCheck()) {
return; return;
} }
@ -1780,7 +1798,9 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
const lastSelectedNodeOutputIndex = this.uiStore.lastSelectedNodeOutputIndex; 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) { if (!newNodeData) {
return; return;
} }
@ -1792,16 +1812,17 @@ export default mixins(
await Vue.nextTick(); await Vue.nextTick();
if (lastSelectedConnection && lastSelectedConnection.__meta) { if (lastSelectedConnection && lastSelectedConnection.__meta) {
this.__deleteJSPlumbConnection(lastSelectedConnection); this.__deleteJSPlumbConnection(lastSelectedConnection, trackHistory);
const targetNodeName = lastSelectedConnection.__meta.targetNodeName; const targetNodeName = lastSelectedConnection.__meta.targetNodeName;
const targetOutputIndex = lastSelectedConnection.__meta.targetOutputIndex; 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 // 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() { initNodeView() {
this.instance.importDefaults({ this.instance.importDefaults({
@ -1847,7 +1868,7 @@ export default mixins(
const sourceNodeName = sourceNode.name; const sourceNodeName = sourceNode.name;
const outputIndex = connection.getParameters().index; const outputIndex = connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0); this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0, true);
this.pullConnActiveNodeName = null; this.pullConnActiveNodeName = null;
} }
return; return;
@ -1988,8 +2009,7 @@ export default mixins(
NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint); NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint);
this.workflowsStore.addConnection({ const connectionData: [IConnection, IConnection] = [
connection: [
{ {
node: sourceNodeName, node: sourceNodeName,
type: sourceInfo.type, type: sourceInfo.type,
@ -2000,9 +2020,15 @@ export default mixins(
type: targetInfo.type, type: targetInfo.type,
index: targetInfo.index, index: targetInfo.index,
}, },
], ];
this.workflowsStore.addConnection({
connection: connectionData,
setStateDirty: true, setStateDirty: true,
}); });
if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData, this));
}
} catch (e) { } catch (e) {
console.error(e); // eslint-disable-line no-console 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 { try {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
NodeViewUtils.resetInputLabelPosition(info.targetEndpoint); NodeViewUtils.resetInputLabelPosition(info.targetEndpoint);
info.connection.removeOverlays(); 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 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 sourceNode = this.workflowsStore.getNodeById(info.connection.sourceId);
const sourceNodeName = sourceNode.name; const sourceNodeName = sourceNode.name;
const outputIndex = info.connection.getParameters().index; 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; 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) { } catch (e) {
console.error(e); // eslint-disable-line no-console console.error(e); // eslint-disable-line no-console
@ -2180,6 +2218,7 @@ export default mixins(
} }
} }
this.uiStore.nodeViewInitialized = true; this.uiStore.nodeViewInitialized = true;
this.historyStore.reset();
this.workflowsStore.activeWorkflowExecution = null; this.workflowsStore.activeWorkflowExecution = null;
this.stopLoading(); this.stopLoading();
}), }),
@ -2246,6 +2285,7 @@ export default mixins(
await this.newWorkflow(); await this.newWorkflow();
} }
} }
this.historyStore.reset();
this.uiStore.nodeViewInitialized = true; this.uiStore.nodeViewInitialized = true;
document.addEventListener('keydown', this.keyDown); document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp); document.addEventListener('keyup', this.keyUp);
@ -2313,23 +2353,38 @@ export default mixins(
}, },
__removeConnection(connection: [IConnection, IConnection], removeVisualConnection = false) { __removeConnection(connection: [IConnection, IConnection], removeVisualConnection = false) {
if (removeVisualConnection) { if (removeVisualConnection) {
const sourceId = this.workflowsStore.getNodeByName(connection[0].node); const sourceNode = this.workflowsStore.getNodeByName(connection[0].node);
const targetId = this.workflowsStore.getNodeByName(connection[1].node); const targetNode = this.workflowsStore.getNodeByName(connection[1].node);
if (!sourceNode || !targetNode) {
return;
}
// @ts-ignore // @ts-ignore
const connections = this.instance.getConnections({ const connections = this.instance.getConnections({
source: sourceId, source: sourceNode.id,
target: targetId, target: targetNode.id,
}); });
// @ts-ignore // @ts-ignore
connections.forEach((connectionInstance) => { connections.forEach((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); this.__deleteJSPlumbConnection(connectionInstance);
}
} else {
this.__deleteJSPlumbConnection(connectionInstance);
}
}); });
} }
this.workflowsStore.removeConnection({ connection }); this.workflowsStore.removeConnection({ connection });
}, },
__deleteJSPlumbConnection(connection: Connection) { __deleteJSPlumbConnection(connection: Connection, trackHistory = false) {
// Make sure to remove the overlay else after the second move // Make sure to remove the overlay else after the second move
// it visibly stays behind free floating without a connection. // it visibly stays behind free floating without a connection.
connection.removeOverlays(); connection.removeOverlays();
@ -2341,31 +2396,24 @@ export default mixins(
const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId); const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId);
endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint
} }
}, if (trackHistory && connection.__meta) {
__removeConnectionByConnectionInfo(info: OnConnectionBindInfo, removeVisualConnection = false) { const connectionData: [IConnection, IConnection] = [
const sourceInfo = info.sourceEndpoint.getParameters(); { index: connection.__meta?.sourceOutputIndex, node: connection.__meta.sourceNodeName, type: 'main' },
const sourceNode = this.workflowsStore.getNodeById(sourceInfo.nodeId); { index: connection.__meta?.targetOutputIndex, node: connection.__meta.targetNodeName, type: 'main' },
const targetInfo = info.targetEndpoint.getParameters(); ];
const targetNode = this.workflowsStore.getNodeById(targetInfo.nodeId); const removeCommand = new RemoveConnectionCommand(connectionData, this);
this.historyStore.pushCommandToUndo(removeCommand);
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];
if (removeVisualConnection) {
this.__deleteJSPlumbConnection(info.connection);
} }
},
__removeConnectionByConnectionInfo(info: OnConnectionBindInfo, removeVisualConnection = false, trackHistory = false) {
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
if (connectionInfo) {
if (removeVisualConnection) {
this.__deleteJSPlumbConnection(info.connection, trackHistory);
} else if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, this));
}
this.workflowsStore.removeConnection({ connection: connectionInfo }); 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); const pinData = this.workflowsStore.pinDataByNodeName(nodeName);
if (pinData) { if (pinData) {
@ -2562,7 +2610,7 @@ export default mixins(
}); });
}); });
}, },
removeNode(nodeName: string) { removeNode(nodeName: string, trackHistory = false, trackBulk = true) {
if (!this.editAllowedCheck()) { if (!this.editAllowedCheck()) {
return; return;
} }
@ -2572,6 +2620,10 @@ export default mixins(
return; return;
} }
if (trackHistory && trackBulk) {
this.historyStore.startRecordingUndo();
}
// "requiredNodeTypes" are also defined in cli/commands/run.ts // "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes: string[] = []; const requiredNodeTypes: string[] = [];
@ -2625,7 +2677,7 @@ export default mixins(
const targetNodeOuputIndex = conn2.__meta.targetOutputIndex; const targetNodeOuputIndex = conn2.__meta.targetOutputIndex;
setTimeout(() => { setTimeout(() => {
this.connectTwoNodes(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex); this.connectTwoNodes(sourceNodeName, sourceNodeOutputIndex, targetNodeName, targetNodeOuputIndex, trackHistory);
if (waitForNewConnection) { if (waitForNewConnection) {
this.instance.setSuspendDrawing(false, true); this.instance.setSuspendDrawing(false, true);
@ -2659,7 +2711,16 @@ export default mixins(
// Remove node from selected index if found in it // Remove node from selected index if found in it
this.uiStore.removeNodeFromSelection(node); this.uiStore.removeNodeFromSelection(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node, this));
}
}, 0); // allow other events to finish like drag stop }, 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) { valueChanged(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) { if (parameterData.name === 'name' && parameterData.oldValue) {
@ -2694,14 +2755,19 @@ export default mixins(
const promptResponse = await promptResponsePromise as MessageBoxInputData; const promptResponse = await promptResponsePromise as MessageBoxInputData;
this.renameNode(currentName, promptResponse.value); this.renameNode(currentName, promptResponse.value, true);
} catch (e) { } } catch (e) { }
}, },
async renameNode(currentName: string, newName: string) { async renameNode(currentName: string, newName: string, trackHistory=false) {
if (currentName === newName) { if (currentName === newName) {
return; return;
} }
this.suspendRecordingDetachedConnections = true;
if (trackHistory) {
this.historyStore.startRecordingUndo();
}
const activeNodeName = this.activeNode && this.activeNode.name; const activeNodeName = this.activeNode && this.activeNode.name;
const isActive = activeNodeName === currentName; const isActive = activeNodeName === currentName;
if (isActive) { if (isActive) {
@ -2717,6 +2783,10 @@ export default mixins(
const workflow = this.getCurrentWorkflow(true); const workflow = this.getCurrentWorkflow(true);
workflow.renameNode(currentName, newName); workflow.renameNode(currentName, newName);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, this));
}
// Update also last selected node and execution data // Update also last selected node and execution data
this.workflowsStore.renameNodeSelectedAndExecution({ old: currentName, new: newName }); this.workflowsStore.renameNodeSelectedAndExecution({ old: currentName, new: newName });
@ -2730,7 +2800,7 @@ export default mixins(
await Vue.nextTick(); await Vue.nextTick();
// Add the new updated nodes // 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 // Make sure that the node is selected again
this.deselectAllNodes(); this.deselectAllNodes();
@ -2740,6 +2810,11 @@ export default mixins(
this.ndvStore.activeNodeName = newName; this.ndvStore.activeNodeName = newName;
this.renamingActive = false; this.renamingActive = false;
} }
if (trackHistory) {
this.historyStore.stopRecordingUndo();
}
this.suspendRecordingDetachedConnections = false;
}, },
deleteEveryEndpoint() { deleteEveryEndpoint() {
// Check as it does not exist on first load // 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) { if (!nodes || !nodes.length) {
return; return;
} }
@ -2859,6 +2934,9 @@ export default mixins(
} }
this.workflowsStore.addNode(node); this.workflowsStore.addNode(node);
if (trackHistory) {
this.historyStore.pushCommandToUndo(new AddNodeCommand(node, this));
}
}); });
// Wait for the node to be rendered // 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 // 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; this.uiStore.stateIsDirty = true;
@ -3259,7 +3339,7 @@ export default mixins(
}, },
onAddNode(nodeTypes: Array<{ nodeTypeName: string; position: XYPosition }>, dragAndDrop: boolean) { onAddNode(nodeTypes: Array<{ nodeTypeName: string; position: XYPosition }>, dragAndDrop: boolean) {
nodeTypes.forEach(({ nodeTypeName, position }, index) => { 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(index === 0) return;
// If there's more than one node, we want to connect them // 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 // this has to be done in mutation subscriber to make sure both nodes already
@ -3287,6 +3367,51 @@ export default mixins(
await this.saveCurrentWorkflow(); await this.saveCurrentWorkflow();
callback?.(); 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() { async mounted() {
this.$titleReset(); this.$titleReset();
@ -3389,6 +3514,13 @@ export default mixins(
this.$root.$on('newWorkflow', this.newWorkflow); this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent); 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('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections); dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
@ -3404,6 +3536,13 @@ export default mixins(
this.$root.$off('newWorkflow', this.newWorkflow); this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent); 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('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections); dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);