mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Prevent unloading when changes are pending in new canvas (no-changelog) (#10474)
This commit is contained in:
parent
e936494e3d
commit
6d82fb9fc0
86
packages/editor-ui/src/composables/useBeforeUnload.spec.ts
Normal file
86
packages/editor-ui/src/composables/useBeforeUnload.spec.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
48
packages/editor-ui/src/composables/useBeforeUnload.ts
Normal file
48
packages/editor-ui/src/composables/useBeforeUnload.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -3,8 +3,10 @@ import {
|
||||||
computed,
|
computed,
|
||||||
defineAsyncComponent,
|
defineAsyncComponent,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
onBeforeMount,
|
onBeforeMount,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
ref,
|
ref,
|
||||||
useCssModule,
|
useCssModule,
|
||||||
|
@ -92,6 +94,7 @@ import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
|
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||||
|
|
||||||
const LazyNodeCreation = defineAsyncComponent(
|
const LazyNodeCreation = defineAsyncComponent(
|
||||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||||
|
@ -136,6 +139,9 @@ const templatesStore = useTemplatesStore();
|
||||||
|
|
||||||
const canvasEventBus = createEventBus();
|
const canvasEventBus = createEventBus();
|
||||||
|
|
||||||
|
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
|
||||||
|
route,
|
||||||
|
});
|
||||||
const { registerCustomAction } = useGlobalLinkActions();
|
const { registerCustomAction } = useGlobalLinkActions();
|
||||||
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
|
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
|
||||||
const {
|
const {
|
||||||
|
@ -1445,6 +1451,10 @@ onMounted(async () => {
|
||||||
void externalHooks.run('nodeView.mount').catch(() => {});
|
void externalHooks.run('nodeView.mount').catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
addBeforeUnloadEventBindings();
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
removeUndoRedoEventBindings();
|
removeUndoRedoEventBindings();
|
||||||
removePostMessageEventBindings();
|
removePostMessageEventBindings();
|
||||||
|
@ -1453,6 +1463,10 @@ onBeforeUnmount(() => {
|
||||||
removeExecutionOpenedEventBindings();
|
removeExecutionOpenedEventBindings();
|
||||||
removeWorkflowSavedEventBindings();
|
removeWorkflowSavedEventBindings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
removeBeforeUnloadEventBindings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -327,7 +327,7 @@ import type {
|
||||||
NodeFilterType,
|
NodeFilterType,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
|
||||||
import { type RouteLocation, useRouter } from 'vue-router';
|
import { type RouteLocation, useRoute, useRouter } from 'vue-router';
|
||||||
import { dataPinningEventBus, nodeViewEventBus } from '@/event-bus';
|
import { dataPinningEventBus, nodeViewEventBus } from '@/event-bus';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
@ -401,6 +401,7 @@ import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuard
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||||
|
|
||||||
interface AddNodeOptions {
|
interface AddNodeOptions {
|
||||||
position?: XYPosition;
|
position?: XYPosition;
|
||||||
|
@ -437,6 +438,7 @@ export default defineComponent({
|
||||||
const nodeViewRef = ref<HTMLElement | null>(null);
|
const nodeViewRef = ref<HTMLElement | null>(null);
|
||||||
const onMouseMoveEnd = ref<((e: MouseEvent | TouchEvent) => void) | null>(null);
|
const onMouseMoveEnd = ref<((e: MouseEvent | TouchEvent) => void) | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
|
@ -452,6 +454,9 @@ export default defineComponent({
|
||||||
const canvasPanning = useCanvasPanning(nodeViewRootRef, { onMouseMoveEnd });
|
const canvasPanning = useCanvasPanning(nodeViewRootRef, { onMouseMoveEnd });
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
const { runWorkflow, stopCurrentExecution } = useRunWorkflow({ router });
|
const { runWorkflow, stopCurrentExecution } = useRunWorkflow({ router });
|
||||||
|
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings } = useBeforeUnload({
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
|
@ -477,6 +482,8 @@ export default defineComponent({
|
||||||
...useMessage(),
|
...useMessage(),
|
||||||
...useUniqueNodeName(),
|
...useUniqueNodeName(),
|
||||||
...useExecutionDebugging(),
|
...useExecutionDebugging(),
|
||||||
|
addBeforeUnloadEventBindings,
|
||||||
|
removeBeforeUnloadEventBindings,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -510,7 +517,6 @@ export default defineComponent({
|
||||||
suspendRecordingDetachedConnections: false,
|
suspendRecordingDetachedConnections: false,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
eventsAttached: false,
|
eventsAttached: false,
|
||||||
unloadTimeout: undefined as undefined | ReturnType<typeof setTimeout>,
|
|
||||||
canOpenNDV: true,
|
canOpenNDV: true,
|
||||||
hideNodeIssues: false,
|
hideNodeIssues: false,
|
||||||
};
|
};
|
||||||
|
@ -926,13 +932,14 @@ export default defineComponent({
|
||||||
nodeViewEventBus.on('saveWorkflow', this.saveCurrentWorkflowExternal);
|
nodeViewEventBus.on('saveWorkflow', this.saveCurrentWorkflowExternal);
|
||||||
|
|
||||||
this.canvasStore.isDemo = this.isDemo;
|
this.canvasStore.isDemo = this.isDemo;
|
||||||
|
|
||||||
|
this.addBeforeUnloadEventBindings();
|
||||||
},
|
},
|
||||||
deactivated() {
|
deactivated() {
|
||||||
this.unbindCanvasEvents();
|
this.unbindCanvasEvents();
|
||||||
document.removeEventListener('keydown', this.keyDown);
|
document.removeEventListener('keydown', this.keyDown);
|
||||||
document.removeEventListener('keyup', this.keyUp);
|
document.removeEventListener('keyup', this.keyUp);
|
||||||
window.removeEventListener('message', this.onPostMessageReceived);
|
window.removeEventListener('message', this.onPostMessageReceived);
|
||||||
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
|
||||||
window.removeEventListener('pageshow', this.onPageShow);
|
window.removeEventListener('pageshow', this.onPageShow);
|
||||||
|
|
||||||
nodeViewEventBus.off('newWorkflow', this.newWorkflow);
|
nodeViewEventBus.off('newWorkflow', this.newWorkflow);
|
||||||
|
@ -950,6 +957,8 @@ export default defineComponent({
|
||||||
dataPinningEventBus.off('pin-data', this.nodeHelpers.addPinDataConnections);
|
dataPinningEventBus.off('pin-data', this.nodeHelpers.addPinDataConnections);
|
||||||
dataPinningEventBus.off('unpin-data', this.nodeHelpers.removePinDataConnections);
|
dataPinningEventBus.off('unpin-data', this.nodeHelpers.removePinDataConnections);
|
||||||
nodeViewEventBus.off('saveWorkflow', this.saveCurrentWorkflowExternal);
|
nodeViewEventBus.off('saveWorkflow', this.saveCurrentWorkflowExternal);
|
||||||
|
|
||||||
|
this.removeBeforeUnloadEventBindings();
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
if (!this.isDemo) {
|
if (!this.isDemo) {
|
||||||
|
@ -3524,22 +3533,6 @@ export default defineComponent({
|
||||||
|
|
||||||
this.eventsAttached = false;
|
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() {
|
makeNewWorkflowShareable() {
|
||||||
const { currentProject, personalProject } = this.projectsStore;
|
const { currentProject, personalProject } = this.projectsStore;
|
||||||
const homeProject = currentProject ?? personalProject ?? {};
|
const homeProject = currentProject ?? personalProject ?? {};
|
||||||
|
@ -3674,8 +3667,6 @@ export default defineComponent({
|
||||||
document.addEventListener('keydown', this.keyDown);
|
document.addEventListener('keydown', this.keyDown);
|
||||||
document.addEventListener('keyup', this.keyUp);
|
document.addEventListener('keyup', this.keyUp);
|
||||||
|
|
||||||
window.addEventListener('beforeunload', this.onBeforeUnload);
|
|
||||||
window.addEventListener('unload', this.onUnload);
|
|
||||||
// Once view is initialized, pick up all toast notifications
|
// Once view is initialized, pick up all toast notifications
|
||||||
// waiting in the store and display them
|
// waiting in the store and display them
|
||||||
this.showNotificationForViews([VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW]);
|
this.showNotificationForViews([VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW]);
|
||||||
|
|
Loading…
Reference in a new issue