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';
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([

View file

@ -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']) {

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 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;

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';
}
}
toIUser(): IUser {
const { id, email, firstName, lastName } = this;
return { id, email, firstName, lastName };
}
}

View file

@ -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 {

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 { 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);

View file

@ -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 = {

View file

@ -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>

View file

@ -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"

View file

@ -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 }],
},
},
};

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

View file

@ -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,
};
}

View file

@ -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,
};
});

View file

@ -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,
};
});

View file

@ -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();
}
});

View file

@ -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;
},
});
});