feat: Reintroduce collaboration feature (#10602)

This commit is contained in:
Tomi Turtiainen
2024-09-03 17:52:12 +03:00
committed by GitHub
parent 35e6a87cba
commit 2ea2bfe762
22 changed files with 1046 additions and 23 deletions

View File

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

View File

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

View File

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