feat(editor): Add undo/redo when moving nodes in new canvas (no-changelog) (#10137)

This commit is contained in:
Alex Grozav 2024-07-23 10:16:56 +03:00 committed by GitHub
parent aa15d22499
commit 278edd6010
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 126 additions and 24 deletions

View file

@ -8,8 +8,7 @@ import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/dat
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { useDeviceSupport } from 'n8n-design-system'; import type { useDeviceSupport } from 'n8n-design-system';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Initialize window object
// @ts-expect-error
global.window = jsdom.window as unknown as Window & typeof globalThis; global.window = jsdom.window as unknown as Window & typeof globalThis;
vi.mock('n8n-design-system', async (importOriginal) => { vi.mock('n8n-design-system', async (importOriginal) => {
@ -100,12 +99,14 @@ describe('Canvas', () => {
await fireEvent.mouseDown(node, { view: window }); await fireEvent.mouseDown(node, { view: window });
await fireEvent.mouseMove(node, { await fireEvent.mouseMove(node, {
view: window, view: window,
clientX: 100, clientX: 96,
clientY: 100, clientY: 96,
}); });
await fireEvent.mouseUp(node, { view: window }); await fireEvent.mouseUp(node, { view: window });
// Snap to 16px grid: 100 -> 96 // Snap to 20px grid: 96 -> 100
expect(emitted()['update:node:position']).toEqual([['1', { x: 96, y: 96 }]]); expect(emitted()['update:nodes:position']).toEqual([
[[{ id: '1', position: { x: 100, y: 100 } }]],
]);
}); });
}); });

