mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
refactor(editor): Enable collaboration features only in NodeView v2 (no-changelog) (#10756)
This commit is contained in:
parent
ee5fbc543c
commit
a1e011dd2a
|
@ -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([
|
||||
|
|
|
@ -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']) {
|
||||
|
|
|
@ -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<User['id'], Iso8601DateTimeString>;
|
||||
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<ActiveWorkflowUser[]> {
|
||||
async getCollaborators(workflowId: Workflow['id']): Promise<CacheEntry[]> {
|
||||
const cacheKey = this.formWorkflowCacheKey(workflowId);
|
||||
|
||||
const cacheValue = await this.cache.getHash<Iso8601DateTimeString>(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;
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import type { Iso8601DateTimeString } from '@/interfaces';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
|
||||
export type ActiveWorkflowUser = {
|
||||
userId: User['id'];
|
||||
lastSeen: Iso8601DateTimeString;
|
||||
};
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -1,22 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, computed, watch } from 'vue';
|
||||
import { useDocumentVisibility } from '@vueuse/core';
|
||||
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||
import { onBeforeUnmount, onMounted, computed, ref } from 'vue';
|
||||
import { TIME } from '@/constants';
|
||||
import { isUserGlobalOwner } from '@/utils/userUtils';
|
||||
|
||||
const collaborationStore = useCollaborationStore();
|
||||
const usersStore = useUsersStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE;
|
||||
const heartbeatTimer = ref<number | null>(null);
|
||||
const visibility = useDocumentVisibility();
|
||||
watch(visibility, (visibilityState) => {
|
||||
if (visibilityState === 'hidden') {
|
||||
collaborationStore.stopHeartbeat();
|
||||
} else {
|
||||
collaborationStore.startHeartbeat();
|
||||
}
|
||||
});
|
||||
|
||||
const activeUsersSorted = computed(() => {
|
||||
const currentWorkflowUsers = (collaborationStore.getUsersForCurrentWorkflow ?? []).map(
|
||||
(userInfo) => userInfo.user,
|
||||
);
|
||||
const collaboratorsSorted = computed(() => {
|
||||
const currentWorkflowUsers = collaborationStore.collaborators.map(({ user }) => user);
|
||||
const owner = currentWorkflowUsers.find(isUserGlobalOwner);
|
||||
return {
|
||||
defaultGroup: owner
|
||||
|
@ -25,43 +28,13 @@ const activeUsersSorted = computed(() => {
|
|||
};
|
||||
});
|
||||
|
||||
const currentUserEmail = computed(() => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
const currentUserEmail = computed(() => usersStore.currentUser?.email);
|
||||
|
||||
onMounted(() => {
|
||||
collaborationStore.initialize();
|
||||
startHeartbeat();
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
stopHeartbeat();
|
||||
collaborationStore.terminate();
|
||||
});
|
||||
</script>
|
||||
|
@ -71,7 +44,7 @@ onBeforeUnmount(() => {
|
|||
:class="`collaboration-pane-container ${$style.container}`"
|
||||
data-test-id="collaboration-pane"
|
||||
>
|
||||
<n8n-user-stack :users="activeUsersSorted" :current-user-email="currentUserEmail" />
|
||||
<n8n-user-stack :users="collaboratorsSorted" :current-user-email="currentUserEmail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -668,7 +668,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
</span>
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||
<div :class="$style.group">
|
||||
<CollaborationPane />
|
||||
<CollaborationPane v-if="nodeViewVersion === '2' && !isNewWorkflow" />
|
||||
<N8nButton
|
||||
type="secondary"
|
||||
data-test-id="workflow-share-button"
|
||||
|
|
|
@ -60,13 +60,10 @@ const initialState = {
|
|||
},
|
||||
},
|
||||
[STORES.COLLABORATION]: {
|
||||
usersForWorkflows: {
|
||||
w1: [
|
||||
collaborators: [
|
||||
{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER },
|
||||
{ lastSeen: '2023-11-22T10:17:12.246Z', user: OWNER_USER },
|
||||
],
|
||||
w2: [{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER_2 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
it('should add beforeunload event listener', () => {
|
||||
const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute });
|
|
@ -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<typeof useRoute> }) {
|
||||
const uiStore = useUIStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const collaborationStore = useCollaborationStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const unloadTimeout = ref<NodeJS.Timeout | null>(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<typeof useRoute>
|
|||
onBeforeUnload,
|
||||
addBeforeUnloadEventBindings,
|
||||
removeBeforeUnloadEventBindings,
|
||||
addBeforeUnloadHandler,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<ActiveUsersForWorkflows>({});
|
||||
const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null);
|
||||
const route = useRoute();
|
||||
const { addBeforeUnloadEventBindings, removeBeforeUnloadEventBindings, addBeforeUnloadHandler } =
|
||||
useBeforeUnload({ route });
|
||||
const unloadTimeout = ref<NodeJS.Timeout | null>(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<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() {
|
||||
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;
|
||||
}
|
||||
notifyWorkflowClosed();
|
||||
stopHeartbeat();
|
||||
pushStore.clearQueue();
|
||||
removeBeforeUnloadEventBindings();
|
||||
if (unloadTimeout.value) {
|
||||
clearTimeout(unloadTimeout.value);
|
||||
}
|
||||
}
|
||||
|
||||
function workflowUsersUpdated(data: ActiveUsersForWorkflows) {
|
||||
usersForWorkflows.value = data;
|
||||
function notifyWorkflowOpened() {
|
||||
const { workflowId } = workflowsStore;
|
||||
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
|
||||
pushStore.send({ type: 'workflowOpened', workflowId });
|
||||
}
|
||||
|
||||
function functionRemoveCurrentUserFromActiveUsers(workflowId: string) {
|
||||
const workflowUsers = usersForWorkflows.value[workflowId];
|
||||
if (!workflowUsers) {
|
||||
return;
|
||||
}
|
||||
function notifyWorkflowClosed() {
|
||||
const { workflowId } = workflowsStore;
|
||||
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
|
||||
pushStore.send({ type: 'workflowClosed', workflowId });
|
||||
|
||||
usersForWorkflows.value[workflowId] = workflowUsers.filter(
|
||||
(activeUser) => activeUser.user.id !== usersStore.currentUserId,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue