refactor(editor): Refactor history and debounce mixins to composables (no-changelog) (#5930)
* refactor(editor): Refactor history and debounce mixins to composables and add unit tests (no-changelog) * Lint fix and use userEvent to fire keydown events * Fix debounce spec
This commit is contained in:
@@ -45,10 +45,11 @@ import { useUsersStore } from './stores/users';
|
|||||||
import { useRootStore } from './stores/n8nRootStore';
|
import { useRootStore } from './stores/n8nRootStore';
|
||||||
import { useTemplatesStore } from './stores/templates';
|
import { useTemplatesStore } from './stores/templates';
|
||||||
import { useNodeTypesStore } from './stores/nodeTypes';
|
import { useNodeTypesStore } from './stores/nodeTypes';
|
||||||
import { historyHelper } from '@/mixins/history';
|
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||||
import { newVersions } from '@/mixins/newVersions';
|
import { newVersions } from '@/mixins/newVersions';
|
||||||
|
import { useRoute } from 'vue-router/composables';
|
||||||
|
|
||||||
export default mixins(newVersions, showMessage, userHelpers, restApi, historyHelper).extend({
|
export default mixins(newVersions, showMessage, userHelpers, restApi).extend({
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: {
|
components: {
|
||||||
LoadingView,
|
LoadingView,
|
||||||
@@ -56,10 +57,9 @@ export default mixins(newVersions, showMessage, userHelpers, restApi, historyHel
|
|||||||
Modals,
|
Modals,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
|
|
||||||
return {
|
return {
|
||||||
registerCustomAction,
|
...useGlobalLinkActions(),
|
||||||
unregisterCustomAction,
|
...useHistoryHelper(useRoute()),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
|
import { useDebounceHelper } from '../useDebounce';
|
||||||
|
import { render, screen } from '@testing-library/vue';
|
||||||
|
|
||||||
|
describe('useDebounceHelper', () => {
|
||||||
|
const debounceTime = 200;
|
||||||
|
|
||||||
|
const TestComponent = {
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<button @click="callDebounced(mockFn, { debounceTime, })">
|
||||||
|
Click me
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="callDebounced(mockFn, { debounceTime, trailing: true })">
|
||||||
|
Click me trailing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
props: {
|
||||||
|
mockFn: {
|
||||||
|
type: Function,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const { callDebounced } = useDebounceHelper();
|
||||||
|
return {
|
||||||
|
callDebounced,
|
||||||
|
debounceTime,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('debounces a function call', async () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
render(TestComponent, { props: { mockFn } });
|
||||||
|
const button = screen.getByText('Click me');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports trailing option', async () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
render(TestComponent, { props: { mockFn } });
|
||||||
|
const button = screen.getByText('Click me trailing');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, debounceTime));
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with async functions', async () => {
|
||||||
|
const mockAsyncFn = vi.fn(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
render(TestComponent, { props: { mockFn: mockAsyncFn } });
|
||||||
|
const button = screen.getByText('Click me');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(mockAsyncFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
|
import { MAIN_HEADER_TABS } from '@/constants';
|
||||||
|
import { render } from '@testing-library/vue';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useHistoryHelper } from '../useHistoryHelper';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { Route } from 'vue-router';
|
||||||
|
|
||||||
|
const undoMock = vi.fn();
|
||||||
|
const redoMock = vi.fn();
|
||||||
|
vi.mock('@/stores/ndv', () => ({
|
||||||
|
useNDVStore: () => ({
|
||||||
|
activeNodeName: null,
|
||||||
|
activeNode: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('@/stores/history', () => {
|
||||||
|
return {
|
||||||
|
useHistoryStore: () => ({
|
||||||
|
popUndoableToUndo: undoMock,
|
||||||
|
popUndoableToRedo: redoMock,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('@/stores/ui');
|
||||||
|
vi.mock('vue-router/composables', () => ({
|
||||||
|
useRoute: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestComponent = defineComponent({
|
||||||
|
props: {
|
||||||
|
route: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
useHistoryHelper(props.route as Route);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
template: '<div />',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHistoryHelper', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
undoMock.mockClear();
|
||||||
|
redoMock.mockClear();
|
||||||
|
});
|
||||||
|
it('should call undo when Ctrl+Z is pressed', async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
render(TestComponent, {
|
||||||
|
props: {
|
||||||
|
route: {
|
||||||
|
name: MAIN_HEADER_TABS.WORKFLOW,
|
||||||
|
meta: {
|
||||||
|
nodeView: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}z');
|
||||||
|
await userEvent.keyboard('{Control>}z');
|
||||||
|
|
||||||
|
expect(undoMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it('should call redo when Ctrl+Shift+Z is pressed', async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
render(TestComponent, {
|
||||||
|
props: {
|
||||||
|
route: {
|
||||||
|
name: MAIN_HEADER_TABS.WORKFLOW,
|
||||||
|
meta: {
|
||||||
|
nodeView: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}{Shift>}z');
|
||||||
|
await userEvent.keyboard('{Control>}{Shift>}z');
|
||||||
|
|
||||||
|
expect(redoMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it('should not call undo when Ctrl+Z if not on NodeView', async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
render(TestComponent, { props: { route: {} } });
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Control>}z');
|
||||||
|
await userEvent.keyboard('{Control>}z');
|
||||||
|
|
||||||
|
expect(undoMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
packages/editor-ui/src/composables/useDebounce.ts
Normal file
38
packages/editor-ui/src/composables/useDebounce.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
type DebouncedFunction = (...args: unknown[]) => Promise<void> | void;
|
||||||
|
|
||||||
|
export function useDebounceHelper() {
|
||||||
|
// Create a ref for the WeakMap to store debounced functions.
|
||||||
|
const debouncedFunctions = ref(new WeakMap<DebouncedFunction, DebouncedFunction>());
|
||||||
|
|
||||||
|
const callDebounced = async (
|
||||||
|
func: DebouncedFunction,
|
||||||
|
options: { debounceTime: number; trailing?: boolean },
|
||||||
|
...inputParameters: unknown[]
|
||||||
|
): Promise<void> => {
|
||||||
|
const { trailing, debounceTime } = options;
|
||||||
|
|
||||||
|
// Check if a debounced version of the function is already stored in the WeakMap.
|
||||||
|
let debouncedFunc = debouncedFunctions.value.get(func);
|
||||||
|
|
||||||
|
// If a debounced version is not found, create one and store it in the WeakMap.
|
||||||
|
if (debouncedFunc === undefined) {
|
||||||
|
debouncedFunc = debounce(
|
||||||
|
async (...args: unknown[]) => {
|
||||||
|
await func(...args);
|
||||||
|
},
|
||||||
|
debounceTime,
|
||||||
|
trailing ? { trailing } : { leading: true },
|
||||||
|
);
|
||||||
|
debouncedFunctions.value.set(func, debouncedFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
await debouncedFunc(...inputParameters);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
callDebounced,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
packages/editor-ui/src/composables/useHistoryHelper.ts
Normal file
137
packages/editor-ui/src/composables/useHistoryHelper.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
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 { ref, onMounted, onUnmounted, Ref, nextTick, getCurrentInstance } from 'vue';
|
||||||
|
import { Command } from '@/models/history';
|
||||||
|
import { useDebounceHelper } from './useDebounce';
|
||||||
|
import useDeviceSupportHelpers from './useDeviceSupport';
|
||||||
|
import { getNodeViewTab } from '@/utils';
|
||||||
|
import { Route } from 'vue-router';
|
||||||
|
|
||||||
|
const UNDO_REDO_DEBOUNCE_INTERVAL = 100;
|
||||||
|
|
||||||
|
export function useHistoryHelper(activeRoute: Route) {
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const telemetry = instance?.proxy.$telemetry;
|
||||||
|
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const historyStore = useHistoryStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const { callDebounced } = useDebounceHelper();
|
||||||
|
const { isCtrlKeyPressed } = useDeviceSupportHelpers();
|
||||||
|
|
||||||
|
const isNDVOpen = ref<boolean>(ndvStore.activeNodeName !== null);
|
||||||
|
|
||||||
|
const undo = () =>
|
||||||
|
callDebounced(
|
||||||
|
async () => {
|
||||||
|
const command = historyStore.popUndoableToUndo();
|
||||||
|
if (!command) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command instanceof BulkCommand) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
historyStore.pushUndoableToRedo(new BulkCommand(reverseCommands));
|
||||||
|
await nextTick();
|
||||||
|
historyStore.bulkInProgress = false;
|
||||||
|
}
|
||||||
|
if (command instanceof Command) {
|
||||||
|
await command.revert();
|
||||||
|
historyStore.pushUndoableToRedo(command.getReverseCommand());
|
||||||
|
uiStore.stateIsDirty = true;
|
||||||
|
}
|
||||||
|
trackCommand(command, 'undo');
|
||||||
|
},
|
||||||
|
{ debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL },
|
||||||
|
);
|
||||||
|
|
||||||
|
const redo = () =>
|
||||||
|
callDebounced(
|
||||||
|
async () => {
|
||||||
|
const command = historyStore.popUndoableToRedo();
|
||||||
|
if (!command) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command instanceof BulkCommand) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
historyStore.pushBulkCommandToUndo(new BulkCommand(reverseCommands), false);
|
||||||
|
await nextTick();
|
||||||
|
historyStore.bulkInProgress = false;
|
||||||
|
}
|
||||||
|
if (command instanceof Command) {
|
||||||
|
await command.revert();
|
||||||
|
historyStore.pushCommandToUndo(command.getReverseCommand(), false);
|
||||||
|
uiStore.stateIsDirty = true;
|
||||||
|
}
|
||||||
|
trackCommand(command, 'redo');
|
||||||
|
},
|
||||||
|
{ debounceTime: UNDO_REDO_DEBOUNCE_INTERVAL },
|
||||||
|
);
|
||||||
|
|
||||||
|
function trackCommand(command: Undoable, type: 'undo' | 'redo'): void {
|
||||||
|
if (command instanceof Command) {
|
||||||
|
telemetry?.track(`User hit ${type}`, { commands_length: 1, commands: [command.name] });
|
||||||
|
} else if (command instanceof BulkCommand) {
|
||||||
|
telemetry?.track(`User hit ${type}`, {
|
||||||
|
commands_length: command.commands.length,
|
||||||
|
commands: command.commands.map((c) => c.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackUndoAttempt(event: KeyboardEvent) {
|
||||||
|
if (isNDVOpen.value && !event.shiftKey) {
|
||||||
|
const activeNode = ndvStore.activeNode;
|
||||||
|
if (activeNode) {
|
||||||
|
telemetry?.track('User hit undo in NDV', { node_type: activeNode.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const currentNodeViewTab = getNodeViewTab(activeRoute);
|
||||||
|
|
||||||
|
if (event.repeat || currentNodeViewTab !== MAIN_HEADER_TABS.WORKFLOW) return;
|
||||||
|
if (isCtrlKeyPressed(event) && event.key.toLowerCase() === 'z') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isNDVOpen.value) {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
redo();
|
||||||
|
} else {
|
||||||
|
undo();
|
||||||
|
}
|
||||||
|
} else if (!event.shiftKey) {
|
||||||
|
trackUndoAttempt(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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.toLowerCase() === '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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user