mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	 7c2ca1a07e
			
		
	
	7c2ca1a07e
	
	
	
		
			
			# Pull Request Template ## Description This PR fixes the issue where the saved `order_by` value for the conversation list is not being applied. The feature was originally added in PR https://github.com/chatwoot/chatwoot/pull/8237 but broke after merging the Vue 3 migration PR https://github.com/chatwoot/chatwoot/pull/10047 Fixes https://linear.app/chatwoot/issue/CW-4110/not-using-the-saved-sort-order-by-option-from-ui-settings **Cause of the Issue:** The previous implementation checked `orderBy` against the keys of the sorting constants instead of their values. Since `orderBy` stores a sorting value, this caused the condition to fail, leading to fallback to the default sorting option. **Solution:** The fix ensures that `orderBy` is validated against the values of the sorting constants rather than the keys. This correctly applies the saved sorting preference, while still falling back to the default if needed. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Loom video** https://www.loom.com/share/ebe8a4d3f1c041c6862334dc3b6d43a3?sid=5167feb7-eb4a-4f2c-8211-662830ba946c ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
		
			
				
	
	
		
			932 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			932 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup>
 | |
| // [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
 | |
| // composables and components, useVirtualChatList, useChatlistFilters
 | |
| import {
 | |
|   ref,
 | |
|   unref,
 | |
|   provide,
 | |
|   computed,
 | |
|   watch,
 | |
|   onMounted,
 | |
|   defineEmits,
 | |
| } from 'vue';
 | |
| import { useStore } from 'vuex';
 | |
| import { useRoute, useRouter } from 'vue-router';
 | |
| import {
 | |
|   useMapGetter,
 | |
|   useFunctionGetter,
 | |
| } from 'dashboard/composables/store.js';
 | |
| 
 | |
| // [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
 | |
| // from the current one. But we should consider using tanstack virtual in the future
 | |
| // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
 | |
| import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
 | |
| import ChatListHeader from './ChatListHeader.vue';
 | |
| import ConversationFilter from 'next/filter/ConversationFilter.vue';
 | |
| import SaveCustomView from 'next/filter/SaveCustomView.vue';
 | |
| import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
 | |
| import ConversationItem from './ConversationItem.vue';
 | |
| import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
 | |
| import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
 | |
| import IntersectionObserver from './IntersectionObserver.vue';
 | |
| 
 | |
| import { useUISettings } from 'dashboard/composables/useUISettings';
 | |
| import { useAlert } from 'dashboard/composables';
 | |
| import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
 | |
| import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
 | |
| import { useFilter } from 'shared/composables/useFilter';
 | |
| import { useTrack } from 'dashboard/composables';
 | |
| import { useI18n } from 'vue-i18n';
 | |
| import {
 | |
|   useCamelCase,
 | |
|   useSnakeCase,
 | |
| } from 'dashboard/composables/useTransformKeys';
 | |
| import { useEmitter } from 'dashboard/composables/emitter';
 | |
| import { useEventListener } from '@vueuse/core';
 | |
| 
 | |
| import { emitter } from 'shared/helpers/mitt';
 | |
| 
 | |
| import wootConstants from 'dashboard/constants/globals';
 | |
| import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
 | |
| import filterQueryGenerator from '../helper/filterQueryGenerator.js';
 | |
| import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
 | |
| import countries from 'shared/constants/countries';
 | |
| import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
 | |
| import { conversationListPageURL } from '../helper/URLHelper';
 | |
| import {
 | |
|   isOnMentionsView,
 | |
|   isOnUnattendedView,
 | |
| } from '../store/modules/conversations/helpers/actionHelpers';
 | |
| import {
 | |
|   getUserPermissions,
 | |
|   filterItemsByPermission,
 | |
| } from 'dashboard/helper/permissionsHelper.js';
 | |
| import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
 | |
| import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
 | |
| 
 | |
| import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
 | |
| 
 | |
| const props = defineProps({
 | |
|   conversationInbox: { type: [String, Number], default: 0 },
 | |
|   teamId: { type: [String, Number], default: 0 },
 | |
|   label: { type: String, default: '' },
 | |
|   conversationType: { type: String, default: '' },
 | |
|   foldersId: { type: [String, Number], default: 0 },
 | |
|   showConversationList: { default: true, type: Boolean },
 | |
|   isOnExpandedLayout: { default: false, type: Boolean },
 | |
| });
 | |
| 
 | |
| const emit = defineEmits(['conversationLoad']);
 | |
| const { uiSettings } = useUISettings();
 | |
| const { t } = useI18n();
 | |
| const router = useRouter();
 | |
| const store = useStore();
 | |
| 
 | |
| const conversationListRef = ref(null);
 | |
| const conversationDynamicScroller = ref(null);
 | |
| 
 | |
| const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
 | |
| const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
 | |
| const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
 | |
| const showAdvancedFilters = ref(false);
 | |
