feat: Updated conversation list UI (#12971)

This commit is contained in:
Sivin Varghese
2025-12-03 15:46:16 +05:30
committed by GitHub
parent eccf3dd8f6
commit 99f17891d0
30 changed files with 1670 additions and 1628 deletions
+94 -479
View File
@@ -1,17 +1,7 @@
<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 { ref, provide, computed, watch, onMounted, defineEmits } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import {
useMapGetter,
useFunctionGetter,
@@ -19,48 +9,24 @@ import {
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import ChatListFilters from './ChatListFilters.vue';
import ConversationActiveFiltersPreview from './widgets/conversation/ConversationActiveFiltersPreview.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
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 { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
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 { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
const props = defineProps({
@@ -76,34 +42,20 @@ const props = defineProps({
const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const router = useRouter();
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 chatsOnView = ref([]);
const isContextMenuOpen = ref(false);
const chatListFiltersRef = ref(null);
// Provide trigger for label recalculation (parent → child pattern)
const recalculateLabelsKey = ref(0);
provide('recalculateLabelsKey', recalculateLabelsKey);
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('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
@@ -113,15 +65,9 @@ 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');
const folders = useMapGetter('customViews/getConversationCustomViews');
const {
selectedConversations,
@@ -131,55 +77,24 @@ const {
selectAllConversations,
resetBulkActions,
isConversationSelected,
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
// computed
// computed
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);
return Boolean(props.foldersId !== 0);
});
const activeFolder = computed(() => {
return chatListFiltersRef.value?.activeFolder;
});
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);
});
@@ -223,11 +138,6 @@ const hasCurrentPageEndReached = useFunctionGetter(
currentPageFilterKey
);
const conversationCustomAttributes = useFunctionGetter(
'attributes/getAttributesByModel',
'conversation_attribute'
);
const activeAssigneeTabCount = computed(() => {
const count = assigneeTabItems.value.find(
item => item.key === activeAssigneeTab.value
@@ -297,7 +207,7 @@ const pageTitle = computed(() => {
if (props.conversationType === 'unattended') {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
if (hasActiveFolders.value && activeFolder.value?.name) {
return activeFolder.value.name;
}
return t('CHAT_LIST.TAB_HEADING');
@@ -319,7 +229,7 @@ const conversationList = computed(() => {
localConversationList = [...chatLists.value];
}
if (activeFolder.value) {
if (activeFolder.value?.query?.payload) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
@@ -364,205 +274,71 @@ function setFiltersFromUISettings() {
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);
const transformedPayload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
queryData: filterQueryGenerator(transformedPayload),
page,
})
.then(emitConversationLoaded);
showAdvancedFilters.value = false;
}
function fetchSavedFilteredConversations(payload) {
payload = useSnakeCase(payload);
const transformedPayload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: payload,
queryData: transformedPayload,
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,
priority: [
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
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 onApplyFilter(payload) {
resetBulkActions();
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
fetchFilteredConversations(payload);
}
function onOpenAddFolders() {
const lastItemOfFolder = folders.value[folders.value.length - 1];
const lastItemId = lastItemOfFolder.id;
router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
}
function onOpenDeleteFolders() {
if (folders.value.length > 0) {
const lastItemOfFolder = folders.value[folders.value.length - 1];
const lastItemId = lastItemOfFolder.id;
router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
} else {
router.push({ name: 'home' });
fetchConversations();
}
}
function resetAndFetchData() {
appliedFilter.value = [];
resetBulkActions();
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
store.dispatch('clearConversationFilters');
if (hasActiveFolders.value) {
if (hasActiveFolders.value && activeFolder.value?.query) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
}
@@ -579,7 +355,7 @@ function loadMoreConversations() {
if (!hasAppliedFiltersOrActiveFolders.value) {
fetchConversations();
} else if (hasActiveFolders.value) {
} else if (hasActiveFolders.value && activeFolder.value?.query) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
} else if (hasAppliedFilters.value) {
@@ -609,116 +385,6 @@ function onBasicFilterChange(value, type) {
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();
}
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
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,
})
);
}
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,
});
redirectToConversationList();
} 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 => {
@@ -750,45 +416,19 @@ onMounted(() => {
}
});
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
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);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
watch(
computed(() => props.conversationInbox),
() => resetAndFetchData()
);
watch(
computed(() => props.teamId),
() => resetAndFetchData()
);
watch(
computed(() => props.label),
() => resetAndFetchData()
@@ -797,6 +437,10 @@ watch(
computed(() => props.conversationType),
() => resetAndFetchData()
);
watch(
computed(() => props.foldersId),
() => resetAndFetchData()
);
watch(activeFolder, (newVal, oldVal) => {
if (newVal !== oldVal) {
@@ -833,32 +477,24 @@ watch(conversationFilters, (newVal, oldVal) => {
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@filters-modal="chatListFiltersRef?.onToggleAdvanceFiltersModal"
@basic-filter-change="onBasicFilterChange"
/>
<TeleportWithDirection
v-if="showAddFoldersModal"
to="#saveFilterTeleportTarget"
>
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</TeleportWithDirection>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
v-model:show="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
<ChatListFilters
ref="chatListFiltersRef"
:conversation-inbox="conversationInbox"
:team-id="teamId"
:label="label"
:folders-id="foldersId"
:active-status="activeStatus"
:active-assignee-tab="activeAssigneeTab"
:current-user-details="{ id: currentUser.id, name: currentUser.name }"
:has-active-folders="hasActiveFolders"
:has-applied-filters="hasAppliedFilters"
@apply-filter="onApplyFilter"
@open-add-folders="onOpenAddFolders"
@open-delete-folders="onOpenDeleteFolders"
/>
<ChatTypeTabs
@@ -868,13 +504,15 @@ watch(conversationFilters, (newVal, oldVal) => {
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>
<ConversationActiveFiltersPreview
:active-folder="activeFolder"
:has-active-folders="hasActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@clear-filters="resetAndFetchData"
@open-filter="chatListFiltersRef?.onToggleAdvanceFiltersModal"
@save-folder="chatListFiltersRef?.onClickOpenAddFoldersModal"
@delete-folder="chatListFiltersRef?.onClickOpenDeleteFoldersModal"
/>
<ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations"
@@ -884,11 +522,15 @@ watch(conversationFilters, (newVal, oldVal) => {
: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"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationList
:items="conversationList"
:is-loading="chatListLoading"
@@ -902,32 +544,5 @@ watch(conversationFilters, (newVal, oldVal) => {
:is-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
>
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
</div>
</template>