feat: Reintroduce collaboration feature (#10602)
This commit is contained in:
@@ -422,6 +422,16 @@ export interface IExecutionDeleteFilter {
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
export type PushDataUsersForWorkflow = {
|
||||
workflowId: string;
|
||||
activeUsers: Array<{ user: IUser; lastSeen: string }>;
|
||||
};
|
||||
|
||||
type PushDataWorkflowUsersChanged = {
|
||||
data: PushDataUsersForWorkflow;
|
||||
type: 'activeWorkflowUsersChanged';
|
||||
};
|
||||
|
||||
export type IPushData =
|
||||
| PushDataExecutionFinished
|
||||
| PushDataExecutionStarted
|
||||
@@ -436,6 +446,7 @@ export type IPushData =
|
||||
| PushDataWorkerStatusMessage
|
||||
| PushDataActiveWorkflowAdded
|
||||
| PushDataActiveWorkflowRemoved
|
||||
| PushDataWorkflowUsersChanged
|
||||
| PushDataWorkflowFailedToActivate;
|
||||
|
||||
export type PushDataActiveWorkflowAdded = {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
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 activeUsersSorted = computed(() => {
|
||||
const currentWorkflowUsers = (collaborationStore.getUsersForCurrentWorkflow ?? []).map(
|
||||
(userInfo) => userInfo.user,
|
||||
);
|
||||
const owner = currentWorkflowUsers.find(isUserGlobalOwner);
|
||||
return {
|
||||
defaultGroup: owner
|
||||
? [owner, ...currentWorkflowUsers.filter((user) => user.id !== owner.id)]
|
||||
: currentWorkflowUsers,
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
collaborationStore.initialize();
|
||||
startHeartbeat();
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
stopHeartbeat();
|
||||
collaborationStore.terminate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="`collaboration-pane-container ${$style.container}`"
|
||||
data-test-id="collaboration-pane"
|
||||
>
|
||||
<n8n-user-stack :users="activeUsersSorted" :current-user-email="currentUserEmail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
||||
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
||||
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
||||
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
|
||||
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -675,6 +676,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||
</span>
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||
<div :class="$style.group">
|
||||
<CollaborationPane />
|
||||
<N8nButton
|
||||
type="secondary"
|
||||
data-test-id="workflow-share-button"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { merge } from 'lodash-es';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||
import { ROLE, STORES } from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import CollaborationPane from '@/components//MainHeader/CollaborationPane.vue';
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
const OWNER_USER = {
|
||||
createdAt: '2023-11-22T10:17:12.246Z',
|
||||
id: 'aaaaaa',
|
||||
email: 'owner@user.com',
|
||||
firstName: 'Owner',
|
||||
lastName: 'User',
|
||||
role: ROLE.Owner,
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
fullName: 'Owner User',
|
||||
};
|
||||
|
||||
const MEMBER_USER = {
|
||||
createdAt: '2023-11-22T10:17:12.246Z',
|
||||
id: 'aaabbb',
|
||||
email: 'member@user.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
fullName: 'Member User',
|
||||
};
|
||||
|
||||
const MEMBER_USER_2 = {
|
||||
createdAt: '2023-11-22T10:17:12.246Z',
|
||||
id: 'aaaccc',
|
||||
email: 'member2@user.com',
|
||||
firstName: 'Another Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
disabled: false,
|
||||
isPending: false,
|
||||
fullName: 'Another Member User',
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||
},
|
||||
[STORES.WORKFLOWS]: {
|
||||
workflow: {
|
||||
id: 'w1',
|
||||
},
|
||||
},
|
||||
[STORES.USERS]: {
|
||||
currentUserId: 'aaaaaa',
|
||||
users: {
|
||||
aaaaaa: OWNER_USER,
|
||||
aaabbb: MEMBER_USER,
|
||||
aaaccc: MEMBER_USER_2,
|
||||
},
|
||||
},
|
||||
[STORES.COLLABORATION]: {
|
||||
usersForWorkflows: {
|
||||
w1: [
|
||||
{ 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 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
pinia: createTestingPinia({ initialState }),
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CollaborationPane, defaultRenderOptions);
|
||||
|
||||
describe('CollaborationPane', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show only current workflow users', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('collaboration-pane')).toBeInTheDocument();
|
||||
expect(getByTestId('user-stack-avatars')).toBeInTheDocument();
|
||||
expect(getByTestId(`user-stack-avatar-${OWNER_USER.id}`)).toBeInTheDocument();
|
||||
expect(getByTestId(`user-stack-avatar-${MEMBER_USER.id}`)).toBeInTheDocument();
|
||||
expect(queryByTestId(`user-stack-avatar-${MEMBER_USER_2.id}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should always render owner first in the list', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
const firstAvatar = getByTestId('user-stack-avatars').querySelector('.n8n-avatar');
|
||||
// Owner is second in the store but should be rendered first
|
||||
expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { computed } from 'vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { computed, ref } from 'vue';
|
||||
import { TIME, 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.
|
||||
@@ -15,19 +17,31 @@ import type { useRoute } from 'vue-router';
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
e.returnValue = true; //Gecko + IE
|
||||
return true; //Gecko + Webkit, Safari, Chrome etc.
|
||||
} else {
|
||||
canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
|
||||
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +51,12 @@ export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute>
|
||||
}
|
||||
|
||||
function removeBeforeUnloadEventBindings() {
|
||||
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
|
||||
|
||||
if (unloadTimeout.value) {
|
||||
clearTimeout(unloadTimeout.value);
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}
|
||||
|
||||
|
||||
@@ -639,6 +639,7 @@ export const enum STORES {
|
||||
CLOUD_PLAN = 'cloudPlan',
|
||||
RBAC = 'rbac',
|
||||
PUSH = 'push',
|
||||
COLLABORATION = 'collaboration',
|
||||
ASSISTANT = 'assistant',
|
||||
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
|
||||
PROJECTS = 'projects',
|
||||
|
||||
86
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
86
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
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';
|
||||
|
||||
type ActiveUsersForWorkflows = {
|
||||
[workflowId: string]: Array<{ user: IUser; lastSeen: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store for tracking active users for workflows. I.e. to show
|
||||
* who is collaboratively viewing/editing the workflow at the same time.
|
||||
*/
|
||||
export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
|
||||
const pushStore = usePushConnectionStore();
|
||||
const workflowStore = useWorkflowsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const usersForWorkflows = ref<ActiveUsersForWorkflows>({});
|
||||
const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null);
|
||||
|
||||
const getUsersForCurrentWorkflow = computed(() => {
|
||||
return usersForWorkflows.value[workflowStore.workflowId] ?? [];
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function terminate() {
|
||||
if (typeof pushStoreEventListenerRemovalFn.value === 'function') {
|
||||
pushStoreEventListenerRemovalFn.value();
|
||||
pushStoreEventListenerRemovalFn.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function workflowUsersUpdated(data: ActiveUsersForWorkflows) {
|
||||
usersForWorkflows.value = data;
|
||||
}
|
||||
|
||||
function functionRemoveCurrentUserFromActiveUsers(workflowId: string) {
|
||||
const workflowUsers = usersForWorkflows.value[workflowId];
|
||||
if (!workflowUsers) {
|
||||
return;
|
||||
}
|
||||
|
||||
usersForWorkflows.value[workflowId] = workflowUsers.filter(
|
||||
(activeUser) => activeUser.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,
|
||||
initialize,
|
||||
terminate,
|
||||
notifyWorkflowOpened,
|
||||
notifyWorkflowClosed,
|
||||
workflowUsersUpdated,
|
||||
getUsersForCurrentWorkflow,
|
||||
};
|
||||
});
|
||||
@@ -101,6 +101,7 @@ 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';
|
||||
|
||||
@@ -134,6 +135,7 @@ const credentialsStore = useCredentialsStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const rootStore = useRootStore();
|
||||
const collaborationStore = useCollaborationStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const npsSurveyStore = useNpsSurveyStore();
|
||||
@@ -338,6 +340,8 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
|
||||
}
|
||||
|
||||
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
|
||||
|
||||
collaborationStore.notifyWorkflowOpened(id);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
||||
|
||||
@@ -1456,6 +1460,7 @@ watch(
|
||||
onBeforeMount(() => {
|
||||
if (!isDemoRoute.value) {
|
||||
pushConnectionStore.pushConnect();
|
||||
collaborationStore.initialize();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1509,6 +1514,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
onDeactivated(() => {
|
||||
removeBeforeUnloadEventBindings();
|
||||
collaborationStore.terminate();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user