diff --git a/packages/editor-ui/src/composables/useBeforeUnload.spec.ts b/packages/editor-ui/src/composables/useBeforeUnload.spec.ts new file mode 100644 index 0000000000..725b6fe1f2 --- /dev/null +++ b/packages/editor-ui/src/composables/useBeforeUnload.spec.ts @@ -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>({ name: 'someRoute' }); + + let uiStore: ReturnType; + let canvasStore: ReturnType; + + 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>({ 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)); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useBeforeUnload.ts b/packages/editor-ui/src/composables/useBeforeUnload.ts new file mode 100644 index 0000000000..5469c43ee8 --- /dev/null +++ b/packages/editor-ui/src/composables/useBeforeUnload.ts @@ -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 }) { + 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, + }; +} diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 3a8e2411cd..b1c1462bae 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -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(); +});