feat: Conversation list virtualization (#8540)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
0e9825f298
commit
075b0292b5
@ -130,26 +130,16 @@
|
|||||||
class="conversations-list flex-1"
|
class="conversations-list flex-1"
|
||||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||||
>
|
>
|
||||||
<conversation-card
|
<virtual-list
|
||||||
v-for="chat in conversationList"
|
ref="conversationVirtualList"
|
||||||
:key="chat.id"
|
:data-key="'id'"
|
||||||
:active-label="label"
|
:data-sources="conversationList"
|
||||||
:team-id="teamId"
|
:data-component="itemComponent"
|
||||||
:folders-id="foldersId"
|
:extra-props="virtualListExtraProps"
|
||||||
:chat="chat"
|
class="w-full overflow-auto h-full"
|
||||||
:conversation-type="conversationType"
|
footer-tag="div"
|
||||||
:show-assignee="showAssigneeInConversationCard"
|
>
|
||||||
:selected="isConversationSelected(chat.id)"
|
<template #footer>
|
||||||
@select-conversation="selectConversation"
|
|
||||||
@de-select-conversation="deSelectConversation"
|
|
||||||
@assign-agent="onAssignAgent"
|
|
||||||
@assign-team="onAssignTeam"
|
|
||||||
@assign-label="onAssignLabels"
|
|
||||||
@update-conversation-status="toggleConversationStatus"
|
|
||||||
@context-menu-toggle="onContextMenuToggle"
|
|
||||||
@mark-as-unread="markAsUnread"
|
|
||||||
@assign-priority="assignPriority"
|
|
||||||
/>
|
|
||||||
<div v-if="chatListLoading" class="text-center">
|
<div v-if="chatListLoading" class="text-center">
|
||||||
<span class="spinner mt-4 mb-4" />
|
<span class="spinner mt-4 mb-4" />
|
||||||
</div>
|
</div>
|
||||||
@ -161,6 +151,8 @@
|
|||||||
:options="infiniteLoaderOptions"
|
:options="infiniteLoaderOptions"
|
||||||
@observed="loadMoreConversations"
|
@observed="loadMoreConversations"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
</virtual-list>
|
||||||
</div>
|
</div>
|
||||||
<woot-modal
|
<woot-modal
|
||||||
:show.sync="showAdvancedFilters"
|
:show.sync="showAdvancedFilters"
|
||||||
@ -183,11 +175,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import VirtualList from 'vue-virtual-scroll-list';
|
||||||
|
|
||||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
import ConversationItem from './ConversationItem.vue';
|
||||||
import timeMixin from '../mixins/time';
|
import timeMixin from '../mixins/time';
|
||||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||||
import conversationMixin from '../mixins/conversations';
|
import conversationMixin from '../mixins/conversations';
|
||||||
@ -220,12 +213,14 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
AddCustomViews,
|
AddCustomViews,
|
||||||
ChatTypeTabs,
|
ChatTypeTabs,
|
||||||
ConversationCard,
|
// eslint-disable-next-line vue/no-unused-components
|
||||||
|
ConversationItem,
|
||||||
ConversationAdvancedFilter,
|
ConversationAdvancedFilter,
|
||||||
DeleteCustomViews,
|
DeleteCustomViews,
|
||||||
ConversationBulkActions,
|
ConversationBulkActions,
|
||||||
ConversationBasicFilter,
|
ConversationBasicFilter,
|
||||||
IntersectionObserver,
|
IntersectionObserver,
|
||||||
|
VirtualList,
|
||||||
},
|
},
|
||||||
mixins: [
|
mixins: [
|
||||||
timeMixin,
|
timeMixin,
|
||||||
@ -235,6 +230,20 @@ export default {
|
|||||||
filterMixin,
|
filterMixin,
|
||||||
uiSettingsMixin,
|
uiSettingsMixin,
|
||||||
],
|
],
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
// Actions to be performed on virtual list item and context menu.
|
||||||
|
selectConversation: this.selectConversation,
|
||||||
|
deSelectConversation: this.deSelectConversation,
|
||||||
|
assignAgent: this.onAssignAgent,
|
||||||
|
assignTeam: this.onAssignTeam,
|
||||||
|
assignLabels: this.onAssignLabels,
|
||||||
|
updateConversationStatus: this.toggleConversationStatus,
|
||||||
|
toggleContextMenu: this.onContextMenuToggle,
|
||||||
|
markAsUnread: this.markAsUnread,
|
||||||
|
assignPriority: this.assignPriority,
|
||||||
|
};
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
conversationInbox: {
|
conversationInbox: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@ -289,6 +298,17 @@ export default {
|
|||||||
root: this.$refs.conversationList,
|
root: this.$refs.conversationList,
|
||||||
rootMargin: '100px 0px 100px 0px',
|
rootMargin: '100px 0px 100px 0px',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
itemComponent: ConversationItem,
|
||||||
|
// virtualListExtraProps is to pass the props to the conversationItem component.
|
||||||
|
virtualListExtraProps: {
|
||||||
|
label: this.label,
|
||||||
|
teamId: this.teamId,
|
||||||
|
foldersId: this.foldersId,
|
||||||
|
conversationType: this.conversationType,
|
||||||
|
showAssignee: false,
|
||||||
|
isConversationSelected: this.isConversationSelected,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -507,16 +527,22 @@ export default {
|
|||||||
},
|
},
|
||||||
label() {
|
label() {
|
||||||
this.resetAndFetchData();
|
this.resetAndFetchData();
|
||||||
|
this.updateVirtualListProps('label', this.label);
|
||||||
},
|
},
|
||||||
conversationType() {
|
conversationType() {
|
||||||
this.resetAndFetchData();
|
this.resetAndFetchData();
|
||||||
|
this.updateVirtualListProps('conversationType', this.conversationType);
|
||||||
},
|
},
|
||||||
activeFolder() {
|
activeFolder() {
|
||||||
this.resetAndFetchData();
|
this.resetAndFetchData();
|
||||||
|
this.updateVirtualListProps('foldersId', this.foldersId);
|
||||||
},
|
},
|
||||||
chatLists() {
|
chatLists() {
|
||||||
this.chatsOnView = this.conversationList;
|
this.chatsOnView = this.conversationList;
|
||||||
},
|
},
|
||||||
|
showAssigneeInConversationCard(newVal) {
|
||||||
|
this.updateVirtualListProps('showAssignee', newVal);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.setFiltersFromUISettings();
|
this.setFiltersFromUISettings();
|
||||||
@ -533,6 +559,12 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateVirtualListProps(key, value) {
|
||||||
|
this.virtualListExtraProps = {
|
||||||
|
...this.virtualListExtraProps,
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
onApplyFilter(payload) {
|
onApplyFilter(payload) {
|
||||||
this.resetBulkActions();
|
this.resetBulkActions();
|
||||||
this.foldersQuery = filterQueryGenerator(payload);
|
this.foldersQuery = filterQueryGenerator(payload);
|
||||||
@ -695,7 +727,7 @@ export default {
|
|||||||
fetchConversations() {
|
fetchConversations() {
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('fetchAllConversations', this.conversationFilters)
|
.dispatch('fetchAllConversations', this.conversationFilters)
|
||||||
.then(() => this.$emit('conversation-load'));
|
.then(this.emitConversationLoaded);
|
||||||
},
|
},
|
||||||
loadMoreConversations() {
|
loadMoreConversations() {
|
||||||
if (this.hasCurrentPageEndReached || this.chatListLoading) {
|
if (this.hasCurrentPageEndReached || this.chatListLoading) {
|
||||||
@ -719,7 +751,7 @@ export default {
|
|||||||
queryData: filterQueryGenerator(payload),
|
queryData: filterQueryGenerator(payload),
|
||||||
page,
|
page,
|
||||||
})
|
})
|
||||||
.then(() => this.$emit('conversation-load'));
|
.then(this.emitConversationLoaded);
|
||||||
this.showAdvancedFilters = false;
|
this.showAdvancedFilters = false;
|
||||||
},
|
},
|
||||||
fetchSavedFilteredConversations(payload) {
|
fetchSavedFilteredConversations(payload) {
|
||||||
@ -729,7 +761,7 @@ export default {
|
|||||||
queryData: payload,
|
queryData: payload,
|
||||||
page,
|
page,
|
||||||
})
|
})
|
||||||
.then(() => this.$emit('conversation-load'));
|
.then(this.emitConversationLoaded);
|
||||||
},
|
},
|
||||||
updateAssigneeTab(selectedTab) {
|
updateAssigneeTab(selectedTab) {
|
||||||
if (this.activeAssigneeTab !== selectedTab) {
|
if (this.activeAssigneeTab !== selectedTab) {
|
||||||
@ -741,6 +773,20 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
emitConversationLoaded() {
|
||||||
|
this.$emit('conversation-load');
|
||||||
|
this.$nextTick(() => {
|
||||||
|
// Addressing a known issue in the virtual list library where dynamically added items
|
||||||
|
// might not render correctly. This workaround involves a slight manual adjustment
|
||||||
|
// to the scroll position, triggering the list to refresh its rendering.
|
||||||
|
const virtualList = this.$refs.conversationVirtualList;
|
||||||
|
const scrollToOffset = virtualList?.scrollToOffset;
|
||||||
|
const currentOffset = virtualList?.getOffset() || 0;
|
||||||
|
if (scrollToOffset) {
|
||||||
|
scrollToOffset(currentOffset + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
resetBulkActions() {
|
resetBulkActions() {
|
||||||
this.selectedConversations = [];
|
this.selectedConversations = [];
|
||||||
this.selectedInboxes = [];
|
this.selectedInboxes = [];
|
||||||
|
|||||||
72
app/javascript/dashboard/components/ConversationItem.vue
Normal file
72
app/javascript/dashboard/components/ConversationItem.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<conversation-card
|
||||||
|
:key="source.id"
|
||||||
|
:active-label="label"
|
||||||
|
:team-id="teamId"
|
||||||
|
:folders-id="foldersId"
|
||||||
|
:chat="source"
|
||||||
|
:conversation-type="conversationType"
|
||||||
|
:selected="isConversationSelected(source.id)"
|
||||||
|
:show-assignee="showAssignee"
|
||||||
|
:enable-context-menu="true"
|
||||||
|
@select-conversation="selectConversation"
|
||||||
|
@de-select-conversation="deSelectConversation"
|
||||||
|
@assign-agent="assignAgent"
|
||||||
|
@assign-team="assignTeam"
|
||||||
|
@assign-label="assignLabels"
|
||||||
|
@update-conversation-status="updateConversationStatus"
|
||||||
|
@context-menu-toggle="toggleContextMenu"
|
||||||
|
@mark-as-unread="markAsUnread"
|
||||||
|
@assign-priority="assignPriority"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ConversationCard,
|
||||||
|
},
|
||||||
|
inject: [
|
||||||
|
'selectConversation',
|
||||||
|
'deSelectConversation',
|
||||||
|
'assignAgent',
|
||||||
|
'assignTeam',
|
||||||
|
'assignLabels',
|
||||||
|
'updateConversationStatus',
|
||||||
|
'toggleContextMenu',
|
||||||
|
'markAsUnread',
|
||||||
|
'assignPriority',
|
||||||
|
],
|
||||||
|
props: {
|
||||||
|
source: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
conversationType: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
foldersId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
isConversationSelected: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
showAssignee: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -31,7 +31,7 @@
|
|||||||
size="40px"
|
size="40px"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
|
class="px-0 py-3 border-b group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
<inbox-name v-if="showInboxName" :inbox="inbox" />
|
||||||
@ -175,6 +175,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
enableContextMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -289,6 +293,7 @@ export default {
|
|||||||
this.$emit(action, this.chat.id, this.inbox.id);
|
this.$emit(action, this.chat.id, this.inbox.id);
|
||||||
},
|
},
|
||||||
openContextMenu(e) {
|
openContextMenu(e) {
|
||||||
|
if (!this.enableContextMenu) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.$emit('context-menu-toggle', true);
|
this.$emit('context-menu-toggle', true);
|
||||||
this.contextMenu.x = e.pageX || e.clientX;
|
this.contextMenu.x = e.pageX || e.clientX;
|
||||||
|
|||||||
@ -91,6 +91,7 @@
|
|||||||
"vue-router": "~3.5.2",
|
"vue-router": "~3.5.2",
|
||||||
"vue-template-compiler": "^2.7.0",
|
"vue-template-compiler": "^2.7.0",
|
||||||
"vue-upload-component": "2.8.22",
|
"vue-upload-component": "2.8.22",
|
||||||
|
"vue-virtual-scroll-list": "^2.3.5",
|
||||||
"vue2-datepicker": "^3.9.1",
|
"vue2-datepicker": "^3.9.1",
|
||||||
"vuedraggable": "^2.24.3",
|
"vuedraggable": "^2.24.3",
|
||||||
"vuelidate": "0.7.7",
|
"vuelidate": "0.7.7",
|
||||||
|
|||||||
@ -20649,6 +20649,11 @@ vue-upload-component@2.8.22:
|
|||||||
resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.22.tgz#7a1573149a4afa5ca6e8c7e0bc70533925fe26b7"
|
resolved "https://registry.yarnpkg.com/vue-upload-component/-/vue-upload-component-2.8.22.tgz#7a1573149a4afa5ca6e8c7e0bc70533925fe26b7"
|
||||||
integrity sha512-AJpETqiZrgqs8bwJQpWTFrRg3i6s7cUodRRZVnb1f94Jvpd0YYfzGY4zluBqPmssNSkUaYu7EteXaK8aW17Osw==
|
integrity sha512-AJpETqiZrgqs8bwJQpWTFrRg3i6s7cUodRRZVnb1f94Jvpd0YYfzGY4zluBqPmssNSkUaYu7EteXaK8aW17Osw==
|
||||||
|
|
||||||
|
vue-virtual-scroll-list@^2.3.5:
|
||||||
|
version "2.3.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.5.tgz#b589ac6245faf857c35090f854e59d653e90626c"
|
||||||
|
integrity sha512-YFK6u5yltqtAOfTBcij/KGAS2SoZvzbNIAf9qTULauPObEp53xj22tDuohrrM2vNkgoD5kejXICIUBt2Q4ZDqQ==
|
||||||
|
|
||||||
vue2-datepicker@^3.9.1:
|
vue2-datepicker@^3.9.1:
|
||||||
version "3.9.1"
|
version "3.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/vue2-datepicker/-/vue2-datepicker-3.9.1.tgz#00d11cf30942e850f8b1a397af3c15c7465f248e"
|
resolved "https://registry.yarnpkg.com/vue2-datepicker/-/vue2-datepicker-3.9.1.tgz#00d11cf30942e850f8b1a397af3c15c7465f248e"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user