| // chatsOnView is to store the chats that are currently visible on the screen,
 | |
| // which mirrors the conversationList.
 | |
| const chatsOnView = ref([]);
 | |
| const foldersQuery = ref({});
 | |
| const showAddFoldersModal = ref(false);
 | |
| const showDeleteFoldersModal = ref(false);
 | |
| const isContextMenuOpen = ref(false);
 | |
| const appliedFilter = ref([]);
 | |
| const advancedFilterTypes = ref(
 | |
|   advancedFilterOptions.map(filter => ({
 | |
|     ...filter,
 | |
|     attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
 | |
|   }))
 | |
| );
 | |
| 
 | |
| const currentUser = useMapGetter('getCurrentUser');
 | |
| const chatLists = useMapGetter('getAllConversations');
 | |
| const mineChatsList = useMapGetter('getMineChats');
 | |
| const allChatList = useMapGetter('getAllStatusChats');
 | |
| const unAssignedChatsList = useMapGetter('getUnAssignedChats');
 | |
| const chatListLoading = useMapGetter('getChatListLoadingStatus');
 | |
| const activeInbox = useMapGetter('getSelectedInbox');
 | |
| const conversationStats = useMapGetter('conversationStats/getStats');
 | |
| const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
 | |
| const folders = useMapGetter('customViews/getConversationCustomViews');
 | |
| const agentList = useMapGetter('agents/getAgents');
 | |
| const teamsList = useMapGetter('teams/getTeams');
 | |
| const inboxesList = useMapGetter('inboxes/getInboxes');
 | |
| const campaigns = useMapGetter('campaigns/getAllCampaigns');
 | |
| const labels = useMapGetter('labels/getLabels');
 | |
| const currentAccountId = useMapGetter('getCurrentAccountId');
 | |
| // We can't useFunctionGetter here since it needs to be called on setup?
 | |
| const getTeamFn = useMapGetter('teams/getTeam');
 | |
| 
 | |
| useChatListKeyboardEvents(conversationListRef);
 | |
| const {
 | |
|   selectedConversations,
 | |
|   selectedInboxes,
 | |
|   selectConversation,
 | |
|   deSelectConversation,
 | |
|   selectAllConversations,
 | |
|   resetBulkActions,
 | |
|   isConversationSelected,
 | |
|   onAssignAgent,
 | |
|   onAssignLabels,
 | |
|   onAssignTeamsForBulk,
 | |
|   onUpdateConversations,
 | |
| } = useBulkActions();
 | |
| 
 | |
| const {
 | |
|   initializeStatusAndAssigneeFilterToModal,
 | |
|   initializeInboxTeamAndLabelFilterToModal,
 | |
| } = useFilter({
 | |
|   filteri18nKey: 'FILTER',
 | |
|   attributeModel: 'conversation_attribute',
 | |
| });
 | |
| 
 | |
| // computed
 | |
| const intersectionObserverOptions = computed(() => {
 | |
|   return {
 | |
|     root: conversationListRef.value,
 | |
|     rootMargin: '100px 0px 100px 0px',
 | |
|   };
 | |
| });
 | |
| 
 | |
| const hasAppliedFilters = computed(() => {
 | |
|   return appliedFilters.value.length !== 0;
 | |
| });
 | |
| 
 | |
| const activeFolder = computed(() => {
 | |
|   if (props.foldersId) {
 | |
|     const activeView = folders.value.filter(
 | |
|       view => view.id === Number(props.foldersId)
 | |
|     );
 | |
|     const [firstValue] = activeView;
 | |
|     return firstValue;
 | |
|   }
 | |
|   return undefined;
 | |
| });
 | |
| 
 | |
| const activeFolderName = computed(() => {
 | |
|   return activeFolder.value?.name;
 | |
| });
 | |
| 
 | |
| const hasActiveFolders = computed(() => {
 | |
|   return Boolean(activeFolder.value && props.foldersId !== 0);
 | |
| });
 | |
| 
 | |
| const hasAppliedFiltersOrActiveFolders = computed(() => {
 | |
|   return hasAppliedFilters.value || hasActiveFolders.value;
 | |
| });
 | |
| 
 | |
| const currentUserDetails = computed(() => {
 | |
|   const { id, name } = currentUser.value;
 | |
|   return { id, name };
 | |
| });
 | |
| 
 | |
| const userPermissions = computed(() => {
 | |
|   return getUserPermissions(currentUser.value, currentAccountId.value);
 | |
| });
 | |
| 
 | |
| const assigneeTabItems = computed(() => {
 | |
|   return filterItemsByPermission(
 | |
|     ASSIGNEE_TYPE_TAB_PERMISSIONS,
 | |
|     userPermissions.value,
 | |
|     item => item.permissions
 | |
|   ).map(({ key, count: countKey }) => ({
 | |
|     key,
 | |
|     name: t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
 | |
|     count: conversationStats.value[countKey] || 0,
 | |
|   }));
 | |
| });
 | |
