* fix(featurable): backport feature_flag_value helper from chatwoot-pro-main Adds the two's-complement-aware helper that returns a signed bigint-safe value for SQL queries against the feature_flags column. Mirrors the existing helper in chatwoot-pro-main so future backports of pro features that reference it (e.g. kanban filters) compile cleanly on main. Note: the helper does NOT fix FlagShihTzu's write path; new account-level toggles should use account.settings jsonb instead of feature_flags (see AGENTS.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(super-admin): toggle to hide assignee tabs for basic agents Adds two account-level settings, configurable from the super admin dashboard, that hide the "Unassigned" and "All" tabs of the conversation list for users with the basic agent role (admins and custom roles are unaffected). Hiding "Unassigned" implicitly hides "All", since seeing the full queue without the unassigned subset is incoherent. The constraint is enforced both in the backend (before_validation forces hide_agent_all_tab=true when hide_agent_unassigned_tab is on) and in the super admin form (the "All" checkbox is disabled and auto-checked when "Unassigned" is checked). Storage uses account.settings (jsonb) instead of feature_flags to sidestep the bigint bit-position overflow that happens once features.yml crosses 64 entries, and to keep keys stable across the main and chatwoot-pro-main forks where feature bit positions diverge. AGENTS.md documents the rationale and the recipe to add future toggles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat-list): guard activeAssigneeTabCount against missing tab When the visibility settings hide the currently selected tab, the fallback watch resets activeAssigneeTab to ME, but activeAssigneeTabCount re-evaluates in the same reactive cycle and can read .count on undefined before the watch flushes. Use optional chaining + nullish fallback so the count safely returns 0 during the brief inconsistency. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1100 lines
33 KiB
Vue
1100 lines
33 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,
|
||
onBeforeUnmount,
|
||
defineEmits,
|
||
} from 'vue';
|
||
import { useStore } from 'vuex';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import {
|
||
useMapGetter,
|
||
useFunctionGetter,
|
||
} from 'dashboard/composables/store.js';
|
||
|
||
import { Virtualizer } from 'virtua/vue';
|
||
import ChatListHeader from './ChatListHeader.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 ConversationItem from './ConversationItem.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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.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 { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||
|
||
import { emitter } from 'shared/helpers/mitt';
|
||
|
||
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
||
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,
|
||
isOnParticipatingView,
|
||
isOnUnattendedView,
|
||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||
import {
|
||
getUserPermissions,
|
||
getUserRole,
|
||
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({
|
||
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 route = useRoute();
|
||
const store = useStore();
|
||
|
||
const resolveAttributesModalRef = ref(null);
|
||
const conversationListRef = ref(null);
|
||
const virtualListRef = ref(null);
|
||
|
||
provide('contextMenuElementTarget', virtualListRef);
|
||
|
||
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 activeGroupType = ref('');
|
||
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');
|
||
const allChatList = useMapGetter('getAllStatusChats');
|
||
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
||
const participatingChatsList = useMapGetter('getParticipatingChats');
|
||
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');
|
||
const getAccount = useMapGetter('accounts/getAccount');
|
||
// We can't useFunctionGetter here since it needs to be called on setup?
|
||
const getTeamFn = useMapGetter('teams/getTeam');
|
||
const getConversationById = useMapGetter('getConversationById');
|
||
|
||
useChatListKeyboardEvents(conversationListRef);
|
||
const {
|
||
selectedConversations,
|
||
selectedInboxes,
|
||
selectConversation,
|
||
deSelectConversation,
|
||
selectAllConversations,
|
||
resetBulkActions,
|
||
isConversationSelected,
|
||
onAssignAgent,
|
||
onAssignLabels,
|
||
onRemoveLabels,
|
||
onAssignTeamsForBulk,
|
||
onUpdateConversations,
|
||
} = useBulkActions();
|
||
|
||
const {
|
||
initializeStatusAndAssigneeFilterToModal,
|
||
initializeInboxTeamAndLabelFilterToModal,
|
||
} = useFilter({
|
||
filteri18nKey: 'FILTER',
|
||
attributeModel: 'conversation_attribute',
|
||
});
|
||
|
||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||
|
||
// 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);
|
||
});
|
||
|
||
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 assigneeTabPermissions = computed(() => {
|
||
if (getUserRole(currentUser.value, currentAccountId.value) !== 'agent') {
|
||
return ASSIGNEE_TYPE_TAB_PERMISSIONS;
|
||
}
|
||
|
||
const accountSettings =
|
||
getAccount.value(currentAccountId.value)?.settings || {};
|
||
const hideUnassigned = Boolean(accountSettings.hide_agent_unassigned_tab);
|
||
const hideAll = hideUnassigned || Boolean(accountSettings.hide_agent_all_tab);
|
||
|
||
if (!hideUnassigned && !hideAll) return ASSIGNEE_TYPE_TAB_PERMISSIONS;
|
||
|
||
const { unassigned, all, ...rest } = ASSIGNEE_TYPE_TAB_PERMISSIONS;
|
||
return {
|
||
...rest,
|
||
...(hideUnassigned ? {} : { unassigned }),
|
||
...(hideAll ? {} : { all }),
|
||
};
|
||
});
|
||
|
||
const assigneeTabItems = computed(() => {
|
||
return filterItemsByPermission(
|
||
assigneeTabPermissions.value,
|
||
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(() => {
|
||
return (
|
||
assigneeTabItems.value.find(item => item.key === activeAssigneeTab.value)
|
||
?.count ?? 0
|
||
);
|
||
});
|
||
|
||
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,
|
||
groupType: activeGroupType.value || 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 === wootConstants.CONVERSATION_TYPE.MENTION) {
|
||
return t('CHAT_LIST.MENTION_HEADING');
|
||
}
|
||
if (
|
||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||
) {
|
||
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
|
||
}
|
||
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
|
||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||
}
|
||
if (hasActiveFolders.value) {
|
||
return activeFolder.value.name;
|
||
}
|
||
return t('CHAT_LIST.TAB_HEADING');
|
||
});
|
||
|
||
function filterByAssigneeTab(conversations) {
|
||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
|
||
return conversations.filter(
|
||
c => c.meta?.assignee?.id === currentUser.value?.id
|
||
);
|
||
}
|
||
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
|
||
return conversations.filter(c => !c.meta?.assignee);
|
||
}
|
||
return [...conversations];
|
||
}
|
||
|
||
const conversationList = computed(() => {
|
||
let localConversationList = [];
|
||
|
||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||
const filters = conversationFilters.value;
|
||
if (
|
||
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
|
||
) {
|
||
localConversationList = filterByAssigneeTab(
|
||
participatingChatsList.value(filters)
|
||
);
|
||
} else 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];
|
||
}
|
||
|
||
if (activeFolder.value) {
|
||
const { payload } = activeFolder.value.query;
|
||
localConversationList = localConversationList.filter(conversation => {
|
||
return matchesFilters(conversation, payload);
|
||
});
|
||
}
|
||
|
||
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, group_type: groupType } = 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;
|
||
activeGroupType.value = groupType || '';
|
||
}
|
||
|
||
function emitConversationLoaded() {
|
||
emit('conversationLoad');
|
||
}
|
||
|
||
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,
|
||
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') },
|
||
],
|
||
group_type: [
|
||
{ id: 'individual', name: t('GROUP.FILTER.INDIVIDUAL') },
|
||
{ id: 'group', name: t('GROUP.FILTER.GROUP') },
|
||
],
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||
// If the list doesn’t fill the viewport, loading can stall.
|
||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||
const intersectionObserverOptions = computed(() => ({
|
||
root: conversationListRef.value,
|
||
rootMargin: '100px 0px 100px 0px',
|
||
}));
|
||
|
||
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 if (type === 'group_type') {
|
||
activeGroupType.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();
|
||
}
|
||
}
|
||
|
||
function redirectToConversationList() {
|
||
const {
|
||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||
name,
|
||
} = route;
|
||
|
||
let conversationType = '';
|
||
if (isOnMentionsView({ route: { name } })) {
|
||
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
|
||
} else if (isOnParticipatingView({ route: { name } })) {
|
||
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
|
||
} else if (isOnUnattendedView({ route: { name } })) {
|
||
conversationType = wootConstants.CONVERSATION_TYPE.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,
|
||
customAttributes = null
|
||
) {
|
||
const payload = {
|
||
conversationId,
|
||
status,
|
||
snoozedUntil,
|
||
};
|
||
|
||
if (customAttributes) {
|
||
payload.customAttributes = customAttributes;
|
||
}
|
||
|
||
store.dispatch('toggleStatus', payload).then(() => {
|
||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||
});
|
||
}
|
||
|
||
function handleResolveConversation(conversationId, status, snoozedUntil) {
|
||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||
return;
|
||
}
|
||
|
||
// Check for required attributes before resolving
|
||
const conversation = getConversationById.value(conversationId);
|
||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||
const { hasMissing, missing } = checkMissingAttributes(
|
||
currentCustomAttributes
|
||
);
|
||
|
||
if (hasMissing) {
|
||
// Pass conversation context through the modal's API
|
||
const conversationContext = {
|
||
id: conversationId,
|
||
snoozedUntil,
|
||
};
|
||
resolveAttributesModalRef.value?.open(
|
||
missing,
|
||
currentCustomAttributes,
|
||
conversationContext
|
||
);
|
||
} else {
|
||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||
}
|
||
}
|
||
|
||
function handleResolveWithAttributes({ attributes, context }) {
|
||
if (context) {
|
||
const existingConversation = getConversationById.value(context.id);
|
||
const currentCustomAttributes =
|
||
existingConversation?.custom_attributes || {};
|
||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||
|
||
toggleConversationStatus(
|
||
context.id,
|
||
wootConstants.STATUS_TYPE.RESOLVED,
|
||
context.snoozedUntil,
|
||
mergedAttributes
|
||
);
|
||
}
|
||
}
|
||
|
||
function allSelectedConversationsStatus(status) {
|
||
if (!selectedConversations.value.length) return false;
|
||
return selectedConversations.value.every(item => {
|
||
return getConversationById.value(item)?.status === status;
|
||
});
|
||
}
|
||
|
||
function onContextMenuToggle(state) {
|
||
isContextMenuOpen.value = state;
|
||
}
|
||
|
||
function toggleSelectAll(check) {
|
||
selectAllConversations(check, conversationList);
|
||
}
|
||
|
||
useEmitter('fetch_conversation_stats', () => {
|
||
if (hasAppliedFiltersOrActiveFolders.value) return;
|
||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||
});
|
||
|
||
let lastSubscribedIds = '';
|
||
const subscribePresenceForTopChats = () => {
|
||
const ids = conversationList.value.slice(0, 10).map(c => c.id);
|
||
const key = ids.join(',');
|
||
if (!ids.length || key === lastSubscribedIds) return;
|
||
lastSubscribedIds = key;
|
||
ConversationAPI.presenceSubscribeBulk(ids).catch(() => {});
|
||
};
|
||
|
||
let presenceInterval = null;
|
||
|
||
onMounted(() => {
|
||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||
setFiltersFromUISettings();
|
||
store.dispatch('setChatStatusFilter', activeStatus.value);
|
||
store.dispatch('setChatSortFilter', activeSortBy.value);
|
||
store.dispatch('setChatGroupTypeFilter', activeGroupType.value);
|
||
resetAndFetchData();
|
||
if (hasActiveFolders.value) {
|
||
store.dispatch('campaigns/get');
|
||
}
|
||
presenceInterval = setInterval(subscribePresenceForTopChats, 60000);
|
||
});
|
||
|
||
watch(conversationList, subscribePresenceForTopChats);
|
||
|
||
watch(assigneeTabItems, items => {
|
||
if (!items.some(item => item.key === activeAssigneeTab.value)) {
|
||
updateAssigneeTab(wootConstants.ASSIGNEE_TYPE.ME);
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (presenceInterval) clearInterval(presenceInterval);
|
||
});
|
||
|
||
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('removeLabels', onRemoveLabels);
|
||
provide('updateConversationStatus', handleResolveConversation);
|
||
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.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 conversations-list-wrap bg-n-surface-1"
|
||
:class="[
|
||
{ hidden: !showConversationList },
|
||
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||
]"
|
||
>
|
||
<slot />
|
||
<ChatListHeader
|
||
:page-title="pageTitle"
|
||
:has-applied-filters="hasAppliedFilters"
|
||
:has-active-folders="hasActiveFolders"
|
||
:active-status="activeStatus"
|
||
: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"
|
||
@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"
|
||
/>
|
||
|
||
<ChatTypeTabs
|
||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||
:items="assigneeTabItems"
|
||
:active-tab="activeAssigneeTab"
|
||
is-compact
|
||
@chat-tab-change="updateAssigneeTab"
|
||
/>
|
||
|
||
<p
|
||
v-if="!chatListLoading && !conversationList.length"
|
||
class="flex overflow-auto justify-center items-center p-4"
|
||
>
|
||
{{ $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 min-h-0 overflow-y-auto conversations-list"
|
||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||
>
|
||
<Virtualizer
|
||
ref="virtualListRef"
|
||
v-slot="{ item, index }"
|
||
:data="conversationList"
|
||
>
|
||
<ConversationItem
|
||
:source="item"
|
||
:label="label"
|
||
:team-id="teamId"
|
||
:folders-id="foldersId"
|
||
:conversation-type="conversationType"
|
||
:show-assignee="showAssigneeInConversationCard"
|
||
:data-index="index"
|
||
@select-conversation="selectConversation"
|
||
@de-select-conversation="deSelectConversation"
|
||
/>
|
||
</Virtualizer>
|
||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||
<Spinner class="text-n-brand" />
|
||
</div>
|
||
<p
|
||
v-else-if="showEndOfListMessage"
|
||
class="p-4 text-center text-n-slate-11"
|
||
>
|
||
{{ $t('CHAT_LIST.EOF') }}
|
||
</p>
|
||
<IntersectionObserver
|
||
v-else
|
||
:options="intersectionObserverOptions"
|
||
@observed="loadMoreConversations"
|
||
/>
|
||
</div>
|
||
<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>
|
||
<ConversationResolveAttributesModal
|
||
ref="resolveAttributesModalRef"
|
||
@submit="handleResolveWithAttributes"
|
||
/>
|
||
</div>
|
||
</template>
|