diff --git a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue index daabc0a1780bc..b8f5033e4de57 100644 --- a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue +++ b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue @@ -1,17 +1,22 @@ diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index de687771d8cf4..3684f6165919f 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -676,7 +676,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
- + { }); }); + describe('addBeforeUnloadHandler', () => { + it('should add additional handlers', () => { + const { addBeforeUnloadHandler, onBeforeUnload } = useBeforeUnload({ route: defaultRoute }); + const event = new Event('beforeunload'); + const handler = vi.fn(); + addBeforeUnloadHandler(handler); + onBeforeUnload(event); + expect(handler).toHaveBeenCalled(); + }); + }); + describe('addBeforeUnloadEventBindings', () => { it('should add beforeunload event listener', () => { const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute }); diff --git a/packages/editor-ui/src/composables/useBeforeUnload.ts b/packages/editor-ui/src/composables/useBeforeUnload.ts index 20095a217afa4..0fdc068a9d362 100644 --- a/packages/editor-ui/src/composables/useBeforeUnload.ts +++ b/packages/editor-ui/src/composables/useBeforeUnload.ts @@ -2,10 +2,8 @@ import { useCanvasStore } from '@/stores/canvas.store'; import { useUIStore } from '@/stores/ui.store'; import { useI18n } from '@/composables/useI18n'; import { computed, ref } from 'vue'; -import { TIME, VIEWS } from '@/constants'; +import { VIEWS } from '@/constants'; import type { useRoute } from 'vue-router'; -import { useCollaborationStore } from '@/stores/collaboration.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; /** * Composable to handle the beforeunload event in canvas views. @@ -17,42 +15,40 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; export function useBeforeUnload({ route }: { route: ReturnType }) { const uiStore = useUIStore(); const canvasStore = useCanvasStore(); - const collaborationStore = useCollaborationStore(); - const workflowsStore = useWorkflowsStore(); const i18n = useI18n(); const unloadTimeout = ref(null); const isDemoRoute = computed(() => route.name === VIEWS.DEMO); + type Handler = () => void; + const handlers: Handler[] = []; + function onBeforeUnload(e: BeforeUnloadEvent) { if (isDemoRoute.value || window.preventNodeViewBeforeUnload) { return; - } else if (uiStore.stateIsDirty) { - // A bit hacky solution to detecting users leaving the page after prompt: - // 1. Notify that workflow is closed straight away - collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); - // 2. If user decided to stay on the page we notify that the workflow is opened again - unloadTimeout.value = setTimeout(() => { - collaborationStore.notifyWorkflowOpened(workflowsStore.workflowId); - }, 5 * TIME.SECOND); + } + + handlers.forEach((handler) => handler()); + if (uiStore.stateIsDirty) { e.returnValue = true; //Gecko + IE return true; //Gecko + Webkit, Safari, Chrome etc. } else { canvasStore.startLoading(i18n.baseText('nodeView.redirecting')); - collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); return; } } + function addBeforeUnloadHandler(handler: () => void) { + handlers.push(handler); + } + function addBeforeUnloadEventBindings() { window.addEventListener('beforeunload', onBeforeUnload); } function removeBeforeUnloadEventBindings() { - collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); - if (unloadTimeout.value) { clearTimeout(unloadTimeout.value); } @@ -64,5 +60,6 @@ export function useBeforeUnload({ route }: { route: ReturnType onBeforeUnload, addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings, + addBeforeUnloadHandler, }; } diff --git a/packages/editor-ui/src/stores/collaboration.store.ts b/packages/editor-ui/src/stores/collaboration.store.ts index e9f4afe168496..9a433bdf9e0ac 100644 --- a/packages/editor-ui/src/stores/collaboration.store.ts +++ b/packages/editor-ui/src/stores/collaboration.store.ts @@ -1,10 +1,14 @@ import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; +import { useRoute } from 'vue-router'; + +import { STORES, PLACEHOLDER_EMPTY_WORKFLOW_ID, TIME } from '@/constants'; +import { useBeforeUnload } from '@/composables/useBeforeUnload'; +import type { IUser } from '@/Interface'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store'; -import { STORES } from '@/constants'; -import type { IUser } from '@/Interface'; import { useUsersStore } from '@/stores/users.store'; +import { useUIStore } from '@/stores/ui.store'; type ActiveUsersForWorkflows = { [workflowId: string]: Array<{ user: IUser; lastSeen: string }>; @@ -16,16 +20,46 @@ type ActiveUsersForWorkflows = { */ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { const pushStore = usePushConnectionStore(); - const workflowStore = useWorkflowsStore(); + const workflowsStore = useWorkflowsStore(); const usersStore = useUsersStore(); + const uiStore = useUIStore(); + + const route = useRoute(); + const { addBeforeUnloadHandler } = useBeforeUnload({ route }); + const unloadTimeout = ref(null); + addBeforeUnloadHandler(() => { + // Notify that workflow is closed straight away + notifyWorkflowClosed(); + if (uiStore.stateIsDirty) { + // If user decided to stay on the page we notify that the workflow is opened again + unloadTimeout.value = setTimeout(() => notifyWorkflowOpened, 5 * TIME.SECOND); + } + }); const usersForWorkflows = ref({}); const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null); const getUsersForCurrentWorkflow = computed(() => { - return usersForWorkflows.value[workflowStore.workflowId] ?? []; + return usersForWorkflows.value[workflowsStore.workflowId] ?? []; }); + const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE; + const heartbeatTimer = ref(null); + + const startHeartbeat = () => { + if (heartbeatTimer.value !== null) { + clearInterval(heartbeatTimer.value); + heartbeatTimer.value = null; + } + heartbeatTimer.value = window.setInterval(notifyWorkflowOpened, HEARTBEAT_INTERVAL); + }; + + const stopHeartbeat = () => { + if (heartbeatTimer.value !== null) { + clearInterval(heartbeatTimer.value); + } + }; + function initialize() { if (pushStoreEventListenerRemovalFn.value) { return; @@ -37,6 +71,9 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { usersForWorkflows.value[workflowId] = event.data.activeUsers; } }); + + notifyWorkflowOpened(); + startHeartbeat(); } function terminate() { @@ -44,10 +81,12 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { pushStoreEventListenerRemovalFn.value(); pushStoreEventListenerRemovalFn.value = null; } - } - - function workflowUsersUpdated(data: ActiveUsersForWorkflows) { - usersForWorkflows.value = data; + notifyWorkflowClosed(); + stopHeartbeat(); + pushStore.clearQueue(); + if (unloadTimeout.value) { + clearTimeout(unloadTimeout.value); + } } function functionRemoveCurrentUserFromActiveUsers(workflowId: string) { @@ -61,14 +100,18 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { ); } - function notifyWorkflowOpened(workflowId: string) { + function notifyWorkflowOpened() { + const { workflowId } = workflowsStore; + if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return; pushStore.send({ type: 'workflowOpened', workflowId, }); } - function notifyWorkflowClosed(workflowId: string) { + function notifyWorkflowClosed() { + const { workflowId } = workflowsStore; + if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return; pushStore.send({ type: 'workflowClosed', workflowId }); functionRemoveCurrentUserFromActiveUsers(workflowId); @@ -78,9 +121,8 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { usersForWorkflows, initialize, terminate, - notifyWorkflowOpened, - notifyWorkflowClosed, - workflowUsersUpdated, getUsersForCurrentWorkflow, + startHeartbeat, + stopHeartbeat, }; }); diff --git a/packages/editor-ui/src/stores/pushConnection.store.ts b/packages/editor-ui/src/stores/pushConnection.store.ts index eb6dcbc85d8ee..47594de22883c 100644 --- a/packages/editor-ui/src/stores/pushConnection.store.ts +++ b/packages/editor-ui/src/stores/pushConnection.store.ts @@ -150,6 +150,10 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => { onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData)); } + const clearQueue = () => { + outgoingQueue.value = []; + }; + return { pushRef, pushSource, @@ -159,5 +163,6 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => { pushConnect, pushDisconnect, send, + clearQueue, }; }); diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index d66d142e7358a..127a1c10c40f5 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -103,7 +103,6 @@ import { createEventBus } from 'n8n-design-system'; import type { PinDataSource } from '@/composables/usePinnedData'; import { useClipboard } from '@/composables/useClipboard'; import { useBeforeUnload } from '@/composables/useBeforeUnload'; -import { useCollaborationStore } from '@/stores/collaboration.store'; import { getResourcePermissions } from '@/permissions'; import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue'; @@ -137,7 +136,6 @@ const credentialsStore = useCredentialsStore(); const environmentsStore = useEnvironmentsStore(); const externalSecretsStore = useExternalSecretsStore(); const rootStore = useRootStore(); -const collaborationStore = useCollaborationStore(); const executionsStore = useExecutionsStore(); const canvasStore = useCanvasStore(); const npsSurveyStore = useNpsSurveyStore(); @@ -353,8 +351,6 @@ async function initializeWorkspaceForExistingWorkflow(id: string) { await projectsStore.setProjectNavActiveIdByWorkflowHomeProject( editableWorkflow.value.homeProject, ); - - collaborationStore.notifyWorkflowOpened(id); } catch (error) { toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError')); @@ -1482,7 +1478,6 @@ watch( onBeforeMount(() => { if (!isDemoRoute.value) { pushConnectionStore.pushConnect(); - collaborationStore.initialize(); } }); @@ -1537,7 +1532,6 @@ onBeforeUnmount(() => { removeExecutionOpenedEventBindings(); unregisterCustomActions(); if (!isDemoRoute.value) { - collaborationStore.terminate(); pushConnectionStore.pushDisconnect(); } }); diff --git a/packages/editor-ui/src/views/NodeViewSwitcher.vue b/packages/editor-ui/src/views/NodeViewSwitcher.vue index 190b02680af55..adcc58fc98aff 100644 --- a/packages/editor-ui/src/views/NodeViewSwitcher.vue +++ b/packages/editor-ui/src/views/NodeViewSwitcher.vue @@ -8,7 +8,6 @@ import { getNodeViewTab } from '@/utils/canvasUtils'; import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -20,8 +19,6 @@ const router = useRouter(); const route = useRoute(); const workflowHelpers = useWorkflowHelpers({ router }); -const { resetWorkspace } = useCanvasOperations({ router }); - const nodeViewVersion = useLocalStorage( 'NodeView.version', settingsStore.deploymentType === 'n8n-internal' ? '2' : '1', @@ -56,9 +53,6 @@ onBeforeRouteLeave(async (to, from, next) => { await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, { async confirm() { - // Make sure workflow id is empty when leaving the editor - workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - if (from.name === VIEWS.NEW_WORKFLOW) { // Replace the current route with the new workflow route // before navigating to the new route when saving new workflow. @@ -72,11 +66,10 @@ onBeforeRouteLeave(async (to, from, next) => { return false; } - return true; - }, - async cancel() { + // Make sure workflow id is empty when leaving the editor workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - resetWorkspace(); + + return true; }, }); });