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
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -37,9 +38,17 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -57,6 +66,7 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -69,7 +79,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (!searchQuery.value) {
if (props.disableLocalFiltering || !searchQuery.value) {
return props.menuSections;
}
@@ -89,6 +99,12 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -118,7 +134,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -130,6 +146,7 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
@@ -141,10 +158,23 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
@@ -180,6 +210,7 @@ onMounted(() => {
>
{{ item.label }}
</span>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
@@ -188,6 +219,9 @@ onMounted(() => {
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" />
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
@@ -223,6 +257,7 @@ onMounted(() => {
>
{{ item.label }}
</span>
<slot name="trailing-icon" :item="item" />
</button>
</template>
<div
@@ -235,5 +270,6 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -39,38 +39,45 @@ const formatFilterValue = value => {
// Case 2: array → map each item, use name if present, else the item itself
if (Array.isArray(value)) {
// Empty array
if (value.length === 0) return '';
return value.map(item => item?.name ?? item).join(', ');
}
// Case 3: object with a "name" property → return name
// Case 4: primitive (string, number, etc.) → return as is
// Case 3: empty object
if (typeof value === 'object' && Object.keys(value).length === 0) {
return '';
}
// Case 4: object with a "name" property → return name
// Case 5: primitive (string, number, etc.) → return as is
return value?.name ?? value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-2 h-7"
class="inline-flex items-center gap-1.5 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="filter.values"
v-if="formatFilterValue(filter.values)"
:title="formatFilterValue(filter.values)"
class="lowercase truncate text-n-slate-12"
class="lowercase truncate text-n-slate-12 font-440"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
@@ -86,7 +93,7 @@ const formatFilterValue = value => {
"
>
<span
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
>
{{ filter.queryOperator }}
</span>
@@ -95,7 +102,7 @@ const formatFilterValue = value => {
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
@@ -105,9 +112,10 @@ const formatFilterValue = value => {
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1"
variant="ghost"
class="!px-1 hover:!no-underline"
link
@click="emit('clearFilters')"
/>
<slot name="actions" />
</div>
</template>
@@ -1,12 +1,14 @@
<script setup>
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBreakpoints } from '@vueuse/core';
import { useTrack } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import wootConstants from 'dashboard/constants/globals';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -42,6 +44,13 @@ const DEFAULT_FILTER = {
const { t } = useI18n();
const store = useStore();
const breakpoints = useBreakpoints({
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
});
const isSmallScreen = breakpoints.smaller('mobile');
const viewInModal = computed(() => isSmallScreen.value);
const resetFilter = () => {
filters.value = [{ ...DEFAULT_FILTER }];
};
@@ -104,69 +113,78 @@ const outsideClickHandler = [
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
viewInModal,
}"
@click.self="viewInModal && emit('close')"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
<div
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
:class="{ 'overflow-x-auto': viewInModal }"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex gap-2 justify-between">
<Button sm ghost blue @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex gap-2 justify-between">
<Button sm ghost blue @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
</Button>
</div>
</div>
</div>
</div>
@@ -108,8 +108,10 @@ export default {
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
:message="
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
@blur="v$.name.$touch"
/>
<div class="flex flex-row justify-end w-full gap-2">
@@ -117,9 +117,11 @@ const toggleOption = option => {
</button>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11 truncate">{{
t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
+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>
@@ -0,0 +1,308 @@
<script setup>
import { ref, unref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import {
useMapGetter,
useFunctionGetter,
} from 'dashboard/composables/store.js';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useFilter } from 'shared/composables/useFilter';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 },
label: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
activeStatus: { type: String, required: true },
activeAssigneeTab: { type: String, required: true },
currentUserDetails: { type: Object, required: true },
hasActiveFolders: { type: Boolean, default: false },
hasAppliedFilters: { type: Boolean, default: false },
});
const emit = defineEmits([
'applyFilter',
'openAddFolders',
'openDeleteFolders',
]);
const store = useStore();
const { t } = useI18n();
const showAdvancedFilters = ref(false);
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const appliedFilter = ref([]);
const foldersQuery = ref({});
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
...filter,
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
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 appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const activeInbox = useMapGetter('getSelectedInbox');
const getTeamFn = useMapGetter('teams/getTeam');
const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
const conversationCustomAttributes = useFunctionGetter(
'attributes/getAttributesByModel',
'conversation_attribute'
);
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 activeTeam = computed(() => {
if (props.teamId) {
return getTeamFn.value(props.teamId);
}
return {};
});
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
function closeAdvanceFiltersModal() {
showAdvancedFilters.value = false;
appliedFilter.value = [];
}
function onApplyFilter(payload) {
const transformedPayload = useSnakeCase(payload);
foldersQuery.value = filterQueryGenerator(transformedPayload);
emit('applyFilter', transformedPayload);
showAdvancedFilters.value = false;
}
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 openLastSavedItemInFolder() {
emit('openAddFolders');
}
function openLastItemAfterDeleteInFolder() {
emit('openDeleteFolders');
}
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(
props.activeStatus,
props.currentUserDetails,
props.activeAssigneeTab
);
if (statusFilter) {
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
}
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
props.conversationInbox,
inbox.value,
props.teamId,
activeTeam.value,
props.label
).map(useCamelCase);
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
}
function initializeFolderToFilterModal(newActiveFolder) {
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;
}
appliedFilter.value = [];
if (!props.hasAppliedFilters && !props.hasActiveFolders) {
initializeExistingFilterToModal();
}
if (props.hasActiveFolders) {
initializeFolderToFilterModal(activeFolder.value);
}
if (props.hasAppliedFilters) {
initalizeAppliedFiltersToModal();
}
showAdvancedFilters.value = true;
}
defineExpose({
onToggleAdvanceFiltersModal,
onClickOpenAddFoldersModal,
onClickOpenDeleteFoldersModal,
onCloseAddFoldersModal,
onCloseDeleteFoldersModal,
foldersQuery,
activeFolder,
appliedFilter,
});
</script>
<template>
<div>
<TeleportWithDirection
v-if="showAddFoldersModal"
to="#saveFilterTeleportTarget"
>
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</TeleportWithDirection>
<TeleportWithDirection
v-if="showDeleteFoldersModal"
to="#deleteFilterTeleportTarget"
>
<DeleteCustomViews
v-model:show="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
/>
</TeleportWithDirection>
<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>
@@ -18,13 +18,7 @@ const props = defineProps({
isListLoading: { type: Boolean, required: true },
});
const emit = defineEmits([
'addFolders',
'deleteFolders',
'resetFilters',
'basicFilterChange',
'filtersModal',
]);
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
const { uiSettings, updateUISettings } = useUISettings();
@@ -56,12 +50,7 @@ const toggleConversationLayout = () => {
</script>
<template>
<div
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
<div class="flex items-center justify-center min-w-0">
<h1
class="text-base font-medium truncate text-n-slate-12"
@@ -73,44 +62,24 @@ const toggleConversationLayout = () => {
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<div class="relative">
<NextButton
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
icon="i-lucide-save"
slate
xs
faded
@click="emit('addFolders')"
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<NextButton
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
icon="i-lucide-circle-x"
ruby
faded
xs
@click="emit('resetFilters')"
/>
</template>
<div class="flex items-center gap-2">
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<template v-if="hasActiveFolders">
<div class="relative">
<NextButton
@@ -128,15 +97,6 @@ const toggleConversationLayout = () => {
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<NextButton
id="toggleConversationFilterButton"
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
icon="i-lucide-trash-2"
ruby
xs
faded
@click="emit('deleteFolders')"
/>
</template>
<div v-else class="relative">
<NextButton
@@ -154,11 +114,6 @@ const toggleConversationLayout = () => {
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<SwitchLayout
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="toggleConversationLayout"
@@ -1,8 +1,15 @@
<script setup>
import { inject } from 'vue';
import { inject, ref } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
const props = defineProps({
source: {
type: Object,
required: true,
@@ -33,18 +40,126 @@ defineProps({
},
});
const store = useStore();
const router = useRouter();
const { t } = useI18n();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const assignAgent = inject('assignAgent');
const assignTeam = inject('assignTeam');
const assignLabels = inject('assignLabels');
const updateConversationStatus = inject('updateConversationStatus');
const toggleContextMenu = inject('toggleContextMenu');
const markAsUnread = inject('markAsUnread');
const markAsRead = inject('markAsRead');
const assignPriority = inject('assignPriority');
const isConversationSelected = inject('isConversationSelected');
const deleteConversation = inject('deleteConversation');
const deleteConversationDialogRef = ref(null);
const { onAssignAgent, onAssignLabels } = useBulkActions();
async function assignTeam(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 updateConversationStatus(conversationId, status, snoozedUntil) {
store
.dispatch('toggleStatus', {
conversationId,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
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,
})
);
});
}
function redirectToConversationList() {
const accountId = store.getters.getCurrentAccountId;
const conversationType = props.conversationType || '';
const inboxId = store.getters.getSelectedInbox?.id;
let path = `/app/accounts/${accountId}/conversations`;
if (conversationType === 'mention') {
path = `/app/accounts/${accountId}/mentions/conversations`;
} else if (conversationType === 'unattended') {
path = `/app/accounts/${accountId}/unattended/conversations`;
} else if (props.foldersId) {
path = `/app/accounts/${accountId}/custom_view/${props.foldersId}`;
} else if (inboxId) {
path = `/app/accounts/${accountId}/inbox/${inboxId}`;
} else if (props.teamId) {
path = `/app/accounts/${accountId}/team/${props.teamId}`;
} else if (props.label) {
path = `/app/accounts/${accountId}/label/${props.label}`;
}
router.push(path);
}
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
}
}
function handleDelete() {
deleteConversationDialogRef.value.open();
}
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', props.source.id);
redirectToConversationList();
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
</script>
<template>
@@ -61,14 +176,27 @@ const deleteConversation = inject('deleteConversation');
enable-context-menu
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-agent="onAssignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@assign-label="onAssignLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@delete-conversation="handleDelete"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: source.id,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
/>
</template>
@@ -6,6 +6,7 @@ import { useInfiniteScroll } from '@vueuse/core';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -76,7 +77,7 @@ useKeyboardEvents({
<template>
<div
ref="parentRef"
class="conversation-list flex-1 w-full h-full touch-pan-y overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] px-2 pt-2.5"
class="conversation-list flex-1 w-full h-full touch-pan-y overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] px-2 pt-2"
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
>
<Virtualizer
@@ -104,9 +105,15 @@ useKeyboardEvents({
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
>
{{ t('CHAT_LIST.EOF') }}
<Icon
icon="i-woot-party"
class="size-4 inline-block align-middle ltr:mr-2 rtl:ml-2"
/>
<span class="align-middle">
{{ t('CHAT_LIST.EOF') }}
</span>
</p>
</div>
</div>
@@ -47,19 +47,19 @@ const onTabClick = event => {
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
>
<a
class="flex items-center flex-row border-b select-none cursor-pointer text-sm relative top-[1px] transition-[border-color] duration-[150ms] ease-[cubic-bezier(0.37,0,0.63,1)]"
class="flex items-center flex-row select-none cursor-pointer text-sm relative after:absolute after:bottom-0 after:left-0 after:right-0 font-460 after:h-[2px] after:rounded-full after:transition-all after:duration-200"
:class="[
active
? 'border-b border-n-brand text-n-blue-11'
: 'border-transparent text-n-slate-11',
isCompact ? 'py-2 text-sm' : 'text-base py-3',
? 'text-n-blue-11 after:bg-n-brand after:opacity-100'
: 'text-n-slate-11 after:bg-transparent after:opacity-0',
isCompact ? 'py-2.5 text-sm' : 'text-base py-3',
]"
@click="onTabClick"
>
{{ name }}
<div
v-if="showBadge"
class="rounded-full h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1.5 py-0 min-w-[20px]"
class="rounded-full h-5 flex items-center justify-center text-xxs font-semibold my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
:class="[
active
? 'bg-n-blue-3 text-n-blue-11'
@@ -48,13 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
class="w-full px-4 py-0 [&_ul]:p-0 h-10"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm [&_a]:font-medium"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -0,0 +1,151 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useElementSize } from '@vueuse/core';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activeFolder: { type: Object, default: null },
hasActiveFolders: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits([
'clearFilters',
'saveFolder',
'deleteFolder',
'openFilter',
]);
const { t } = useI18n();
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const activeFolderQuery = computed(() => {
const query = props.activeFolder?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const activeFilterQueryData = computed(() => {
return props.hasActiveFolders
? activeFolderQuery.value
: appliedFilters.value;
});
const showPreview = computed(() => {
return activeFilterQueryData.value.length > 0;
});
const containerRef = ref(null);
const { width: containerWidth } = useElementSize(containerRef);
const maxVisibleFilters = computed(() => {
const totalFilters = activeFilterQueryData.value.length;
const width = containerWidth.value;
if (!width || totalFilters === 0) return 0;
const chipWidth = 200; // Estimated avg width + gap
const buttonSpace = 400; // Reserved space for "Edit/Delete/More" buttons
// Calculate available space across 2 lines
const totalAvailablePixels = Math.max(0, width * 2 - buttonSpace);
const maxCapacity = Math.floor(totalAvailablePixels / chipWidth);
// Return at least 1, but no more than actual total
return Math.max(1, Math.min(maxCapacity, totalFilters));
});
</script>
<template>
<div
v-if="showPreview"
ref="containerRef"
class="flex items-center justify-between gap-2 mx-2 px-2 pb-2 pt-0.5 border-b-[1.2px] border-dashed border-n-strong mt-px"
>
<ActiveFilterPreview
:applied-filters="activeFilterQueryData"
:max-visible-filters="maxVisibleFilters"
:more-filters-label="
t('FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: activeFilterQueryData.length - maxVisibleFilters,
})
"
:clear-button-label="t('FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')"
:show-clear-button="!hasActiveFolders"
class="flex-1 min-w-0"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
>
<template #actions>
<template v-if="hasActiveFolders">
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Button
:label="t('FILTER.ACTIVE_FILTERS.EDIT_BUTTON')"
link
class="hover:!no-underline !text-n-blue-11 !px-0.5"
xs
@click="emit('openFilter')"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<div class="inline-flex items-center">
<Button
:label="t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
link
ruby
class="hover:!no-underline !no-underline !px-0.5"
xs
@click="emit('deleteFolder')"
/>
<!-- Teleport target for Delete modal -->
<div
id="deleteFilterTeleportTarget"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
</template>
<div
v-if="appliedFilters.length > 0"
class="w-px h-3 bg-n-weak rounded-lg"
/>
<div
v-if="appliedFilters.length > 0"
class="relative inline-flex items-center"
>
<Button
:label="t('FILTER.CUSTOM_VIEWS.SAVE_VIEW')"
link
class="hover:!no-underline !text-n-blue-11 !px-0.5"
xs
@click="emit('saveFolder')"
/>
<!-- Teleport target for Save modal -->
<div
id="saveFilterTeleportTarget"
class="absolute z-50 top-6"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
</template>
</ActiveFilterPreview>
</div>
<template v-else />
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
@@ -26,6 +26,7 @@ const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const filterContainerRef = useTemplateRef('filterContainerRef');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
@@ -131,7 +132,7 @@ const handleSortChange = value => {
</script>
<template>
<div class="relative flex">
<div ref="filterContainerRef" class="relative flex">
<NextButton
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
@@ -142,7 +143,10 @@ const handleSortChange = value => {
/>
<div
v-if="showActionsDropdown"
v-on-click-outside="() => toggleDropdown()"
v-on-click-outside="[
() => toggleDropdown(),
{ ignore: [filterContainerRef] },
]"
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
@@ -1,254 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>
@@ -0,0 +1,195 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedAgent = ref(null);
const assignableAgentsUiFlags = computed(
() => store.getters['inboxAssignableAgents/getUIFlags']
);
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: 'None',
thumbnail: {
name: 'None',
src: '',
},
},
];
assignableAgents.value.forEach(agent => {
items.push({
action: 'select',
value: agent.id,
label: agent.name,
thumbnail: {
name: agent.name,
src: agent.thumbnail,
},
isSelected: selectedAgent.value?.id === agent.id,
});
});
return items;
});
const handleSelectAgent = item => {
if (item.value === 'none') {
selectedAgent.value = { id: null, name: 'None' };
} else {
const agent = assignableAgents.value.find(a => a.id === item.value);
selectedAgent.value = agent || { id: null, name: 'None' };
}
};
const handleAssign = () => {
emit('select', selectedAgent.value);
selectedAgent.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedAgent.value = null;
};
const handleToggleDropdown = () => {
const willOpen = !showDropdown.value;
toggleDropdown();
// Fetch agents only when opening the dropdown
if (willOpen && props.selectedInboxes.length > 0) {
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-check"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="handleToggleDropdown"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-top"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-top"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="agentMenuItems"
:is-loading="isLoading"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-60 max-h-80 overflow-y-auto"
@action="handleSelectAgent"
>
<template #footer>
<div
v-if="selectedAgent"
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedAgent.id"
keypath="BULK_ACTION.ASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #conversationCount>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #agentName>
<strong class="text-n-slate-12">
{{ selectedAgent.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
type: {
type: String,
default: 'conversation',
},
isLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const buttonLabel = computed(() => {
if (props.type === 'contact') return t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
return '';
});
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
color: label.color,
id: label.id,
isSelected: isLabelSelected(label.title),
}));
});
const toggleLabelSelection = labelTitle => {
const index = selectedLabels.value.indexOf(labelTitle);
if (index > -1) {
selectedLabels.value.splice(index, 1);
} else {
selectedLabels.value.push(labelTitle);
}
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
:class="{
'bg-n-alpha-2': showDropdown,
'[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit':
isTypeContact,
}"
:disabled="disabled"
:is-loading="isLoading"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-top"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-top"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="labelMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="top-8 w-60 max-h-80 overflow-y-auto"
:class="{
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0':
!isTypeContact,
'ltr:right-0 rtl:left-0 mt-1': isTypeContact,
}"
@action="item => toggleLabelSelection(item.value)"
>
<template #thumbnail="{ item }">
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="isLabelSelected(item.value)"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
class="sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<NextButton
sm
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,164 @@
<script setup>
import { useTemplateRef, computed, ref, onMounted } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
const teams = useMapGetter('teams/getTeams');
const teamMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.TEAMS.NONE'),
},
];
teams.value.forEach(team => {
items.push({
action: 'select',
value: team.id,
label: team.name,
isSelected: selectedTeam.value?.id === team.id,
});
});
return items;
});
const handleSelectTeam = item => {
if (item.value === 'none') {
selectedTeam.value = { id: 0, name: 'None' };
} else {
const foundTeam = teams.value.find(team => team.id === item.value);
selectedTeam.value = foundTeam || { id: 0, name: 'None' };
}
};
const handleAssign = () => {
emit('select', selectedTeam.value);
selectedTeam.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedTeam.value = null;
};
onMounted(() => {
store.dispatch('teams/fetch');
});
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-top"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-top"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="teamMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-2 rtl:-left-2 top-8 w-60 max-h-80 overflow-y-auto"
@action="handleSelectTeam"
>
<template #footer>
<div
v-if="selectedTeam"
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedTeam.id"
keypath="BULK_ACTION.TEAMS.ASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #conversationCount>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #teamName>
<strong class="text-n-slate-12">
{{ selectedTeam.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.TEAMS.UNASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const updateMenuItems = computed(() => {
const items = [];
if (props.showResolve) {
items.push({
action: 'update',
value: 'resolved',
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
icon: 'i-lucide-check',
});
}
if (props.showReopen) {
items.push({
action: 'update',
value: 'open',
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
icon: 'i-lucide-redo',
});
}
if (props.showSnooze) {
items.push({
action: 'update',
value: 'snoozed',
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
icon: 'i-lucide-alarm-clock',
});
}
return items;
});
const handleUpdate = item => {
if (item.value === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', item.value);
}
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-circle-fading-arrow-up"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-top"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-top"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="updateMenuItems"
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-36"
@action="handleUpdate"
/>
</Transition>
</div>
</template>
@@ -1,7 +1,9 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -10,243 +12,150 @@ import {
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import BulkAgentActions from './BulkAgentActions.vue';
import BulkUpdateActions from './BulkUpdateActions.vue';
import BulkLabelActions from './BulkLabelActions.vue';
import BulkTeamActions from './BulkTeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
const props = defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
allConversationsSelected: {
type: Boolean,
default: false,
},
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
selectedInboxes: {
type: Array,
default: () => [],
},
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showOpenAction: {
type: Boolean,
default: false,
},
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showResolvedAction: {
type: Boolean,
default: false,
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
showSnoozedAction: {
type: Boolean,
default: false,
},
};
});
const emit = defineEmits(['selectAllConversations']);
const {
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomTimeSnoozeModal.value = true;
} else {
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
}
function onCmdReopenConversation() {
onUpdateConversations('open', null);
}
function onCmdResolveConversation() {
onUpdateConversations('resolved', null);
}
function customSnoozeTime(customSnoozedTime) {
showCustomTimeSnoozeModal.value = false;
if (customSnoozedTime) {
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
}
}
function hideCustomSnoozeModal() {
showCustomTimeSnoozeModal.value = false;
}
// Computed property with getter/setter to enable v-model usage
const allSelected = computed({
get: () => props.allConversationsSelected,
set: value => {
emit('selectAllConversations', value);
},
});
onMounted(() => {
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
onUnmounted(() => {
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
</script>
<template>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<div class="pt-3 pb-2 px-2 relative">
<div
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak"
>
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
<label class="cursor-pointer flex items-center gap-1.5">
<Checkbox
v-model="allSelected"
:indeterminate="!allConversationsSelected"
/>
<span class="cursor-pointer">
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
ghost
class="!text-n-blue-11 !px-1 !h-6"
sm
@click="allSelected = false"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
@update="onUpdateConversations"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
@select="onAssignAgent"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</transition>
</div>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
<div
v-if="allConversationsSelected"
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mt-2 py-1.5 px-2 text-n-amber-text"
>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
@@ -260,63 +169,3 @@ export default {
</woot-modal>
</div>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>
@@ -1,141 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>
@@ -1,145 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>
@@ -1,109 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>
@@ -20,9 +20,15 @@ export function useBulkActions() {
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
// Only remove one instance of the inboxId, not all
// This handles the case where multiple conversations from the same inbox are selected
const index = selectedInboxes.value.indexOf(inboxId);
if (index > -1) {
selectedInboxes.value = [
...selectedInboxes.value.slice(0, index),
...selectedInboxes.value.slice(index + 1),
];
}
}
function resetBulkActions() {
@@ -75,6 +75,11 @@
"ADDITIONAL_FILTERS": "Additional filters",
"CUSTOM_ATTRIBUTES": "Custom attributes"
},
"ACTIVE_FILTERS": {
"MORE_FILTERS": "+ {count} more filters",
"CLEAR_FILTERS": "Clear filters",
"EDIT_BUTTON": "Edit"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "Do you want to save this filter?",
@@ -95,8 +100,9 @@
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"SAVE_VIEW": "Save View",
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"DELETE_BUTTON": "Delete",
"MODAL": {
"CONFIRM": {
"TITLE": "Confirm deletion",
@@ -1,12 +1,14 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"CLEAR_SELECTION": "Clear",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {agentName}? | Are you sure to assign {n} conversations to {agentName}?",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -34,6 +36,8 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {teamName}? | Are you sure to assign {n} conversations to {teamName}?",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -2,7 +2,7 @@
"CHAT_LIST": {
"LOADING": "Fetching conversations",
"LOAD_MORE_CONVERSATIONS": "Load more conversations",
"EOF": "All conversations loaded 🎉",
"EOF": "All conversations loaded",
"LIST": {
"404": "There are no active conversations in this group."
},
@@ -1,11 +1,10 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
@@ -34,7 +33,6 @@ const { t } = useI18n();
const selectedCount = computed(() => props.selectedContactIds.length);
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
const showLabelSelector = ref(false);
const selectAllLabel = computed(() => {
if (!totalVisibleContacts.value) {
@@ -73,28 +71,17 @@ const selectionModel = computed({
});
const emitClearSelection = () => {
showLabelSelector.value = false;
emit('clearSelection');
};
const toggleLabelSelector = () => {
if (!selectedCount.value || props.isLoading) return;
showLabelSelector.value = !showLabelSelector.value;
};
const closeLabelSelector = () => {
showLabelSelector.value = false;
};
const handleAssignLabels = labels => {
emit('assignLabels', labels);
closeLabelSelector();
};
</script>
<template>
<div
class="sticky top-0 z-10 bg-gradient-to-b from-n-background from-90% to-transparent px-6 pt-1 pb-2"
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent px-6 pt-1 pb-2"
>
<BulkSelectBar
v-model="selectionModel"
@@ -109,42 +96,18 @@ const handleAssignLabels = labels => {
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1.5"
class="!px-1"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<div
v-on-click-outside="closeLabelSelector"
class="relative flex items-center"
>
<Button
sm
faded
slate
icon="i-lucide-tags"
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="toggleLabelSelector"
/>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<LabelActions
v-if="showLabelSelector"
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
@assign="handleAssignLabels"
/>
</transition>
</div>
<BulkLabelActions
type="contact"
:is-loading="isLoading"
:disabled="!selectedCount"
@assign="handleAssignLabels"
/>
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
@@ -156,7 +119,7 @@ const handleAssignLabels = labels => {
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
class="!px-2 [&>span:nth-child(2)]:hidden"
@click="emit('deleteSelected')"
/>
</Policy>
@@ -23,15 +23,15 @@ export default {
<template>
<NextButton
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
:icon="
isOnExpandedLayout
? 'i-lucide-arrow-left-to-line'
: 'i-lucide-arrow-right-to-line'
"
icon="i-woot-expand-list"
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
class="flex-shrink-0 md:inline-flex hidden"
:class="{
'ltr:!rotate-180 rtl:!rotate-0': isOnExpandedLayout,
'ltr:!rotate-0 rtl:!rotate-180': !isOnExpandedLayout,
}"
@click="toggle"
/>
</template>