| 
 | |
| const showAssigneeInConversationCard = computed(() => {
 | |
|   return (
 | |
|     hasAppliedFiltersOrActiveFolders.value ||
 | |
|     activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ALL
 | |
|   );
 | |
| });
 | |
| 
 | |
| const currentPageFilterKey = computed(() => {
 | |
|   return hasAppliedFiltersOrActiveFolders.value
 | |
|     ? 'appliedFilters'
 | |
|     : activeAssigneeTab.value;
 | |
| });
 | |
| 
 | |
| const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
 | |
| const currentPage = useFunctionGetter(
 | |
|   'conversationPage/getCurrentPageFilter',
 | |
|   activeAssigneeTab
 | |
| );
 | |
| const currentFiltersPage = useFunctionGetter(
 | |
|   'conversationPage/getCurrentPageFilter',
 | |
|   currentPageFilterKey
 | |
| );
 | |
| const hasCurrentPageEndReached = useFunctionGetter(
 | |
|   'conversationPage/getHasEndReached',
 | |
|   currentPageFilterKey
 | |
| );
 | |
| 
 | |
| const conversationCustomAttributes = useFunctionGetter(
 | |
|   'attributes/getAttributesByModel',
 | |
|   'conversation_attribute'
 | |
| );
 | |
| 
 | |
| const activeAssigneeTabCount = computed(() => {
 | |
|   const count = assigneeTabItems.value.find(
 | |
|     item => item.key === activeAssigneeTab.value
 | |
|   ).count;
 | |
|   return count;
 | |
| });
 | |
| 
 | |
| const conversationListPagination = computed(() => {
 | |
|   const conversationsPerPage = 25;
 | |
|   const hasChatsOnView =
 | |
|     chatsOnView.value &&
 | |
|     Array.isArray(chatsOnView.value) &&
 | |
|     !chatsOnView.value.length;
 | |
|   const isNoFiltersOrFoldersAndChatListNotEmpty =
 | |
|     !hasAppliedFiltersOrActiveFolders.value && hasChatsOnView;
 | |
|   const isUnderPerPage =
 | |
|     chatsOnView.value.length < conversationsPerPage &&
 | |
|     activeAssigneeTabCount.value < conversationsPerPage &&
 | |
|     activeAssigneeTabCount.value > chatsOnView.value.length;
 | |
| 
 | |
|   if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
 | |
|     return 1;
 | |
|   }
 | |
| 
 | |
|   return currentPage.value + 1;
 | |
| });
 | |
| 
 | |
| const conversationFilters = computed(() => {
 | |
|   return {
 | |
|     inboxId: props.conversationInbox ? props.conversationInbox : undefined,
 | |
|     assigneeType: activeAssigneeTab.value,
 | |
|     status: activeStatus.value,
 | |
|     sortBy: activeSortBy.value,
 | |
|     page: conversationListPagination.value,
 | |
|     labels: props.label ? [props.label] : undefined,
 | |
|     teamId: props.teamId || undefined,
 | |
|     conversationType: props.conversationType || undefined,
 | |
|   };
 | |
| });
 | |
| 
 | |
| const activeTeam = computed(() => {
 | |
|   if (props.teamId) {
 | |
|     return getTeamFn.value(props.teamId);
 | |
|   }
 | |
|   return {};
 | |
| });
 | |
| 
 | |
| const pageTitle = computed(() => {
 | |
|   if (hasAppliedFilters.value) {
 | |
|     return t('CHAT_LIST.TAB_HEADING');
 | |
|   }
 | |
|   if (inbox.value.name) {
 | |
|     return inbox.value.name;
 | |
|   }
 | |
|   if (activeTeam.value.name) {
 | |
|     return activeTeam.value.name;
 | |
|   }
 | |
|   if (props.label) {
 | |
|     return `#${props.label}`;
 | |
|   }
 | |
|   if (props.conversationType === 'mention') {
 | |
|     return t('CHAT_LIST.MENTION_HEADING');
 | |
|   }
 | |
|   if (props.conversationType === 'participating') {
 | |
|     return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
 | |
|   }
 | |
|   if (props.conversationType === 'unattended') {
 | |
|     return t('CHAT_LIST.UNATTENDED_HEADING');
 | |
|   }
 | |
|   if (hasActiveFolders.value) {
 | |
|     return activeFolder.value.name;
 | |
|   }
 | |
|   return t('CHAT_LIST.TAB_HEADING');
 | |
| });
 | |
| 
 | |
