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

View file

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

View file

@ -49,7 +49,12 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.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 {
createCanvasConnectionHandleString,
@ -138,20 +143,33 @@ export function useCanvasOperations({
* 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(
id: string,
position: CanvasNode['position'],
{ trackHistory = false, trackBulk = true } = {},
{ trackHistory = false } = {},
) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
const oldPosition: XYPosition = [...node.position];
const newPosition: XYPosition = [position.x, position.y];
@ -159,13 +177,18 @@ export function useCanvasOperations({
if (trackHistory) {
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 } = {}) {
if (currentName === newName) {
return;
@ -1648,7 +1671,9 @@ export function useCanvasOperations({
addNodes,
addNode,
revertAddNode,
updateNodesPosition,
updateNodePosition,
revertUpdateNodePosition,
setNodeActive,
setNodeActiveByName,
setNodeSelected,

View file

@ -132,3 +132,5 @@ export interface CanvasNodeHandleInjectionData {
}
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,
} from '@/Interface';
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 {
CHAT_TRIGGER_NODE_TYPE,
@ -134,6 +139,8 @@ const lastClickPosition = ref<XYPosition>([450, 450]);
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const {
updateNodePosition,
updateNodesPosition,
revertUpdateNodePosition,
renameNode,
revertRenameNode,
setNodeActive,
@ -444,10 +451,18 @@ const allTriggerNodesDisabled = computed(() => {
return disabledTriggerNodes.length === triggerNodes.value.length;
});
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
updateNodesPosition(events, { trackHistory: true });
}
function onUpdateNodePosition(id: string, position: CanvasNode['position']) {
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) {
deleteNode(id, { trackHistory: true });
}
@ -986,7 +1001,7 @@ const chatTriggerNodePinnedData = computed(() => {
*/
function addUndoRedoEventBindings() {
// historyBus.on('nodeMove', onMoveNode);
historyBus.on('nodeMove', onRevertNodePosition);
historyBus.on('revertAddNode', onRevertAddNode);
historyBus.on('revertRemoveNode', onRevertDeleteNode);
historyBus.on('revertAddConnection', onRevertCreateConnection);
@ -996,7 +1011,7 @@ function addUndoRedoEventBindings() {
}
function removeUndoRedoEventBindings() {
// historyBus.off('nodeMove', onMoveNode);
historyBus.off('nodeMove', onRevertNodePosition);
historyBus.off('revertAddNode', onRevertAddNode);
historyBus.off('revertRemoveNode', onRevertDeleteNode);
historyBus.off('revertAddConnection', onRevertCreateConnection);
@ -1362,6 +1377,7 @@ onBeforeUnmount(() => {
:workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes"
:event-bus="canvasEventBus"
@update:nodes:position="onUpdateNodesPosition"
@update:node:position="onUpdateNodePosition"
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"