fix(editor): Prevent unloading when changes are pending in new canvas (no-changelog) (#10474)

This commit is contained in:
Alex Grozav 2024-08-21 13:46:57 +03:00 committed by GitHub
parent e936494e3d
commit 6d82fb9fc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 21 deletions

View file

@ -0,0 +1,86 @@
import { useBeforeUnload } from '@/composables/useBeforeUnload';
import { STORES, VIEWS } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useCanvasStore } from '@/stores/canvas.store';
import type { useRoute } from 'vue-router';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { mock } from 'vitest-mock-extended';
import { describe } from 'vitest';
describe('useBeforeUnload', () => {
const defaultRoute = mock<ReturnType<typeof useRoute>>({ name: 'someRoute' });
let uiStore: ReturnType<typeof useUIStore>;
let canvasStore: ReturnType<typeof useCanvasStore>;
beforeEach(() => {
const pinia = createTestingPinia({
initialState: {
[STORES.UI]: {
stateIsDirty: false,
},
},
});
setActivePinia(pinia);
uiStore = useUIStore();
canvasStore = useCanvasStore();
});
describe('onBeforeUnload', () => {
it('should do nothing if route is demo', () => {
const route = mock<ReturnType<typeof useRoute>>({ name: VIEWS.DEMO });
const { onBeforeUnload } = useBeforeUnload({ route });
const event = new Event('beforeunload');
const result = onBeforeUnload(event);
expect(result).toBeUndefined();
});
it('should prompt user if state is dirty', () => {
uiStore.stateIsDirty = true;
const { onBeforeUnload } = useBeforeUnload({ route: defaultRoute });
const event = new Event('beforeunload');
const result = onBeforeUnload(event);
expect(result).toBe(true);
});
it('should start loading if state is not dirty', () => {
uiStore.stateIsDirty = false;
const startLoadingSpy = vi.spyOn(canvasStore, 'startLoading');
const { onBeforeUnload } = useBeforeUnload({ route: defaultRoute });
const event = new Event('beforeunload');
const result = onBeforeUnload(event);
expect(startLoadingSpy).toHaveBeenCalledWith(expect.any(String));
expect(result).toBeUndefined();
});
});
describe('addBeforeUnloadEventBindings', () => {
it('should add beforeunload event listener', () => {
const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute });
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
addBeforeUnloadEventBindings();
expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
});
describe('removeBeforeUnloadEventBindings', () => {
it('should remove beforeunload event listener', () => {
const { removeBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute });
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
removeBeforeUnloadEventBindings();
expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
});
});

View file

@ -0,0 +1,48 @@
import { useCanvasStore } from '@/stores/canvas.store';
import { useUIStore } from '@/stores/ui.store';
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
import { VIEWS } from '@/constants';
import type { useRoute } from 'vue-router';
/**
* Composable to handle the beforeunload event in canvas views.
*
* This hook will prevent closing the tab and prompt the user if the ui state is dirty
* (workflow has changes) and the user tries to leave the page.
*/
export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute> }) {
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
const i18n = useI18n();
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
function onBeforeUnload(e: BeforeUnloadEvent) {
if (isDemoRoute.value || window.preventNodeViewBeforeUnload) {
return;
} else if (uiStore.stateIsDirty) {
e.returnValue = true; //Gecko + IE
return true; //Gecko + Webkit, Safari, Chrome etc.
} else {
canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
return;
}
}
function addBeforeUnloadEventBindings() {
window.addEventListener('beforeunload', onBeforeUnload);
}
function removeBeforeUnloadEventBindings() {
window.removeEventListener('beforeunload', onBeforeUnload);
}
return {
onBeforeUnload,
addBeforeUnloadEventBindings,
removeBeforeUnloadEventBindings,
};
}

View file

