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:
committed by
GitHub
parent
99a9ea497a
commit
77bc8ecd4b
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user