feat(editor): Show avatars for users currently working on the same workflow (#7763)

This PR introduces the following changes:
- New Vue stores: `collaborationStore` and `pushConnectionStore`
- Front-end push connection handling overhaul: Keep only a singe
connection open and handle it from the new store
- Add user avatars in the editor header when there are multiple users
working on the same workflow
- Sending a heartbeat event to back-end service periodically to confirm
user is still active

- Back-end overhauls (authored by @tomi):
  - Implementing a cleanup procedure that removes inactive users
  - Refactoring collaboration service current implementation

---------

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
Milorad FIlipović
2023-11-23 10:14:34 +01:00
committed by GitHub
parent 99a9ea497a
commit 77bc8ecd4b
18 changed files with 654 additions and 148 deletions

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCollaborationStore } from '@/stores/collaboration.store';
import { onBeforeUnmount } from 'vue';
import { onMounted } from 'vue';
import { computed, ref } from 'vue';
import { TIME } from '@/constants';
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((user) => user.globalRoleId === 1);
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(() => {
startHeartbeat();
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
});
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
stopHeartbeat();
});
</script>
<template>
<div
:class="`collaboration-pane-container ${$style.container}`"
data-test-id="collaboration-pane"
>
<n8n-user-stack :users="activeUsersSorted" :currentUserEmail="currentUserEmail" />
</div>
</template>
<style lang="scss" module>
.container {
margin: 0 var(--spacing-4xs);
}
</style>

View File

@@ -90,11 +90,6 @@ export default defineComponent({
mounted() {
this.dirtyState = this.uiStore.stateIsDirty;
this.syncTabsWithRoute(this.$route);
// Initialize the push connection
this.pushConnect();
},
beforeUnmount() {
this.pushDisconnect();
},
watch: {
$route(to, from) {

View File

@@ -56,18 +56,20 @@
<span v-else class="tags"></span>
<PushConnectionTracker class="actions">
<span class="activator">
<span :class="`activator ${$style.group}`">
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
</span>
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
<n8n-button
type="secondary"
class="mr-2xs"
@click="onShareButtonClick"
data-test-id="workflow-share-button"
>
{{ $locale.baseText('workflowDetails.share') }}
</n8n-button>
<div :class="$style.group">
<collaboration-pane />
<n8n-button
type="secondary"
@click="onShareButtonClick"
data-test-id="workflow-share-button"
>
{{ $locale.baseText('workflowDetails.share') }}
</n8n-button>
</div>
<template #fallback>
<n8n-tooltip>
<n8n-button type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
@@ -94,28 +96,30 @@
</n8n-tooltip>
</template>
</enterprise-edition>
<SaveButton
type="primary"
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving || readOnly"
data-test-id="workflow-save-button"
@click="onSaveButtonClick"
/>
<router-link
v-if="isWorkflowHistoryFeatureEnabled"
:to="workflowHistoryRoute"
:class="$style.workflowHistoryButton"
>
<n8n-icon-button
:disabled="isWorkflowHistoryButtonDisabled"
data-test-id="workflow-history-button"
type="tertiary"
icon="history"
size="medium"
text
<div :class="$style.group">
<SaveButton
type="primary"
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving || readOnly"
data-test-id="workflow-save-button"
@click="onSaveButtonClick"
/>
</router-link>
<div :class="$style.workflowMenuContainer">
<router-link
v-if="isWorkflowHistoryFeatureEnabled"
:to="workflowHistoryRoute"
:class="$style.workflowHistoryButton"
>
<n8n-icon-button
:disabled="isWorkflowHistoryButtonDisabled"
data-test-id="workflow-history-button"
type="tertiary"
icon="history"
size="medium"
text
/>
</router-link>
</div>
<div :class="[$style.workflowMenuContainer, $style.group]">
<input
:class="$style.hiddenInput"
type="file"
@@ -159,6 +163,7 @@ import SaveButton from '@/components/SaveButton.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver';
@@ -201,6 +206,7 @@ export default defineComponent({
TagsDropdown,
InlineTextEdit,
BreakpointsObserver,
CollaborationPane,
},
props: {
readOnly: {
@@ -681,7 +687,6 @@ $--header-spacing: 20px;
line-height: $--text-line-height;
display: flex;
align-items: center;
margin-right: 30px;
> span {
margin-right: 5px;
@@ -717,14 +722,15 @@ $--header-spacing: 20px;
.actions {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
</style>
<style module lang="scss">
.workflowMenuContainer {
margin-left: var(--spacing-2xs);
.group {
display: flex;
gap: var(--spacing-xs);
}
.hiddenInput {
display: none;
}
@@ -740,8 +746,6 @@ $--header-spacing: 20px;
.workflowHistoryButton {
width: 30px;
height: 30px;
margin-left: var(--spacing-m);
margin-right: var(--spacing-4xs);
color: var(--color-text-dark);
border-radius: var(--border-radius-base);

View File

@@ -0,0 +1,135 @@
import { merge } from 'lodash-es';
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import { useUIStore } from '@/stores/ui.store';
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',
globalRoleId: 1,
disabled: false,
globalRole: {
id: '1',
name: 'owner',
scope: 'global',
},
isPending: false,
isOwner: true,
fullName: 'Owner User',
};
const MEMBER_USER = {
createdAt: '2023-11-22T10:17:12.246Z',
id: 'aaabbb',
email: 'member@user.com',
firstName: 'Member',
lastName: 'User',
globalRoleId: 2,
disabled: false,
globalRole: {
id: '2',
name: 'member',
scope: 'global',
},
isPending: false,
isOwner: 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',
globalRoleId: 2,
disabled: false,
globalRole: {
id: '2',
name: 'member',
scope: 'global',
},
isPending: false,
isOwner: false,
fullName: 'Another Member User',
};
let uiStore: ReturnType<typeof useUIStore>;
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', () => {
beforeEach(() => {
uiStore = useUIStore();
});
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 render current user correctly', async () => {
const { getByText, queryByText } = renderComponent();
await waitAllPromises();
expect(getByText(`${OWNER_USER.fullName} (you)`)).toBeInTheDocument();
expect(queryByText(`${MEMBER_USER.fullName} (you)`)).toBeNull();
expect(queryByText(`${MEMBER_USER.fullName}`)).toBeInTheDocument();
});
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 bur shourld be rendered first
expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`);
});
});