diff --git a/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts index 4435645d7a..ba3e4a1ff4 100644 --- a/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts +++ b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts @@ -27,13 +27,13 @@ describe('CollaborationState', () => { const workflowId = 'workflow'; - describe('addActiveWorkflowUser', () => { + describe('addCollaborator', () => { it('should add workflow user with correct cache key and value', async () => { // Arrange global.Date = mockDateFactory('2023-01-01T00:00:00.000Z'); // Act - await collaborationState.addActiveWorkflowUser(workflowId, 'userId'); + await collaborationState.addCollaborator(workflowId, 'userId'); // Assert expect(mockCacheService.setHash).toHaveBeenCalledWith('collaboration:workflow', { @@ -42,10 +42,10 @@ describe('CollaborationState', () => { }); }); - describe('removeActiveWorkflowUser', () => { + describe('removeCollaborator', () => { it('should remove workflow user with correct cache key', async () => { // Act - await collaborationState.removeActiveWorkflowUser(workflowId, 'userId'); + await collaborationState.removeCollaborator(workflowId, 'userId'); // Assert expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith( @@ -55,10 +55,10 @@ describe('CollaborationState', () => { }); }); - describe('getActiveWorkflowUsers', () => { + describe('getCollaborators', () => { it('should get workflows with correct cache key', async () => { // Act - const users = await collaborationState.getActiveWorkflowUsers(workflowId); + const users = await collaborationState.getCollaborators(workflowId); // Assert expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow'); @@ -77,7 +77,7 @@ describe('CollaborationState', () => { }); // Act - const users = await collaborationState.getActiveWorkflowUsers(workflowId); + const users = await collaborationState.getCollaborators(workflowId); // Assert expect(users).toEqual([ diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts index 775d3791fc..67cd55a518 100644 --- a/packages/cli/src/collaboration/collaboration.service.ts +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -1,16 +1,17 @@ -import type { Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; -import { Push } from '../push'; -import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message'; -import { parseWorkflowMessage } from './collaboration.message'; -import type { IActiveWorkflowUsersChanged } from '../interfaces'; +import type { Workflow } from 'n8n-workflow'; +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; + +import { Push } from '@/push'; +import type { ICollaboratorsChanged } from '@/interfaces'; import type { OnPushMessage } from '@/push/types'; import { UserRepository } from '@/databases/repositories/user.repository'; import type { User } from '@/databases/entities/user'; -import { CollaborationState } from '@/collaboration/collaboration.state'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { UserService } from '@/services/user.service'; -import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; + +import { CollaborationState } from './collaboration.state'; +import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message'; +import { parseWorkflowMessage } from './collaboration.message'; /** * Service for managing collaboration feature between users. E.g. keeping @@ -22,7 +23,6 @@ export class CollaborationService { private readonly push: Push, private readonly state: CollaborationState, private readonly userRepository: UserRepository, - private readonly userService: UserService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, ) {} @@ -61,7 +61,7 @@ export class CollaborationService { return; } - await this.state.addActiveWorkflowUser(workflowId, userId); + await this.state.addCollaborator(workflowId, userId); await this.sendWorkflowUsersChangedMessage(workflowId); } @@ -73,7 +73,7 @@ export class CollaborationService { return; } - await this.state.removeActiveWorkflowUser(workflowId, userId); + await this.state.removeCollaborator(workflowId, userId); await this.sendWorkflowUsersChangedMessage(workflowId); } @@ -81,26 +81,23 @@ export class CollaborationService { private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) { // We have already validated that all active workflow users // have proper access to the workflow, so we don't need to validate it again - const activeWorkflowUsers = await this.state.getActiveWorkflowUsers(workflowId); - const workflowUserIds = activeWorkflowUsers.map((user) => user.userId); + const collaborators = await this.state.getCollaborators(workflowId); + const userIds = collaborators.map((user) => user.userId); - if (workflowUserIds.length === 0) { + if (userIds.length === 0) { return; } - const users = await this.userRepository.getByIds(this.userRepository.manager, workflowUserIds); - - const msgData: IActiveWorkflowUsersChanged = { + const users = await this.userRepository.getByIds(this.userRepository.manager, userIds); + const activeCollaborators = users.map((user) => ({ + user: user.toIUser(), + lastSeen: collaborators.find(({ userId }) => userId === user.id)!.lastSeen, + })); + const msgData: ICollaboratorsChanged = { workflowId, - activeUsers: await Promise.all( - users.map(async (user) => ({ - user: await this.userService.toPublic(user), - lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)! - .lastSeen, - })), - ), + collaborators: activeCollaborators, }; - this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds); + this.push.sendToUsers('collaboratorsChanged', msgData, userIds); } private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) { diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts index d110bf20dd..f6b9549460 100644 --- a/packages/cli/src/collaboration/collaboration.state.ts +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -1,12 +1,16 @@ -import type { ActiveWorkflowUser } from '@/collaboration/collaboration.types'; +import { Service } from 'typedi'; +import type { Workflow } from 'n8n-workflow'; + import { Time } from '@/constants'; import type { Iso8601DateTimeString } from '@/interfaces'; import { CacheService } from '@/services/cache/cache.service'; import type { User } from '@/databases/entities/user'; -import { type Workflow } from 'n8n-workflow'; -import { Service } from 'typedi'; type WorkflowCacheHash = Record; +interface CacheEntry { + userId: string; + lastSeen: string; +} /** * State management for the collaboration service. Workflow active @@ -30,7 +34,7 @@ export class CollaborationState { /** * Mark user active for given workflow */ - async addActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) { + async addCollaborator(workflowId: Workflow['id'], userId: User['id']) { const cacheKey = this.formWorkflowCacheKey(workflowId); const cacheEntry: WorkflowCacheHash = { [userId]: new Date().toISOString(), @@ -42,13 +46,13 @@ export class CollaborationState { /** * Remove user from workflow's active users */ - async removeActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) { + async removeCollaborator(workflowId: Workflow['id'], userId: User['id']) { const cacheKey = this.formWorkflowCacheKey(workflowId); await this.cache.deleteFromHash(cacheKey, userId); } - async getActiveWorkflowUsers(workflowId: Workflow['id']): Promise { + async getCollaborators(workflowId: Workflow['id']): Promise { const cacheKey = this.formWorkflowCacheKey(workflowId); const cacheValue = await this.cache.getHash(cacheKey); @@ -56,11 +60,11 @@ export class CollaborationState { return []; } - const workflowActiveUsers = this.cacheHashToWorkflowActiveUsers(cacheValue); - const [expired, stillActive] = this.splitToExpiredAndStillActive(workflowActiveUsers); + const activeCollaborators = this.cacheHashToCollaborators(cacheValue); + const [expired, stillActive] = this.splitToExpiredAndStillActive(activeCollaborators); if (expired.length > 0) { - void this.removeExpiredUsersForWorkflow(workflowId, expired); + void this.removeExpiredCollaborators(workflowId, expired); } return stillActive; @@ -70,39 +74,36 @@ export class CollaborationState { return `collaboration:${workflowId}`; } - private splitToExpiredAndStillActive(workflowUsers: ActiveWorkflowUser[]) { - const expired: ActiveWorkflowUser[] = []; - const stillActive: ActiveWorkflowUser[] = []; + private splitToExpiredAndStillActive(collaborators: CacheEntry[]) { + const expired: CacheEntry[] = []; + const stillActive: CacheEntry[] = []; - for (const user of workflowUsers) { - if (this.hasUserExpired(user.lastSeen)) { - expired.push(user); + for (const collaborator of collaborators) { + if (this.hasSessionExpired(collaborator.lastSeen)) { + expired.push(collaborator); } else { - stillActive.push(user); + stillActive.push(collaborator); } } return [expired, stillActive]; } - private async removeExpiredUsersForWorkflow( - workflowId: Workflow['id'], - expiredUsers: ActiveWorkflowUser[], - ) { + private async removeExpiredCollaborators(workflowId: Workflow['id'], expiredUsers: CacheEntry[]) { const cacheKey = this.formWorkflowCacheKey(workflowId); await Promise.all( expiredUsers.map(async (user) => await this.cache.deleteFromHash(cacheKey, user.userId)), ); } - private cacheHashToWorkflowActiveUsers(workflowCacheEntry: WorkflowCacheHash) { + private cacheHashToCollaborators(workflowCacheEntry: WorkflowCacheHash): CacheEntry[] { return Object.entries(workflowCacheEntry).map(([userId, lastSeen]) => ({ userId, lastSeen, })); } - private hasUserExpired(lastSeenString: Iso8601DateTimeString) { + private hasSessionExpired(lastSeenString: Iso8601DateTimeString) { const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime; return Date.now() > expiryTime; diff --git a/packages/cli/src/collaboration/collaboration.types.ts b/packages/cli/src/collaboration/collaboration.types.ts deleted file mode 100644 index d2a0591395..0000000000 --- a/packages/cli/src/collaboration/collaboration.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Iso8601DateTimeString } from '@/interfaces'; -import type { User } from '@/databases/entities/user'; - -export type ActiveWorkflowUser = { - userId: User['id']; - lastSeen: Iso8601DateTimeString; -}; diff --git a/packages/cli/src/databases/entities/user.ts b/packages/cli/src/databases/entities/user.ts index 8a69f2f9dc..956bebd1b2 100644 --- a/packages/cli/src/databases/entities/user.ts +++ b/packages/cli/src/databases/entities/user.ts @@ -162,4 +162,9 @@ export class User extends WithTimestamps implements IUser { return 'Unnamed Project'; } } + + toIUser(): IUser { + const { id, email, firstName, lastName } = this; + return { id, email, firstName, lastName }; + } } diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index b594332344..beb3c75497 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -21,6 +21,7 @@ import type { INodeProperties, IUserSettings, IWorkflowExecutionDataProcess, + IUser, } from 'n8n-workflow'; import type { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -289,11 +290,11 @@ export type IPushData = | PushDataWorkflowActivated | PushDataWorkflowDeactivated | PushDataWorkflowFailedToActivate - | PushDataActiveWorkflowUsersChanged; + | PushDataCollaboratorsChanged; -type PushDataActiveWorkflowUsersChanged = { - data: IActiveWorkflowUsersChanged; - type: 'activeWorkflowUsersChanged'; +type PushDataCollaboratorsChanged = { + data: ICollaboratorsChanged; + type: 'collaboratorsChanged'; }; type PushDataWorkflowFailedToActivate = { @@ -369,14 +370,14 @@ export type PushDataNodeDescriptionUpdated = { /** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */ export type Iso8601DateTimeString = string; -export interface IActiveWorkflowUser { - user: PublicUser; +export interface ICollaborator { + user: IUser; lastSeen: Iso8601DateTimeString; } -export interface IActiveWorkflowUsersChanged { +export interface ICollaboratorsChanged { workflowId: Workflow['id']; - activeUsers: IActiveWorkflowUser[]; + collaborators: ICollaborator[]; } export interface IActiveWorkflowAdded { diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts index 81b00dc866..33f7d81607 100644 --- a/packages/cli/test/integration/collaboration/collaboration.service.test.ts +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -1,19 +1,20 @@ +import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; + +import type { User } from '@/databases/entities/user'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { Push } from '@/push'; import { CacheService } from '@/services/cache/cache.service'; -import { mock } from 'jest-mock-extended'; -import * as testDb from '../shared/test-db'; -import Container from 'typedi'; -import type { User } from '@/databases/entities/user'; -import { createMember, createOwner } from '@test-integration/db/users'; import type { WorkflowClosedMessage, WorkflowOpenedMessage, } from '@/collaboration/collaboration.message'; -import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; -import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; + import { mockInstance } from '@test/mocking'; -import { UserService } from '@/services/user.service'; +import * as testDb from '@test-integration/test-db'; +import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; +import { createMember, createOwner } from '@test-integration/db/users'; describe('CollaborationService', () => { mockInstance(Push, new Push(mock())); @@ -23,7 +24,6 @@ describe('CollaborationService', () => { let memberWithoutAccess: User; let memberWithAccess: User; let workflow: WorkflowEntity; - let userService: UserService; let cacheService: CacheService; beforeAll(async () => { @@ -31,7 +31,6 @@ describe('CollaborationService', () => { pushService = Container.get(Push); collaborationService = Container.get(CollaborationService); - userService = Container.get(UserService); cacheService = Container.get(CacheService); await cacheService.init(); @@ -69,7 +68,7 @@ describe('CollaborationService', () => { }; describe('workflow opened message', () => { - it('should emit activeWorkflowUsersChanged after workflowOpened', async () => { + it('should emit collaboratorsChanged after workflowOpened', async () => { // Arrange const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); @@ -80,15 +79,12 @@ describe('CollaborationService', () => { // Assert expect(sendToUsersSpy).toHaveBeenNthCalledWith( 1, - 'activeWorkflowUsersChanged', + 'collaboratorsChanged', { - activeUsers: [ + collaborators: [ { lastSeen: expect.any(String), - user: { - ...(await userService.toPublic(owner)), - isPending: false, - }, + user: owner.toIUser(), }, ], workflowId: workflow.id, @@ -97,9 +93,9 @@ describe('CollaborationService', () => { ); expect(sendToUsersSpy).toHaveBeenNthCalledWith( 2, - 'activeWorkflowUsersChanged', + 'collaboratorsChanged', { - activeUsers: expect.arrayContaining([ + collaborators: expect.arrayContaining([ expect.objectContaining({ lastSeen: expect.any(String), user: expect.objectContaining({ @@ -119,7 +115,7 @@ describe('CollaborationService', () => { ); }); - it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => { + it("should not emit collaboratorsChanged if user don't have access to the workflow", async () => { const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); // Act @@ -131,7 +127,7 @@ describe('CollaborationService', () => { }); describe('workflow closed message', () => { - it('should not emit activeWorkflowUsersChanged after workflowClosed when there are no active users', async () => { + it('should not emit collaboratorsChanged after workflowClosed when there are no active users', async () => { // Arrange const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); await sendWorkflowOpenedMessage(workflow.id, owner.id); @@ -144,7 +140,7 @@ describe('CollaborationService', () => { expect(sendToUsersSpy).not.toHaveBeenCalled(); }); - it('should emit activeWorkflowUsersChanged after workflowClosed when there are active users', async () => { + it('should emit collaboratorsChanged after workflowClosed when there are active users', async () => { // Arrange const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); await sendWorkflowOpenedMessage(workflow.id, owner.id); @@ -156,9 +152,9 @@ describe('CollaborationService', () => { // Assert expect(sendToUsersSpy).toHaveBeenCalledWith( - 'activeWorkflowUsersChanged', + 'collaboratorsChanged', { - activeUsers: expect.arrayContaining([ + collaborators: expect.arrayContaining([ expect.objectContaining({ lastSeen: expect.any(String), user: expect.objectContaining({ @@ -172,7 +168,7 @@ describe('CollaborationService', () => { ); }); - it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => { + it("should not emit collaboratorsChanged if user don't have access to the workflow", async () => { // Arrange const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); await sendWorkflowOpenedMessage(workflow.id, owner.id); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 0f6f7a927e..e4d809f98a 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -422,14 +422,19 @@ export interface IExecutionDeleteFilter { ids?: string[]; } -export type PushDataUsersForWorkflow = { +export interface Collaborator { + user: IUser; + lastSeen: string; +} + +export type PushDataCollaborators = { workflowId: string; - activeUsers: Array<{ user: IUser; lastSeen: string }>; + collaborators: Collaborator[]; }; -type PushDataWorkflowUsersChanged = { - data: PushDataUsersForWorkflow; - type: 'activeWorkflowUsersChanged'; +type PushDataCollaboratorsChanged = { + data: PushDataCollaborators; + type: 'collaboratorsChanged'; }; export type IPushData = @@ -446,7 +451,7 @@ export type IPushData = | PushDataWorkerStatusMessage | PushDataActiveWorkflowAdded | PushDataActiveWorkflowRemoved - | PushDataWorkflowUsersChanged + | PushDataCollaboratorsChanged | PushDataWorkflowFailedToActivate; export type PushDataActiveWorkflowAdded = { diff --git a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue index daabc0a178..83230acd6c 100644 --- a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue +++ b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue @@ -1,22 +1,25 @@ @@ -71,7 +44,7 @@ onBeforeUnmount(() => { :class="`collaboration-pane-container ${$style.container}`" data-test-id="collaboration-pane" > - + diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 36d18eae1c..0b8f61326e 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -668,7 +668,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 20095a217a..0fdc068a9d 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 e9f4afe168..6eea3094a8 100644 --- a/packages/editor-ui/src/stores/collaboration.store.ts +++ b/packages/editor-ui/src/stores/collaboration.store.ts @@ -1,14 +1,16 @@ import { defineStore } from 'pinia'; -import { computed, ref } from 'vue'; +import { ref } from 'vue'; +import { useRoute } from 'vue-router'; + +import { STORES, PLACEHOLDER_EMPTY_WORKFLOW_ID, TIME } from '@/constants'; +import { useBeforeUnload } from '@/composables/useBeforeUnload'; +import type { Collaborator } 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 }>; -}; +const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE; /** * Store for tracking active users for workflows. I.e. to show @@ -16,27 +18,59 @@ type ActiveUsersForWorkflows = { */ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { const pushStore = usePushConnectionStore(); - const workflowStore = useWorkflowsStore(); + const workflowsStore = useWorkflowsStore(); const usersStore = useUsersStore(); + const uiStore = useUIStore(); - const usersForWorkflows = ref({}); - const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null); + const route = useRoute(); + const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings, addBeforeUnloadHandler } = + useBeforeUnload({ route }); + const unloadTimeout = ref(null); - const getUsersForCurrentWorkflow = computed(() => { - return usersForWorkflows.value[workflowStore.workflowId] ?? []; + 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 collaborators = ref([]); + + const heartbeatTimer = ref(null); + + const startHeartbeat = () => { + stopHeartbeat(); + heartbeatTimer.value = window.setInterval(notifyWorkflowOpened, HEARTBEAT_INTERVAL); + }; + + const stopHeartbeat = () => { + if (heartbeatTimer.value !== null) { + clearInterval(heartbeatTimer.value); + heartbeatTimer.value = null; + } + }; + + const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null); + function initialize() { if (pushStoreEventListenerRemovalFn.value) { return; } pushStoreEventListenerRemovalFn.value = pushStore.addEventListener((event) => { - if (event.type === 'activeWorkflowUsersChanged') { - const workflowId = event.data.workflowId; - usersForWorkflows.value[workflowId] = event.data.activeUsers; + if ( + event.type === 'collaboratorsChanged' && + event.data.workflowId === workflowsStore.workflowId + ) { + collaborators.value = event.data.collaborators; } }); + + addBeforeUnloadEventBindings(); + notifyWorkflowOpened(); + startHeartbeat(); } function terminate() { @@ -44,43 +78,36 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { pushStoreEventListenerRemovalFn.value(); pushStoreEventListenerRemovalFn.value = null; } - } - - function workflowUsersUpdated(data: ActiveUsersForWorkflows) { - usersForWorkflows.value = data; - } - - function functionRemoveCurrentUserFromActiveUsers(workflowId: string) { - const workflowUsers = usersForWorkflows.value[workflowId]; - if (!workflowUsers) { - return; + notifyWorkflowClosed(); + stopHeartbeat(); + pushStore.clearQueue(); + removeBeforeUnloadEventBindings(); + if (unloadTimeout.value) { + clearTimeout(unloadTimeout.value); } + } - usersForWorkflows.value[workflowId] = workflowUsers.filter( - (activeUser) => activeUser.user.id !== usersStore.currentUserId, + function notifyWorkflowOpened() { + const { workflowId } = workflowsStore; + if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return; + pushStore.send({ type: 'workflowOpened', workflowId }); + } + + function notifyWorkflowClosed() { + const { workflowId } = workflowsStore; + if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return; + pushStore.send({ type: 'workflowClosed', workflowId }); + + collaborators.value = collaborators.value.filter( + ({ user }) => user.id !== usersStore.currentUserId, ); } - function notifyWorkflowOpened(workflowId: string) { - pushStore.send({ - type: 'workflowOpened', - workflowId, - }); - } - - function notifyWorkflowClosed(workflowId: string) { - pushStore.send({ type: 'workflowClosed', workflowId }); - - functionRemoveCurrentUserFromActiveUsers(workflowId); - } - return { - usersForWorkflows, + collaborators, 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 eb6dcbc85d..47594de228 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 d66d142e73..127a1c10c4 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 190b02680a..adcc58fc98 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; }, }); });