| const conversationList = computed(() => {
 | |
|   let localConversationList = [];
 | |
| 
 | |
|   if (!hasAppliedFiltersOrActiveFolders.value) {
 | |
|     const filters = conversationFilters.value;
 | |
|     if (activeAssigneeTab.value === 'me') {
 | |
|       localConversationList = [...mineChatsList.value(filters)];
 | |
|     } else if (activeAssigneeTab.value === 'unassigned') {
 | |
|       localConversationList = [...unAssignedChatsList.value(filters)];
 | |
|     } else {
 | |
|       localConversationList = [...allChatList.value(filters)];
 | |
|     }
 | |
|   } else {
 | |
|     localConversationList = [...chatLists.value];
 | |
|   }
 | |
|   return localConversationList;
 | |
| });
 | |
| 
 | |
| const showEndOfListMessage = computed(() => {
 | |
|   return (
 | |
|     conversationList.value.length &&
 | |
|     hasCurrentPageEndReached.value &&
 | |
|     !chatListLoading.value
 | |
|   );
 | |
| });
 | |
| 
 | |
| const allConversationsSelected = computed(() => {
 | |
|   return (
 | |
|     conversationList.value.length === selectedConversations.value.length &&
 | |
|     conversationList.value.every(el =>
 | |
|       selectedConversations.value.includes(el.id)
 | |
|     )
 | |
|   );
 | |
| });
 | |
| 
 | |
| const uniqueInboxes = computed(() => {
 | |
|   return [...new Set(selectedInboxes.value)];
 | |
| });
 | |
| 
 | |
| // ---------------------- Methods -----------------------
 | |
| function setFiltersFromUISettings() {
 | |
|   const { conversations_filter_by: filterBy = {} } = uiSettings.value;
 | |
|   const { status, order_by: orderBy } = filterBy;
 | |
|   activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
 | |
|   activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
 | |
|     orderBy
 | |
|   )
 | |
|     ? orderBy
 | |
|     : wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
 | |
| }
 | |
| 
 | |
| function emitConversationLoaded() {
 | |
|   emit('conversationLoad');
 | |
|   // [VITE] removing this since the library has changed
 | |
|   // 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 = conversationListRef.value;
 | |
|   //   const scrollToOffset = virtualList?.scrollToOffset;
 | |
|   //   const currentOffset = virtualList?.getOffset() || 0;
 | |
|   //   if (scrollToOffset) {
 | |
|   //     scrollToOffset(currentOffset + 1);
 | |
|   //   }
 | |
|   // });
 | |
| }
 | |
| 
 | |
| function fetchFilteredConversations(payload) {
 | |
|   payload = useSnakeCase(payload);
 | |
|   let page = currentFiltersPage.value + 1;
 | |
|   store
 | |
|     .dispatch('fetchFilteredConversations', {
 | |
|       queryData: filterQueryGenerator(payload),
 | |
|       page,
 | |
|     })
 | |
|     .then(emitConversationLoaded);
 | |
| 
 | |
|   showAdvancedFilters.value = false;
 | |
| }
 | |
| 
 | |
| function fetchSavedFilteredConversations(payload) {
 | |
|   payload = useSnakeCase(payload);
 | |
|   let page = currentFiltersPage.value + 1;
 | |
|   store
 | |
|     .dispatch('fetchFilteredConversations', {
 | |
|       queryData: payload,
 | |
|       page,
 | |
|     })
 | |
|     .then(emitConversationLoaded);
 | |
| }
 | |
| 
 | |
| function onApplyFilter(payload) {
 | |
|   payload = useSnakeCase(payload);
 | |
|   resetBulkActions();
 | |
|   foldersQuery.value = filterQueryGenerator(payload);
 | |
|   store.dispatch('conversationPage/reset');
 | |
|   store.dispatch('emptyAllConversations');
 | |
|   fetchFilteredConversations(payload);
 | |
| }
 | |
| 
 | |
| function closeAdvanceFiltersModal() {
 | |
|   showAdvancedFilters.value = false;
 | |
|   appliedFilter.value = [];
 | |
| }
 | |
| 
 | |
| function onUpdateSavedFilter(payload, folderName) {
 | |
|   const transformedPayload = useSnakeCase(payload);
 | |
|   const payloadData = {
 | |
|     ...unref(activeFolder),
 | |
|     name: unref(folderName),
 | |
|     query: filterQueryGenerator(transformedPayload),
 | |
|   };
 | |
|   store.dispatch('customViews/update', payloadData);
 | |
|   closeAdvanceFiltersModal();
 | |
| }
 | |
| 
 | |
| function onClickOpenAddFoldersModal() {
 | |
|   showAddFoldersModal.value = true;
 | |
| }
 | |
| 
 | |
| function onCloseAddFoldersModal() {
 | |
|   showAddFoldersModal.value = false;
 | |
| }
 | |
| 
 | |
| function onClickOpenDeleteFoldersModal() {
 | |
|   showDeleteFoldersModal.value = true;
 | |
| }
 | |
| 
 | |
| function onCloseDeleteFoldersModal() {
 | |
|   showDeleteFoldersModal.value = false;
 | |
| }
 | |
| 
 | |
