refactor(editor): Enable collaboration features only in NodeView v2 (no-changelog) (#10756)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-09-11 14:22:55 +02:00 committed by GitHub
parent ee5fbc543c
commit a1e011dd2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 221 additions and 226 deletions

View file

@ -27,13 +27,13 @@ describe('CollaborationState', () => {
const workflowId = 'workflow'; const workflowId = 'workflow';
describe('addActiveWorkflowUser', () => { describe('addCollaborator', () => {
it('should add workflow user with correct cache key and value', async () => { it('should add workflow user with correct cache key and value', async () => {
// Arrange // Arrange
global.Date = mockDateFactory('2023-01-01T00:00:00.000Z'); global.Date = mockDateFactory('2023-01-01T00:00:00.000Z');
// Act // Act
await collaborationState.addActiveWorkflowUser(workflowId, 'userId'); await collaborationState.addCollaborator(workflowId, 'userId');
// Assert // Assert
expect(mockCacheService.setHash).toHaveBeenCalledWith('collaboration:workflow', { 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 () => { it('should remove workflow user with correct cache key', async () => {
// Act // Act
await collaborationState.removeActiveWorkflowUser(workflowId, 'userId'); await collaborationState.removeCollaborator(workflowId, 'userId');
// Assert // Assert
expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith( expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith(
@ -55,10 +55,10 @@ describe('CollaborationState', () => {
}); });
}); });
describe('getActiveWorkflowUsers', () => { describe('getCollaborators', () => {
it('should get workflows with correct cache key', async () => { it('should get workflows with correct cache key', async () => {
// Act // Act
const users = await collaborationState.getActiveWorkflowUsers(workflowId); const users = await collaborationState.getCollaborators(workflowId);
// Assert // Assert
expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow'); expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow');
@ -77,7 +77,7 @@ describe('CollaborationState', () => {
}); });
// Act // Act
const users = await collaborationState.getActiveWorkflowUsers(workflowId); const users = await collaborationState.getCollaborators(workflowId);
// Assert // Assert
expect(users).toEqual([ expect(users).toEqual([

View file

@ -1,16 +1,17 @@
import type { Workflow } from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { Push } from '../push'; import type { Workflow } from 'n8n-workflow';
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message'; import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { parseWorkflowMessage } from './collaboration.message';
import type { IActiveWorkflowUsersChanged } from '../interfaces'; import { Push } from '@/push';
import type { ICollaboratorsChanged } from '@/interfaces';
import type { OnPushMessage } from '@/push/types'; import type { OnPushMessage } from '@/push/types';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CollaborationState } from '@/collaboration/collaboration.state';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; 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 * Service for managing collaboration feature between users. E.g. keeping
@ -22,7 +23,6 @@ export class CollaborationService {
private readonly push: Push, private readonly push: Push,
private readonly state: CollaborationState, private readonly state: CollaborationState,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly userService: UserService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {} ) {}
@ -61,7 +61,7 @@ export class CollaborationService {
return; return;
} }
await this.state.addActiveWorkflowUser(workflowId, userId); await this.state.addCollaborator(workflowId, userId);
await this.sendWorkflowUsersChangedMessage(workflowId); await this.sendWorkflowUsersChangedMessage(workflowId);
} }
@ -73,7 +73,7 @@ export class CollaborationService {
return; return;
} }
await this.state.removeActiveWorkflowUser(workflowId, userId); await this.state.removeCollaborator(workflowId, userId);
await this.sendWorkflowUsersChangedMessage(workflowId); await this.sendWorkflowUsersChangedMessage(workflowId);
} }
@ -81,26 +81,23 @@ export class CollaborationService {
private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) { private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) {
// We have already validated that all active workflow users // We have already validated that all active workflow users
// have proper access to the workflow, so we don't need to validate it again // have proper access to the workflow, so we don't need to validate it again
const activeWorkflowUsers = await this.state.getActiveWorkflowUsers(workflowId); const collaborators = await this.state.getCollaborators(workflowId);
const workflowUserIds = activeWorkflowUsers.map((user) => user.userId); const userIds = collaborators.map((user) => user.userId);
if (workflowUserIds.length === 0) { if (userIds.length === 0) {
return; return;
} }
const users = await this.userRepository.getByIds(this.userRepository.manager, workflowUserIds); const users = await this.userRepository.getByIds(this.userRepository.manager, userIds);
const activeCollaborators = users.map((user) => ({
const msgData: IActiveWorkflowUsersChanged = { user: user.toIUser(),
lastSeen: collaborators.find(({ userId }) => userId === user.id)!.lastSeen,
}));
const msgData: ICollaboratorsChanged = {
workflowId, workflowId,
activeUsers: await Promise.all( collaborators: activeCollaborators,
users.map(async (user) => ({
user: await this.userService.toPublic(user),
lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)!
.lastSeen,
})),
),
}; };
this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds); this.push.sendToUsers('collaboratorsChanged', msgData, userIds);
} }
private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) { private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) {

View file

@ -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 { Time } from '@/constants';
import type { Iso8601DateTimeString } from '@/interfaces'; import type { Iso8601DateTimeString } from '@/interfaces';
import { CacheService } from '@/services/cache/cache.service'; import { CacheService } from '@/services/cache/cache.service';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { type Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
type WorkflowCacheHash = Record<User['id'], Iso8601DateTimeString>; type WorkflowCacheHash = Record<User['id'], Iso8601DateTimeString>;
interface CacheEntry {
userId: string;
lastSeen: string;
}
/** /**
* State management for the collaboration service. Workflow active * State management for the collaboration service. Workflow active
@ -30,7 +34,7 @@ export class CollaborationState {
/** /**
* Mark user active for given workflow * 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 cacheKey = this.formWorkflowCacheKey(workflowId);
const cacheEntry: WorkflowCacheHash = { const cacheEntry: WorkflowCacheHash = {
[userId]: new Date().toISOString(), [userId]: new Date().toISOString(),
@ -42,13 +46,13 @@ export class CollaborationState {
/** /**
* Remove user from workflow's active users * 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); const cacheKey = this.formWorkflowCacheKey(workflowId);
await this.cache.deleteFromHash(cacheKey, userId); await this.cache.deleteFromHash(cacheKey, userId);
} }
async getActiveWorkflowUsers(workflowId: Workflow['id']): Promise<ActiveWorkflowUser[]> { async getCollaborators(workflowId: Workflow['id']): Promise<CacheEntry[]> {
const cacheKey = this.formWorkflowCacheKey(workflowId); const cacheKey = this.formWorkflowCacheKey(workflowId);
const cacheValue = await this.cache.getHash<Iso8601DateTimeString>(cacheKey); const cacheValue = await this.cache.getHash<Iso8601DateTimeString>(cacheKey);
@ -56,11 +60,11 @@ export class CollaborationState {
return []; return [];
} }
const workflowActiveUsers = this.cacheHashToWorkflowActiveUsers(cacheValue); const activeCollaborators = this.cacheHashToCollaborators(cacheValue);
const [expired, stillActive] = this.splitToExpiredAndStillActive(workflowActiveUsers); const [expired, stillActive] = this.splitToExpiredAndStillActive(activeCollaborators);
if (expired.length > 0) { if (expired.length > 0) {
void this.removeExpiredUsersForWorkflow(workflowId, expired); void this.removeExpiredCollaborators(workflowId, expired);
} }
return stillActive; return stillActive;
@ -70,39 +74,36 @@ export class CollaborationState {
return `collaboration:${workflowId}`; return `collaboration:${workflowId}`;
} }
private splitToExpiredAndStillActive(workflowUsers: ActiveWorkflowUser[]) { private splitToExpiredAndStillActive(collaborators: CacheEntry[]) {
const expired: ActiveWorkflowUser[] = []; const expired: CacheEntry[] = [];
const stillActive: ActiveWorkflowUser[] = []; const stillActive: CacheEntry[] = [];
for (const user of workflowUsers) { for (const collaborator of collaborators) {
if (this.hasUserExpired(user.lastSeen)) { if (this.hasSessionExpired(collaborator.lastSeen)) {
expired.push(user); expired.push(collaborator);
} else { } else {
stillActive.push(user); stillActive.push(collaborator);
} }
} }
return [expired, stillActive]; return [expired, stillActive];
} }
private async removeExpiredUsersForWorkflow( private async removeExpiredCollaborators(workflowId: Workflow['id'], expiredUsers: CacheEntry[]) {
workflowId: Workflow['id'],
expiredUsers: ActiveWorkflowUser[],
) {
const cacheKey = this.formWorkflowCacheKey(workflowId); const cacheKey = this.formWorkflowCacheKey(workflowId);
await Promise.all( await Promise.all(
expiredUsers.map(async (user) => await this.cache.deleteFromHash(cacheKey, user.userId)), 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]) => ({ return Object.entries(workflowCacheEntry).map(([userId, lastSeen]) => ({
userId, userId,
lastSeen, lastSeen,
})); }));
} }
private hasUserExpired(lastSeenString: Iso8601DateTimeString) { private hasSessionExpired(lastSeenString: Iso8601DateTimeString) {
const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime; const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime;
return Date.now() > expiryTime; return Date.now() > expiryTime;

View file

@ -1,7 +0,0 @@
import type { Iso8601DateTimeString } from '@/interfaces';
import type { User } from '@/databases/entities/user';
export type ActiveWorkflowUser = {
userId: User['id'];
lastSeen: Iso8601DateTimeString;
};

View file

@ -162,4 +162,9 @@ export class User extends WithTimestamps implements IUser {
return 'Unnamed Project'; return 'Unnamed Project';
} }
} }
toIUser(): IUser {
const { id, email, firstName, lastName } = this;
return { id, email, firstName, lastName };
}
} }

View file

@ -21,6 +21,7 @@ import type {
INodeProperties, INodeProperties,
IUserSettings, IUserSettings,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
IUser,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { ActiveWorkflowManager } from '@/active-workflow-manager'; import type { ActiveWorkflowManager } from '@/active-workflow-manager';
@ -289,11 +290,11 @@ export type IPushData =
| PushDataWorkflowActivated | PushDataWorkflowActivated
| PushDataWorkflowDeactivated | PushDataWorkflowDeactivated
| PushDataWorkflowFailedToActivate | PushDataWorkflowFailedToActivate
| PushDataActiveWorkflowUsersChanged; | PushDataCollaboratorsChanged;
type PushDataActiveWorkflowUsersChanged = { type PushDataCollaboratorsChanged = {
data: IActiveWorkflowUsersChanged; data: ICollaboratorsChanged;
type: 'activeWorkflowUsersChanged'; type: 'collaboratorsChanged';
}; };
type PushDataWorkflowFailedToActivate = { type PushDataWorkflowFailedToActivate = {
@ -369,14 +370,14 @@ export type PushDataNodeDescriptionUpdated = {
/** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */ /** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */
export type Iso8601DateTimeString = string; export type Iso8601DateTimeString = string;
export interface IActiveWorkflowUser { export interface ICollaborator {
user: PublicUser; user: IUser;
lastSeen: Iso8601DateTimeString; lastSeen: Iso8601DateTimeString;
} }
export interface IActiveWorkflowUsersChanged { export interface ICollaboratorsChanged {
workflowId: Workflow['id']; workflowId: Workflow['id'];
activeUsers: IActiveWorkflowUser[]; collaborators: ICollaborator[];
} }
export interface IActiveWorkflowAdded { export interface IActiveWorkflowAdded {

View file

@ -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 { CollaborationService } from '@/collaboration/collaboration.service';
import { Push } from '@/push'; import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service'; 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 { import type {
WorkflowClosedMessage, WorkflowClosedMessage,
WorkflowOpenedMessage, WorkflowOpenedMessage,
} from '@/collaboration/collaboration.message'; } 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 { 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', () => { describe('CollaborationService', () => {
mockInstance(Push, new Push(mock())); mockInstance(Push, new Push(mock()));
@ -23,7 +24,6 @@ describe('CollaborationService', () => {
let memberWithoutAccess: User; let memberWithoutAccess: User;
let memberWithAccess: User; let memberWithAccess: User;
let workflow: WorkflowEntity; let workflow: WorkflowEntity;
let userService: UserService;
let cacheService: CacheService; let cacheService: CacheService;
beforeAll(async () => { beforeAll(async () => {
@ -31,7 +31,6 @@ describe('CollaborationService', () => {
pushService = Container.get(Push); pushService = Container.get(Push);
collaborationService = Container.get(CollaborationService); collaborationService = Container.get(CollaborationService);
userService = Container.get(UserService);
cacheService = Container.get(CacheService); cacheService = Container.get(CacheService);
await cacheService.init(); await cacheService.init();
@ -69,7 +68,7 @@ describe('CollaborationService', () => {
}; };
describe('workflow opened message', () => { describe('workflow opened message', () => {
it('should emit activeWorkflowUsersChanged after workflowOpened', async () => { it('should emit collaboratorsChanged after workflowOpened', async () => {
// Arrange // Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
@ -80,15 +79,12 @@ describe('CollaborationService', () => {
// Assert // Assert
expect(sendToUsersSpy).toHaveBeenNthCalledWith( expect(sendToUsersSpy).toHaveBeenNthCalledWith(
1, 1,
'activeWorkflowUsersChanged', 'collaboratorsChanged',
{ {
activeUsers: [ collaborators: [
{ {
lastSeen: expect.any(String), lastSeen: expect.any(String),
user: { user: owner.toIUser(),
...(await userService.toPublic(owner)),
isPending: false,
},
}, },
], ],
workflowId: workflow.id, workflowId: workflow.id,
@ -97,9 +93,9 @@ describe('CollaborationService', () => {
); );
expect(sendToUsersSpy).toHaveBeenNthCalledWith( expect(sendToUsersSpy).toHaveBeenNthCalledWith(
2, 2,
'activeWorkflowUsersChanged', 'collaboratorsChanged',
{ {
activeUsers: expect.arrayContaining([ collaborators: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
lastSeen: expect.any(String), lastSeen: expect.any(String),
user: expect.objectContaining({ 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'); const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
// Act // Act
@ -131,7 +127,7 @@ describe('CollaborationService', () => {
}); });
describe('workflow closed message', () => { 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 // Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id); await sendWorkflowOpenedMessage(workflow.id, owner.id);
@ -144,7 +140,7 @@ describe('CollaborationService', () => {
expect(sendToUsersSpy).not.toHaveBeenCalled(); 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 // Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id); await sendWorkflowOpenedMessage(workflow.id, owner.id);
@ -156,9 +152,9 @@ describe('CollaborationService', () => {
// Assert // Assert
expect(sendToUsersSpy).toHaveBeenCalledWith( expect(sendToUsersSpy).toHaveBeenCalledWith(
'activeWorkflowUsersChanged', 'collaboratorsChanged',
{ {
activeUsers: expect.arrayContaining([ collaborators: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
lastSeen: expect.any(String), lastSeen: expect.any(String),
user: expect.objectContaining({ 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 // Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id); await sendWorkflowOpenedMessage(workflow.id, owner.id);

View file

@ -422,14 +422,19 @@ export interface IExecutionDeleteFilter {
ids?: string[]; ids?: string[];
} }
export type PushDataUsersForWorkflow = { export interface Collaborator {
user: IUser;
lastSeen: string;
}
export type PushDataCollaborators = {
workflowId: string; workflowId: string;
activeUsers: Array<{ user: IUser; lastSeen: string }>; collaborators: Collaborator[];
}; };
type PushDataWorkflowUsersChanged = { type PushDataCollaboratorsChanged = {
data: PushDataUsersForWorkflow; data: PushDataCollaborators;
type: 'activeWorkflowUsersChanged'; type: 'collaboratorsChanged';
}; };
export type IPushData = export type IPushData =
@ -446,7 +451,7 @@ export type IPushData =
| PushDataWorkerStatusMessage | PushDataWorkerStatusMessage
| PushDataActiveWorkflowAdded | PushDataActiveWorkflowAdded
| PushDataActiveWorkflowRemoved | PushDataActiveWorkflowRemoved
| PushDataWorkflowUsersChanged | PushDataCollaboratorsChanged
| PushDataWorkflowFailedToActivate; | PushDataWorkflowFailedToActivate;
export type PushDataActiveWorkflowAdded = { export type PushDataActiveWorkflowAdded = {

View file

@ -1,22 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount, computed, watch } from 'vue';
import { useDocumentVisibility } from '@vueuse/core';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCollaborationStore } from '@/stores/collaboration.store'; import { useCollaborationStore } from '@/stores/collaboration.store';
import { onBeforeUnmount, onMounted, computed, ref } from 'vue';
import { TIME } from '@/constants';
import { isUserGlobalOwner } from '@/utils/userUtils'; import { isUserGlobalOwner } from '@/utils/userUtils';
const collaborationStore = useCollaborationStore(); const collaborationStore = useCollaborationStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE; const visibility = useDocumentVisibility();
const heartbeatTimer = ref<number | null>(null); watch(visibility, (visibilityState) => {
if (visibilityState === 'hidden') {
collaborationStore.stopHeartbeat();
} else {
collaborationStore.startHeartbeat();
}
});
const activeUsersSorted = computed(() => { const collaboratorsSorted = computed(() => {
const currentWorkflowUsers = (collaborationStore.getUsersForCurrentWorkflow ?? []).map( const currentWorkflowUsers = collaborationStore.collaborators.map(({ user }) => user);
(userInfo) => userInfo.user,
);
const owner = currentWorkflowUsers.find(isUserGlobalOwner); const owner = currentWorkflowUsers.find(isUserGlobalOwner);
return { return {
defaultGroup: owner defaultGroup: owner
@ -25,43 +28,13 @@ const activeUsersSorted = computed(() => {
}; };
}); });
const currentUserEmail = computed(() => { const currentUserEmail = computed(() => usersStore.currentUser?.email);
return usersStore.currentUser?.email;
});
const startHeartbeat = () => {
if (heartbeatTimer.value !== null) {
clearInterval(heartbeatTimer.value);
heartbeatTimer.value = null;
}
heartbeatTimer.value = window.setInterval(() => {
collaborationStore.notifyWorkflowOpened(workflowsStore.workflow.id);
}, HEARTBEAT_INTERVAL);
};
const stopHeartbeat = () => {
if (heartbeatTimer.value !== null) {
clearInterval(heartbeatTimer.value);
}
};
const onDocumentVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
stopHeartbeat();
} else {
startHeartbeat();
}
};
onMounted(() => { onMounted(() => {
collaborationStore.initialize(); collaborationStore.initialize();
startHeartbeat();
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
stopHeartbeat();
collaborationStore.terminate(); collaborationStore.terminate();
}); });
</script> </script>
@ -71,7 +44,7 @@ onBeforeUnmount(() => {
:class="`collaboration-pane-container ${$style.container}`" :class="`collaboration-pane-container ${$style.container}`"
data-test-id="collaboration-pane" data-test-id="collaboration-pane"
> >
<n8n-user-stack :users="activeUsersSorted" :current-user-email="currentUserEmail" /> <n8n-user-stack :users="collaboratorsSorted" :current-user-email="currentUserEmail" />
</div> </div>
</template> </template>

View file

@ -668,7 +668,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
</span> </span>
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]"> <EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
<div :class="$style.group"> <div :class="$style.group">
<CollaborationPane /> <CollaborationPane v-if="nodeViewVersion === '2' && !isNewWorkflow" />
<N8nButton <N8nButton
type="secondary" type="secondary"
data-test-id="workflow-share-button" data-test-id="workflow-share-button"

View file

@ -60,13 +60,10 @@ const initialState = {
}, },
}, },
[STORES.COLLABORATION]: { [STORES.COLLABORATION]: {
usersForWorkflows: { collaborators: [
w1: [
{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER }, { lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER },
{ lastSeen: '2023-11-22T10:17:12.246Z', user: OWNER_USER }, { lastSeen: '2023-11-22T10:17:12.246Z', user: OWNER_USER },
], ],
w2: [{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER_2 }],
},
}, },
}; };

View file

@ -62,6 +62,17 @@ describe('useBeforeUnload', () => {
}); });
}); });
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', () => { describe('addBeforeUnloadEventBindings', () => {
it('should add beforeunload event listener', () => { it('should add beforeunload event listener', () => {
const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute }); const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute });

View file

@ -2,10 +2,8 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { TIME, VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { useRoute } from 'vue-router'; 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. * 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<typeof useRoute> }) { export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute> }) {
const uiStore = useUIStore(); const uiStore = useUIStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const collaborationStore = useCollaborationStore();
const workflowsStore = useWorkflowsStore();
const i18n = useI18n(); const i18n = useI18n();
const unloadTimeout = ref<NodeJS.Timeout | null>(null); const unloadTimeout = ref<NodeJS.Timeout | null>(null);
const isDemoRoute = computed(() => route.name === VIEWS.DEMO); const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
type Handler = () => void;
const handlers: Handler[] = [];
function onBeforeUnload(e: BeforeUnloadEvent) { function onBeforeUnload(e: BeforeUnloadEvent) {
if (isDemoRoute.value || window.preventNodeViewBeforeUnload) { if (isDemoRoute.value || window.preventNodeViewBeforeUnload) {
return; 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 e.returnValue = true; //Gecko + IE
return true; //Gecko + Webkit, Safari, Chrome etc. return true; //Gecko + Webkit, Safari, Chrome etc.
} else { } else {
canvasStore.startLoading(i18n.baseText('nodeView.redirecting')); canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
return; return;
} }
} }
function addBeforeUnloadHandler(handler: () => void) {
handlers.push(handler);
}
function addBeforeUnloadEventBindings() { function addBeforeUnloadEventBindings() {
window.addEventListener('beforeunload', onBeforeUnload); window.addEventListener('beforeunload', onBeforeUnload);
} }
function removeBeforeUnloadEventBindings() { function removeBeforeUnloadEventBindings() {
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
if (unloadTimeout.value) { if (unloadTimeout.value) {
clearTimeout(unloadTimeout.value); clearTimeout(unloadTimeout.value);
} }
@ -64,5 +60,6 @@ export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute>
onBeforeUnload, onBeforeUnload,
addBeforeUnloadEventBindings, addBeforeUnloadEventBindings,
removeBeforeUnloadEventBindings, removeBeforeUnloadEventBindings,
addBeforeUnloadHandler,
}; };
} }

View file

@ -1,14 +1,16 @@
import { defineStore } from 'pinia'; 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 { useWorkflowsStore } from '@/stores/workflows.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { STORES } from '@/constants';
import type { IUser } from '@/Interface';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useUIStore } from '@/stores/ui.store';
type ActiveUsersForWorkflows = { const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE;
[workflowId: string]: Array<{ user: IUser; lastSeen: string }>;
};
/** /**
* Store for tracking active users for workflows. I.e. to show * Store for tracking active users for workflows. I.e. to show
@ -16,27 +18,59 @@ type ActiveUsersForWorkflows = {
*/ */
export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
const pushStore = usePushConnectionStore(); const pushStore = usePushConnectionStore();
const workflowStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const uiStore = useUIStore();
const usersForWorkflows = ref<ActiveUsersForWorkflows>({}); const route = useRoute();
const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null); const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings, addBeforeUnloadHandler } =
useBeforeUnload({ route });
const unloadTimeout = ref<NodeJS.Timeout | null>(null);
const getUsersForCurrentWorkflow = computed(() => { addBeforeUnloadHandler(() => {
return usersForWorkflows.value[workflowStore.workflowId] ?? []; // 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<Collaborator[]>([]);
const heartbeatTimer = ref<number | null>(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() { function initialize() {
if (pushStoreEventListenerRemovalFn.value) { if (pushStoreEventListenerRemovalFn.value) {
return; return;
} }
pushStoreEventListenerRemovalFn.value = pushStore.addEventListener((event) => { pushStoreEventListenerRemovalFn.value = pushStore.addEventListener((event) => {
if (event.type === 'activeWorkflowUsersChanged') { if (
const workflowId = event.data.workflowId; event.type === 'collaboratorsChanged' &&
usersForWorkflows.value[workflowId] = event.data.activeUsers; event.data.workflowId === workflowsStore.workflowId
) {
collaborators.value = event.data.collaborators;
} }
}); });
addBeforeUnloadEventBindings();
notifyWorkflowOpened();
startHeartbeat();
} }
function terminate() { function terminate() {
@ -44,43 +78,36 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
pushStoreEventListenerRemovalFn.value(); pushStoreEventListenerRemovalFn.value();
pushStoreEventListenerRemovalFn.value = null; pushStoreEventListenerRemovalFn.value = null;
} }
notifyWorkflowClosed();
stopHeartbeat();
pushStore.clearQueue();
removeBeforeUnloadEventBindings();
if (unloadTimeout.value) {
clearTimeout(unloadTimeout.value);
}
} }
function workflowUsersUpdated(data: ActiveUsersForWorkflows) { function notifyWorkflowOpened() {
usersForWorkflows.value = data; const { workflowId } = workflowsStore;
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
pushStore.send({ type: 'workflowOpened', workflowId });
} }
function functionRemoveCurrentUserFromActiveUsers(workflowId: string) { function notifyWorkflowClosed() {
const workflowUsers = usersForWorkflows.value[workflowId]; const { workflowId } = workflowsStore;
if (!workflowUsers) { if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
return; pushStore.send({ type: 'workflowClosed', workflowId });
}
usersForWorkflows.value[workflowId] = workflowUsers.filter( collaborators.value = collaborators.value.filter(
(activeUser) => activeUser.user.id !== usersStore.currentUserId, ({ 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 { return {
usersForWorkflows, collaborators,
initialize, initialize,
terminate, terminate,
notifyWorkflowOpened, startHeartbeat,
notifyWorkflowClosed, stopHeartbeat,
workflowUsersUpdated,
getUsersForCurrentWorkflow,
}; };
}); });

View file

@ -150,6 +150,10 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData)); onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData));
} }
const clearQueue = () => {
outgoingQueue.value = [];
};
return { return {
pushRef, pushRef,
pushSource, pushSource,
@ -159,5 +163,6 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
pushConnect, pushConnect,
pushDisconnect, pushDisconnect,
send, send,
clearQueue,
}; };
}); });

View file

@ -103,7 +103,6 @@ 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'; import { useBeforeUnload } from '@/composables/useBeforeUnload';
import { useCollaborationStore } from '@/stores/collaboration.store';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue'; import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
@ -137,7 +136,6 @@ const credentialsStore = useCredentialsStore();
const environmentsStore = useEnvironmentsStore(); const environmentsStore = useEnvironmentsStore();
const externalSecretsStore = useExternalSecretsStore(); const externalSecretsStore = useExternalSecretsStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const collaborationStore = useCollaborationStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const npsSurveyStore = useNpsSurveyStore(); const npsSurveyStore = useNpsSurveyStore();
@ -353,8 +351,6 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject( await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(
editableWorkflow.value.homeProject, editableWorkflow.value.homeProject,
); );
collaborationStore.notifyWorkflowOpened(id);
} catch (error) { } catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError')); toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
@ -1482,7 +1478,6 @@ watch(
onBeforeMount(() => { onBeforeMount(() => {
if (!isDemoRoute.value) { if (!isDemoRoute.value) {
pushConnectionStore.pushConnect(); pushConnectionStore.pushConnect();
collaborationStore.initialize();
} }
}); });
@ -1537,7 +1532,6 @@ onBeforeUnmount(() => {
removeExecutionOpenedEventBindings(); removeExecutionOpenedEventBindings();
unregisterCustomActions(); unregisterCustomActions();
if (!isDemoRoute.value) { if (!isDemoRoute.value) {
collaborationStore.terminate();
pushConnectionStore.pushDisconnect(); pushConnectionStore.pushDisconnect();
} }
}); });

View file

@ -8,7 +8,6 @@ import { getNodeViewTab } from '@/utils/canvasUtils';
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -20,8 +19,6 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
const { resetWorkspace } = useCanvasOperations({ router });
const nodeViewVersion = useLocalStorage( const nodeViewVersion = useLocalStorage(
'NodeView.version', 'NodeView.version',
settingsStore.deploymentType === 'n8n-internal' ? '2' : '1', settingsStore.deploymentType === 'n8n-internal' ? '2' : '1',
@ -56,9 +53,6 @@ onBeforeRouteLeave(async (to, from, next) => {
await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, { await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, {
async confirm() { async confirm() {
// Make sure workflow id is empty when leaving the editor
workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
if (from.name === VIEWS.NEW_WORKFLOW) { if (from.name === VIEWS.NEW_WORKFLOW) {
// Replace the current route with the new workflow route // Replace the current route with the new workflow route
// before navigating to the new route when saving new workflow. // before navigating to the new route when saving new workflow.
@ -72,11 +66,10 @@ onBeforeRouteLeave(async (to, from, next) => {
return false; return false;
} }
return true; // Make sure workflow id is empty when leaving the editor
},
async cancel() {
workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
resetWorkspace();
return true;
}, },
}); });
}); });