Skip to content

Commit

Permalink
feat(editor): Add undo/redo support for canvas actions (#4787)
Browse files Browse the repository at this point in the history
* ✨ 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
  • Loading branch information
MiloradFilipovic authored Dec 9, 2022
1 parent 38d7300 commit b2aba48
Show file tree
Hide file tree
Showing 13 changed files with 764 additions and 86 deletions.
3 changes: 2 additions & 1 deletion packages/editor-ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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') {
Expand Down
9 changes: 8 additions & 1 deletion packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -164,7 +165,7 @@ export interface IUpdateInformation {
export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: {
[key: string]: IDataObject;
[key: string]: IDataObject | XYPosition;
};
}

Expand Down Expand Up @@ -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;

Expand Down
11 changes: 8 additions & 3 deletions packages/editor-ui/src/components/Node.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers';
import { pinData } from '@/mixins/pinData';
import {
IDataObject,
INodeTypeDescription,
ITaskData,
NodeHelpers,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions packages/editor-ui/src/components/NodeSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -179,6 +181,7 @@ export default mixins(externalHooks, nodeHelpers).extend({
},
computed: {
...mapStores(
useHistoryStore,
useNodeTypesStore,
useNDVStore,
useUIStore,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/editor-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,5 @@ export enum STORES {
VERSIONS = 'versions',
NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks',
HISTORY = 'history',
}
117 changes: 117 additions & 0 deletions packages/editor-ui/src/mixins/history.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
},
},
});
19 changes: 15 additions & 4 deletions packages/editor-ui/src/mixins/nodeBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +35,7 @@ export const nodeBase = mixins(
useNodeTypesStore,
useUIStore,
useWorkflowsStore,
useHistoryStore,
),
data (): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
17 changes: 15 additions & 2 deletions packages/editor-ui/src/mixins/nodeHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { EnableNodeToggleCommand } from './../models/history';
import { useHistoryStore } from '@/stores/history';
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
Expand Down Expand Up @@ -51,6 +53,7 @@ export const nodeHelpers = mixins(
computed: {
...mapStores(
useCredentialsStore,
useHistoryStore,
useNodeTypesStore,
useSettingsStore,
useWorkflowsStore,
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down
Loading

0 comments on commit b2aba48

Please sign in to comment.