| function setParamsForEditFolderModal() {
 | |
|   // Here we are setting the params for edit folder modal to show the existing values.
 | |
| 
 | |
|   // For agent, team, inboxes,and campaigns we get only the id's from the query.
 | |
|   // So we are mapping the id's to the actual values.
 | |
| 
 | |
|   // For labels we get the name of the label from the query.
 | |
|   // If we delete the label from the label list then we will not be able to show the label name.
 | |
| 
 | |
|   // For custom attributes we get only attribute key.
 | |
|   // So we are mapping it to find the input type of the attribute to show in the edit folder modal.
 | |
|   return {
 | |
|     agents: agentList.value,
 | |
|     teams: teamsList.value,
 | |
|     inboxes: inboxesList.value,
 | |
|     labels: labels.value,
 | |
|     campaigns: campaigns.value,
 | |
|     languages: languages,
 | |
|     countries: countries,
 | |
|     filterTypes: advancedFilterTypes.value,
 | |
|     allCustomAttributes: conversationCustomAttributes.value,
 | |
|   };
 | |
| }
 | |
| 
 | |
| function initializeExistingFilterToModal() {
 | |
|   const statusFilter = initializeStatusAndAssigneeFilterToModal(
 | |
|     activeStatus.value,
 | |
|     currentUserDetails.value,
 | |
|     activeAssigneeTab.value
 | |
|   );
 | |
|   // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
 | |
|   if (statusFilter) {
 | |
|     appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
 | |
|   }
 | |
| 
 | |
|   // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
 | |
|   const otherFilters = initializeInboxTeamAndLabelFilterToModal(
 | |
|     props.conversationInbox,
 | |
|     inbox.value,
 | |
|     props.teamId,
 | |
|     activeTeam.value,
 | |
|     props.label
 | |
|   ).map(useCamelCase);
 | |
| 
 | |
|   appliedFilter.value = [...appliedFilter.value, ...otherFilters];
 | |
| }
 | |
| 
 | |
