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