View file

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { CanvasConnection, CanvasNode, ConnectStartEvent } from '@/types'; import type { CanvasConnection, CanvasNode, CanvasNodeMoveEvent, ConnectStartEvent } from '@/types';
import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core'; import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core'; import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background'; import { Background } from '@vue-flow/background';
@ -16,12 +16,14 @@ import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { NodeCreatorOpenSource } from '@/Interface'; import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData'; import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils'; import { isPresent } from '@/utils/typesUtils';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
const $style = useCssModule(); const $style = useCssModule();
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [elements: CanvasNode[]]; 'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition]; 'update:node:position': [id: string, position: XYPosition];
'update:nodes:position': [events: CanvasNodeMoveEvent[]];
'update:node:active': [id: string]; 'update:node:active': [id: string];
'update:node:enabled': [id: string]; 'update:node:enabled': [id: string];
'update:node:selected': [id: string]; 'update:node:selected': [id: string];
@ -116,9 +118,11 @@ function onClickNodeAdd(id: string, handle: string) {
} }
function onNodeDragStop(e: NodeDragEvent) { function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => { onUpdateNodesPosition(e.nodes.map((node) => ({ id: node.id, position: node.position })));
onUpdateNodePosition(node.id, node.position); }
});
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
emit('update:nodes:position', events);
} }
function onUpdateNodePosition(id: string, position: XYPosition) { function onUpdateNodePosition(id: string, position: XYPosition) {
@ -343,7 +347,7 @@ onPaneReady(async () => {
:apply-changes="false" :apply-changes="false"
pan-on-scroll pan-on-scroll
snap-to-grid snap-to-grid
:snap-grid="[16, 16]" :snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0.2" :min-zoom="0.2"
:max-zoom="4" :max-zoom="4"
:class="[$style.canvas, { [$style.visible]: paneReady }]" :class="[$style.canvas, { [$style.visible]: paneReady }]"

View file

@ -190,6 +190,60 @@ describe('useCanvasOperations', () => {
}); });
}); });
describe('updateNodesPosition', () => {
it('records history for multiple node position updates when tracking is enabled', () => {
const events = [
{ id: 'node1', position: { x: 100, y: 100 } },
{ id: 'node2', position: { x: 200, y: 200 } },
];
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
canvasOperations.updateNodesPosition(events, { trackHistory: true, trackBulk: true });
expect(startRecordingUndoSpy).toHaveBeenCalled();
expect(stopRecordingUndoSpy).toHaveBeenCalled();
});
it('updates positions for multiple nodes', () => {
const events = [
{ id: 'node1', position: { x: 100, y: 100 } },
{ id: 'node2', position: { x: 200, y: 200 } },
];
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(
createTestNode({
id: events[0].id,
position: [events[0].position.x, events[0].position.y],
}),
)
.mockReturnValueOnce(
createTestNode({
id: events[1].id,
position: [events[1].position.x, events[1].position.y],
}),
);
canvasOperations.updateNodesPosition(events);
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node2', [200, 200]);
});
it('does not record history when trackHistory is false', () => {
const events = [{ id: 'node1', position: { x: 100, y: 100 } }];
const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo');
const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo');
canvasOperations.updateNodesPosition(events, { trackHistory: false, trackBulk: false });
expect(startRecordingUndoSpy).not.toHaveBeenCalled();
expect(stopRecordingUndoSpy).not.toHaveBeenCalled();
});
});
describe('updateNodePosition', () => { describe('updateNodePosition', () => {
it('should update node position', () => { it('should update node position', () => {
const setNodePositionByIdSpy = vi const setNodePositionByIdSpy = vi

View file

@ -49,7 +49,12 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { CanvasConnection, CanvasConnectionCreateData, CanvasNode } from '@/types'; import type {
CanvasConnection,
CanvasConnectionCreateData,
CanvasNode,
CanvasNodeMoveEvent,
} from '@/types';
import { CanvasConnectionMode } from '@/types'; import { CanvasConnectionMode } from '@/types';
import { import {
createCanvasConnectionHandleString, createCanvasConnectionHandleString,
@ -138,20 +143,33 @@ export function useCanvasOperations({
* Node operations * Node operations
*/ */
function updateNodesPosition(
events: CanvasNodeMoveEvent[],
{ trackHistory = false, trackBulk = true } = {},
) {
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
events.forEach(({ id, position }) => {
updateNodePosition(id, position, { trackHistory });
});
if (trackBulk) {
historyStore.stopRecordingUndo();
}
}
function updateNodePosition( function updateNodePosition(
id: string, id: string,
position: CanvasNode['position'], position: CanvasNode['position'],
{ trackHistory = false, trackBulk = true } = {}, { trackHistory = false } = {},
) { ) {
const node = workflowsStore.getNodeById(id); const node = workflowsStore.getNodeById(id);
if (!node) { if (!node) {
return; return;
} }
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
const oldPosition: XYPosition = [...node.position]; const oldPosition: XYPosition = [...node.position];
const newPosition: XYPosition = [position.x, position.y]; const newPosition: XYPosition = [position.x, position.y];
@ -159,13 +177,18 @@ export function useCanvasOperations({
if (trackHistory) { if (trackHistory) {
historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newPosition)); historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newPosition));
if (trackBulk) {
historyStore.stopRecordingUndo();
}
} }
} }
function revertUpdateNodePosition(nodeName: string, position: CanvasNode['position']) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) {
return;
}
updateNodePosition(node.id, position);
}
async function renameNode(currentName: string, newName: string, { trackHistory = false } = {}) { async function renameNode(currentName: string, newName: string, { trackHistory = false } = {}) {
if (currentName === newName) { if (currentName === newName) {
return; return;
@ -1648,7 +1671,9 @@ export function useCanvasOperations({
addNodes, addNodes,
addNode, addNode,
revertAddNode, revertAddNode,
updateNodesPosition,
updateNodePosition, updateNodePosition,
revertUpdateNodePosition,
setNodeActive, setNodeActive,
setNodeActiveByName, setNodeActiveByName,
setNodeSelected, setNodeSelected,

View file

@ -132,3 +132,5 @@ export interface CanvasNodeHandleInjectionData {
} }
export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string }; export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string };
export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] };

View file

@ -29,7 +29,12 @@ import type {
XYPosition, XYPosition,
} from '@/Interface'; } from '@/Interface';
import type { Connection } from '@vue-flow/core'; import type { Connection } from '@vue-flow/core';
import type { CanvasConnectionCreateData, CanvasNode, ConnectStartEvent } from '@/types'; import type {
CanvasConnectionCreateData,
CanvasNode,
CanvasNodeMoveEvent,
ConnectStartEvent,
} from '@/types';
import { CanvasNodeRenderType } from '@/types'; import { CanvasNodeRenderType } from '@/types';
import { import {
CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE,
@ -134,6 +139,8 @@ const lastClickPosition = ref<XYPosition>([450, 450]);
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router }); const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const { const {
updateNodePosition, updateNodePosition,
updateNodesPosition,
revertUpdateNodePosition,
renameNode, renameNode,
revertRenameNode, revertRenameNode,
setNodeActive, setNodeActive,
@ -444,10 +451,18 @@ const allTriggerNodesDisabled = computed(() => {
return disabledTriggerNodes.length === triggerNodes.value.length; return disabledTriggerNodes.length === triggerNodes.value.length;
}); });
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
updateNodesPosition(events, { trackHistory: true });
}
function onUpdateNodePosition(id: string, position: CanvasNode['position']) { function onUpdateNodePosition(id: string, position: CanvasNode['position']) {
updateNodePosition(id, position, { trackHistory: true }); updateNodePosition(id, position, { trackHistory: true });
} }
function onRevertNodePosition({ nodeName, position }: { nodeName: string; position: XYPosition }) {
revertUpdateNodePosition(nodeName, { x: position[0], y: position[1] });
}
function onDeleteNode(id: string) { function onDeleteNode(id: string) {
deleteNode(id, { trackHistory: true }); deleteNode(id, { trackHistory: true });
} }
@ -986,7 +1001,7 @@ const chatTriggerNodePinnedData = computed(() => {
*/ */
function addUndoRedoEventBindings() { function addUndoRedoEventBindings() {
// historyBus.on('nodeMove', onMoveNode); historyBus.on('nodeMove', onRevertNodePosition);
historyBus.on('revertAddNode', onRevertAddNode); historyBus.on('revertAddNode', onRevertAddNode);
historyBus.on('revertRemoveNode', onRevertDeleteNode); historyBus.on('revertRemoveNode', onRevertDeleteNode);
historyBus.on('revertAddConnection', onRevertCreateConnection); historyBus.on('revertAddConnection', onRevertCreateConnection);
@ -996,7 +1011,7 @@ function addUndoRedoEventBindings() {
} }
function removeUndoRedoEventBindings() { function removeUndoRedoEventBindings() {
// historyBus.off('nodeMove', onMoveNode); historyBus.off('nodeMove', onRevertNodePosition);
historyBus.off('revertAddNode', onRevertAddNode); historyBus.off('revertAddNode', onRevertAddNode);
historyBus.off('revertRemoveNode', onRevertDeleteNode); historyBus.off('revertRemoveNode', onRevertDeleteNode);
historyBus.off('revertAddConnection', onRevertCreateConnection); historyBus.off('revertAddConnection', onRevertCreateConnection);
@ -1362,6 +1377,7 @@ onBeforeUnmount(() => {
:workflow-object="editableWorkflowObject" :workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes" :fallback-nodes="fallbackNodes"
:event-bus="canvasEventBus" :event-bus="canvasEventBus"
@update:nodes:position="onUpdateNodesPosition"
@update:node:position="onUpdateNodePosition" @update:node:position="onUpdateNodePosition"
@update:node:active="onSetNodeActive" @update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected" @update:node:selected="onSetNodeSelected"