@ -3,8 +3,10 @@ import {
computed,
defineAsyncComponent,
nextTick,
onActivated,
onBeforeMount,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
useCssModule,
@ -92,6 +94,7 @@ import { useTemplatesStore } from '@/stores/templates.store';
import { createEventBus } from 'n8n-design-system';
import type { PinDataSource } from '@/composables/usePinnedData';
import { useClipboard } from '@/composables/useClipboard';
import { useBeforeUnload } from '@/composables/useBeforeUnload';
const LazyNodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -136,6 +139,9 @@ const templatesStore = useTemplatesStore();
const canvasEventBus = createEventBus();
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
route,
});
const { registerCustomAction } = useGlobalLinkActions();
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const {
@ -1445,6 +1451,10 @@ onMounted(async () => {
void externalHooks.run('nodeView.mount').catch(() => {});
});
onActivated(() => {
addBeforeUnloadEventBindings();
});
onBeforeUnmount(() => {
removeUndoRedoEventBindings();
removePostMessageEventBindings();
@ -1453,6 +1463,10 @@ onBeforeUnmount(() => {
removeExecutionOpenedEventBindings();
removeWorkflowSavedEventBindings();
});
onDeactivated(() => {
removeBeforeUnloadEventBindings();
});
</script>
<template>

View file

@ -327,7 +327,7 @@ import type {
NodeFilterType,
} from '@/Interface';
import { type RouteLocation, useRouter } from 'vue-router';
import { type RouteLocation, useRoute, useRouter } from 'vue-router';
import { dataPinningEventBus, nodeViewEventBus } from '@/event-bus';
import { useCanvasStore } from '@/stores/canvas.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -401,6 +401,7 @@ import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuard
import { usePostHog } from '@/stores/posthog.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { getResourcePermissions } from '@/permissions';
import { useBeforeUnload } from '@/composables/useBeforeUnload';
interface AddNodeOptions {
position?: XYPosition;
@ -437,6 +438,7 @@ export default defineComponent({
const nodeViewRef = ref<HTMLElement | null>(null);
const onMouseMoveEnd = ref<((e: MouseEvent | TouchEvent) => void) | null>(null);
const router = useRouter();
const route = useRoute();
const ndvStore = useNDVStore();
const externalHooks = useExternalHooks();
@ -452,6 +454,9 @@ export default defineComponent({
const canvasPanning = useCanvasPanning(nodeViewRootRef, { onMouseMoveEnd });
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow, stopCurrentExecution } = useRunWorkflow({ router });
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
route,
});
return {
locale,
@ -477,6 +482,8 @@ export default defineComponent({
...useMessage(),
...useUniqueNodeName(),
...useExecutionDebugging(),
addBeforeUnloadEventBindings,
removeBeforeUnloadEventBindings,
};
},
data() {
@ -510,7 +517,6 @@ export default defineComponent({
suspendRecordingDetachedConnections: false,
NODE_CREATOR_OPEN_SOURCES,
eventsAttached: false,
unloadTimeout: undefined as undefined | ReturnType<typeof setTimeout>,
canOpenNDV: true,
hideNodeIssues: false,
};
@ -926,13 +932,14 @@ export default defineComponent({
nodeViewEventBus.on('saveWorkflow', this.saveCurrentWorkflowExternal);
this.canvasStore.isDemo = this.isDemo;
this.addBeforeUnloadEventBindings();
},
deactivated() {
this.unbindCanvasEvents();
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
window.removeEventListener('message', this.onPostMessageReceived);
window.removeEventListener('beforeunload', this.onBeforeUnload);
window.removeEventListener('pageshow', this.onPageShow);
nodeViewEventBus.off('newWorkflow', this.newWorkflow);
@ -950,6 +957,8 @@ export default defineComponent({
dataPinningEventBus.off('pin-data', this.nodeHelpers.addPinDataConnections);
dataPinningEventBus.off('unpin-data', this.nodeHelpers.removePinDataConnections);
nodeViewEventBus.off('saveWorkflow', this.saveCurrentWorkflowExternal);
this.removeBeforeUnloadEventBindings();
},
beforeMount() {
if (!this.isDemo) {
@ -3524,22 +3533,6 @@ export default defineComponent({
this.eventsAttached = false;
},
onBeforeUnload(e: BeforeUnloadEvent) {
if (this.isDemo || window.preventNodeViewBeforeUnload) {
return;
} else if (this.uiStore.stateIsDirty) {
e.returnValue = true; //Gecko + IE
return true; //Gecko + Webkit, Safari, Chrome etc.
} else {
this.canvasStore.startLoading(this.$locale.baseText('nodeView.redirecting'));
return;
}
},
onUnload() {
// This will fire if users decides to leave the page after prompted
// Clear the interval to prevent the notification from being sent
clearTimeout(this.unloadTimeout);
},
makeNewWorkflowShareable() {
const { currentProject, personalProject } = this.projectsStore;
const homeProject = currentProject ?? personalProject ?? {};
@ -3674,8 +3667,6 @@ export default defineComponent({
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener('beforeunload', this.onBeforeUnload);
window.addEventListener('unload', this.onUnload);
// Once view is initialized, pick up all toast notifications
// waiting in the store and display them
this.showNotificationForViews([VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW]);