| function initializeFolderToFilterModal(newActiveFolder) {
 | |
|   // Here we are setting the params for edit folder modal.
 | |
|   //  To show the existing values. when we click on edit folder button.
 | |
| 
 | |
|   // Here we get the query from the active folder.
 | |
|   // And we are mapping the query to the actual values.
 | |
|   // To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
 | |
|   const query = unref(newActiveFolder)?.query?.payload;
 | |
|   if (!Array.isArray(query)) return;
 | |
| 
 | |
|   const newFilters = query.map(filter => {
 | |
|     const transformed = useCamelCase(filter);
 | |
|     const values = Array.isArray(transformed.values)
 | |
|       ? generateValuesForEditCustomViews(
 | |
|           useSnakeCase(filter),
 | |
|           setParamsForEditFolderModal()
 | |
|         )
 | |
|       : [];
 | |
| 
 | |
|     return {
 | |
|       attributeKey: transformed.attributeKey,
 | |
|       attributeModel: transformed.attributeModel,
 | |
|       customAttributeType: transformed.customAttributeType,
 | |
|       filterOperator: transformed.filterOperator,
 | |
|       queryOperator: transformed.queryOperator ?? 'and',
 | |
|       values,
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   appliedFilter.value = [...appliedFilter.value, ...newFilters];
 | |
| }
 | |
| 
 | |
| function initalizeAppliedFiltersToModal() {
 | |
|   appliedFilter.value = [...appliedFilters.value];
 | |
| }
 | |
| 
 | |
| function onToggleAdvanceFiltersModal() {
 | |
|   if (showAdvancedFilters.value === true) {
 | |
|     closeAdvanceFiltersModal();
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!hasAppliedFilters.value && !hasActiveFolders.value) {
 | |
|     initializeExistingFilterToModal();
 | |
|   }
 | |
|   if (hasActiveFolders.value) {
 | |
|     initializeFolderToFilterModal(activeFolder.value);
 | |
|   }
 | |
|   if (hasAppliedFilters.value) {
 | |
|     initalizeAppliedFiltersToModal();
 | |
|   }
 | |
| 
 | |
|   showAdvancedFilters.value = true;
 | |
| }
 | |
| 
 | |
| function fetchConversations() {
 | |
|   store.dispatch('updateChatListFilters', conversationFilters.value);
 | |
|   store.dispatch('fetchAllConversations').then(emitConversationLoaded);
 | |
| }
 | |
| 
 | |
| function resetAndFetchData() {
 | |
|   appliedFilter.value = [];
 | |
|   resetBulkActions();
 | |
|   store.dispatch('conversationPage/reset');
 | |
|   store.dispatch('emptyAllConversations');
 | |
|   store.dispatch('clearConversationFilters');
 | |
|   if (hasActiveFolders.value) {
 | |
|     const payload = activeFolder.value.query;
 | |
|     fetchSavedFilteredConversations(payload);
 | |
|   }
 | |
|   if (props.foldersId) {
 | |
|     return;
 | |
|   }
 | |
|   fetchConversations();
 | |
| }
 | |
| 
 | |
| function loadMoreConversations() {
 | |
|   if (hasCurrentPageEndReached.value || chatListLoading.value) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!hasAppliedFiltersOrActiveFolders.value) {
 | |
|     fetchConversations();
 | |
|   } else if (hasActiveFolders.value) {
 | |
|     const payload = activeFolder.value.query;
 | |
|     fetchSavedFilteredConversations(payload);
 | |
|   } else if (hasAppliedFilters.value) {
 | |
|     fetchFilteredConversations(appliedFilters.value);
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Add a method to handle scroll events
 | |
| function handleScroll() {
 | |
|   const scroller = conversationDynamicScroller.value;
 | |
|   if (scroller && scroller.hasScrollbar) {
 | |
|     const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
 | |
|     if (scrollHeight - (scrollTop + clientHeight) < 100) {
 | |
|       loadMoreConversations();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function updateAssigneeTab(selectedTab) {
 | |
|   if (activeAssigneeTab.value !== selectedTab) {
 | |
|     resetBulkActions();
 | |
|     emitter.emit('clearSearchInput');
 | |
|     activeAssigneeTab.value = selectedTab;
 | |
|     if (!currentPage.value) {
 | |
|       fetchConversations();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function onBasicFilterChange(value, type) {
 | |
|   if (type === 'status') {
 | |
|     activeStatus.value = value;
 | |
|   } else {
 | |
|     activeSortBy.value = value;
 | |
|   }
 | |
|   resetAndFetchData();
 | |
| }
 | |
| 
 | |
| function openLastSavedItemInFolder() {
 | |
|   const lastItemOfFolder = folders.value[folders.value.length - 1];
 | |
|   const lastItemId = lastItemOfFolder.id;
 | |
|   router.push({
 | |
|     name: 'folder_conversations',
 | |
|     params: { id: lastItemId },
 | |
|   });
 | |
| }
 | |
| 
 | |
| function openLastItemAfterDeleteInFolder() {
 | |
|   if (folders.value.length > 0) {
 | |
|     openLastSavedItemInFolder();
 | |
|   } else {
 | |
|     router.push({ name: 'home' });
 | |
|     fetchConversations();
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function assignPriority(priority, conversationId = null) {
 | |
|   store.dispatch('setCurrentChatPriority', {
 | |
|     priority,
 | |
|     conversationId,
 | |
|   });
 | |
|   store.dispatch('assignPriority', { conversationId, priority }).then(() => {
 | |
|     useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
 | |
|       newValue: priority,
 | |
|       from: 'Context menu',
 | |
|     });
 | |
|     useAlert(
 | |
|       t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
 | |
|         priority,
 | |
|         conversationId,
 | |
|       })
 | |
|     );
 | |
|   });
 | |
| }
 | |
| 
 | |
| async function markAsUnread(conversationId) {
 | |
|   try {
 | |
|     await store.dispatch('markMessagesUnread', {
 | |
|       id: conversationId,
 | |
|     });
 | |
|     const {
 | |
|       params: { accountId, inbox_id: inboxId, label, teamId },
 | |
|       name,
 | |
|     } = useRoute();
 | |
|     let conversationType = '';
 | |
|     if (isOnMentionsView({ route: { name } })) {
 | |
|       conversationType = 'mention';
 | |
|     } else if (isOnUnattendedView({ route: { name } })) {
 | |
|       conversationType = 'unattended';
 | |
|     }
 | |
|     router.push(
 | |
|       conversationListPageURL({
 | |
|         accountId,
 | |
|         conversationType: conversationType,
 | |
|         customViewId: props.foldersId,
 | |
|         inboxId,
 | |
|         label,
 | |
|         teamId,
 | |
|       })
 | |
|     );
 | |
|   } catch (error) {
 | |
|     // Ignore error
 | |
|   }
 | |
| }
 | |
| async function markAsRead(conversationId) {
 | |
|   try {
 | |
|     await store.dispatch('markMessagesRead', {
 | |
|       id: conversationId,
 | |
|     });
 | |
|   } catch (error) {
 | |
|     // Ignore error
 | |
|   }
 | |
| }
 | |
| async function onAssignTeam(team, conversationId = null) {
 | |
|   try {
 | |
|     await store.dispatch('assignTeam', {
 | |
|       conversationId,
 | |
|       teamId: team.id,
 | |
|     });
 | |
|     useAlert(
 | |
|       t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
 | |
|         team: team.name,
 | |
|         conversationId,
 | |
|       })
 | |
|     );
 | |
|   } catch (error) {
 | |
|     useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
 | |
|   }
 | |
| }
 | |
| 
 | |
| function toggleConversationStatus(conversationId, status, snoozedUntil) {
 | |
|   store
 | |
|     .dispatch('toggleStatus', {
 | |
|       conversationId,
 | |
|       status,
 | |
|       snoozedUntil,
 | |
|     })
 | |
|     .then(() => {
 | |
|       useAlert(t('CONVERSATION.CHANGE_STATUS'));
 | |
|     });
 | |
| }
 | |
| 
 | |
| function allSelectedConversationsStatus(status) {
 | |
|   if (!selectedConversations.value.length) return false;
 | |
|   return selectedConversations.value.every(item => {
 | |
|     return store.getters.getConversationById(item)?.status === status;
 | |
|   });
 | |
| }
 | |
| 
 | |
| function onContextMenuToggle(state) {
 | |
|   isContextMenuOpen.value = state;
 | |
| }
 | |
| 
 | |
| function toggleSelectAll(check) {
 | |
|   selectAllConversations(check, conversationList);
 | |
| }
 | |
| 
 | |
| useEmitter('fetch_conversation_stats', () => {
 | |
|   store.dispatch('conversationStats/get', conversationFilters.value);
 | |
| });
 | |
| 
 | |
| useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
 | |
| 
 | |
| onMounted(() => {
 | |
|   store.dispatch('setChatListFilters', conversationFilters.value);
 | |
|   setFiltersFromUISettings();
 | |
|   store.dispatch('setChatStatusFilter', activeStatus.value);
 | |
|   store.dispatch('setChatSortFilter', activeSortBy.value);
 | |
|   resetAndFetchData();
 | |
|   if (hasActiveFolders.value) {
 | |
|     store.dispatch('campaigns/get');
 | |
|   }
 | |
| });
 | |
| 
 | |
| provide('selectConversation', selectConversation);
 | |
| provide('deSelectConversation', deSelectConversation);
 | |
| provide('assignAgent', onAssignAgent);
 | |
| provide('assignTeam', onAssignTeam);
 | |
| provide('assignLabels', onAssignLabels);
 | |
| provide('updateConversationStatus', toggleConversationStatus);
 | |
| provide('toggleContextMenu', onContextMenuToggle);
 | |
| provide('markAsUnread', markAsUnread);
 | |
| provide('markAsRead', markAsRead);
 | |
| provide('assignPriority', assignPriority);
 | |
| provide('isConversationSelected', isConversationSelected);
 | |
| 
 | |
| watch(activeTeam, () => resetAndFetchData());
 | |
| 
 | |
| watch(
 | |
|   computed(() => props.conversationInbox),
 | |
|   () => resetAndFetchData()
 | |
| );
 | |
| watch(
 | |
|   computed(() => props.label),
 | |
|   () => resetAndFetchData()
 | |
| );
 | |
| watch(
 | |
|   computed(() => props.conversationType),
 | |
|   () => resetAndFetchData()
 | |
| );
 | |
| 
 | |
| watch(activeFolder, (newVal, oldVal) => {
 | |
|   if (newVal !== oldVal) {
 | |
|     store.dispatch('customViews/setActiveConversationFolder', newVal || null);
 | |
|   }
 | |
|   resetAndFetchData();
 | |
| });
 | |
| 
 | |
| watch(chatLists, () => {
 | |
|   chatsOnView.value = conversationList.value;
 | |
| });
 | |
| 
 | |
| watch(conversationFilters, (newVal, oldVal) => {
 | |
|   if (newVal !== oldVal) {
 | |
|     store.dispatch('updateChatListFilters', newVal);
 | |
|   }
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div
 | |
|     class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
 | |
|     :class="[
 | |
|       { hidden: !showConversationList },
 | |
|       isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
 | |
|     ]"
 | |
|   >
 | |
|     <slot />
 | |
|     <ChatListHeader
 | |
|       :page-title="pageTitle"
 | |
|       :has-applied-filters="hasAppliedFilters"
 | |
|       :has-active-folders="hasActiveFolders"
 | |
|       :active-status="activeStatus"
 | |
|       :is-on-expanded-layout="isOnExpandedLayout"
 | |
|       @add-folders="onClickOpenAddFoldersModal"
 | |
|       @delete-folders="onClickOpenDeleteFoldersModal"
 | |
|       @filters-modal="onToggleAdvanceFiltersModal"
 | |
|       @reset-filters="resetAndFetchData"
 | |
|       @basic-filter-change="onBasicFilterChange"
 | |
|     />
 | |
| 
 | |
|     <Teleport v-if="showAddFoldersModal" to="#saveFilterTeleportTarget">
 | |
|       <SaveCustomView
 | |
|         v-model="appliedFilter"
 | |
|         :custom-views-query="foldersQuery"
 | |
|         :open-last-saved-item="openLastSavedItemInFolder"
 | |
|         @close="onCloseAddFoldersModal"
 | |
|       />
 | |
|     </Teleport>
 | |
| 
 | |
|     <DeleteCustomViews
 | |
|       v-if="showDeleteFoldersModal"
 | |
|       v-model:show="showDeleteFoldersModal"
 | |
|       :active-custom-view="activeFolder"
 | |
|       :custom-views-id="foldersId"
 | |
|       :open-last-item-after-delete="openLastItemAfterDeleteInFolder"
 | |
|       @close="onCloseDeleteFoldersModal"
 | |
|     />
 | |
| 
 | |
|     <ChatTypeTabs
 | |
|       v-if="!hasAppliedFiltersOrActiveFolders"
 | |
|       :items="assigneeTabItems"
 | |
|       :active-tab="activeAssigneeTab"
 | |
|       is-compact
 | |
|       @chat-tab-change="updateAssigneeTab"
 | |
|     />
 | |
| 
 | |
|     <p
 | |
|       v-if="!chatListLoading && !conversationList.length"
 | |
|       class="flex items-center justify-center p-4 overflow-auto"
 | |
|     >
 | |
|       {{ $t('CHAT_LIST.LIST.404') }}
 | |
|     </p>
 | |
|     <ConversationBulkActions
 | |
|       v-if="selectedConversations.length"
 | |
|       :conversations="selectedConversations"
 | |
|       :all-conversations-selected="allConversationsSelected"
 | |
|       :selected-inboxes="uniqueInboxes"
 | |
|       :show-open-action="allSelectedConversationsStatus('open')"
 | |
|       :show-resolved-action="allSelectedConversationsStatus('resolved')"
 | |
|       :show-snoozed-action="allSelectedConversationsStatus('snoozed')"
 | |
|       @select-all-conversations="toggleSelectAll"
 | |
|       @assign-agent="onAssignAgent"
 | |
|       @update-conversations="onUpdateConversations"
 | |
|       @assign-labels="onAssignLabels"
 | |
|       @assign-team="onAssignTeamsForBulk"
 | |
|     />
 | |
|     <div
 | |
|       ref="conversationListRef"
 | |
|       class="flex-1 overflow-hidden conversations-list hover:overflow-y-auto"
 | |
|       :class="{ 'overflow-hidden': isContextMenuOpen }"
 | |
|     >
 | |
|       <DynamicScroller
 | |
|         ref="conversationDynamicScroller"
 | |
|         :items="conversationList"
 | |
|         :min-item-size="24"
 | |
|         class="w-full h-full overflow-auto"
 | |
|       >
 | |
|         <template #default="{ item, index, active }">
 | |
|           <!--
 | |
|             If we encounter resizing issues, we can set the `watchData` prop to true
 | |
|             this will deeply watch the entire object instead of just size dependencies
 | |
|             But it can impact performance
 | |
|           -->
 | |
|           <DynamicScrollerItem
 | |
|             :item="item"
 | |
|             :active="active"
 | |
|             :data-index="index"
 | |
|             :size-dependencies="[
 | |
|               item.messages,
 | |
|               item.labels,
 | |
|               item.uuid,
 | |
|               item.inbox_id,
 | |
|             ]"
 | |
|           >
 | |
|             <ConversationItem
 | |
|               :source="item"
 | |
|               :label="label"
 | |
|               :team-id="teamId"
 | |
|               :folders-id="foldersId"
 | |
|               :conversation-type="conversationType"
 | |
|               :show-assignee="showAssigneeInConversationCard"
 | |
|               @select-conversation="selectConversation"
 | |
|               @de-select-conversation="deSelectConversation"
 | |
|             />
 | |
|           </DynamicScrollerItem>
 | |
|         </template>
 | |
|         <template #after>
 | |
|           <div v-if="chatListLoading" class="text-center">
 | |
|             <span class="mt-4 mb-4 spinner" />
 | |
|           </div>
 | |
|           <p
 | |
|             v-else-if="showEndOfListMessage"
 | |
|             class="p-4 text-center text-slate-400 dark:text-slate-300"
 | |
|           >
 | |
|             {{ $t('CHAT_LIST.EOF') }}
 | |
|           </p>
 | |
|           <IntersectionObserver
 | |
|             v-else
 | |
|             :options="intersectionObserverOptions"
 | |
|             @observed="loadMoreConversations"
 | |
|           />
 | |
|         </template>
 | |
|       </DynamicScroller>
 | |
|     </div>
 | |
|     <Teleport v-if="showAdvancedFilters" to="#conversationFilterTeleportTarget">
 | |
|       <ConversationFilter
 | |
|         v-model="appliedFilter"
 | |
|         :folder-name="activeFolderName"
 | |
|         :is-folder-view="hasActiveFolders"
 | |
|         @apply-filter="onApplyFilter"
 | |
|         @update-folder="onUpdateSavedFilter"
 | |
|         @close="closeAdvanceFiltersModal"
 | |
|       />
 | |
|     </Teleport>
 | |
|   </div>
 | |
| </template>
 |