feat(v5): New expanded layout for chat list

This commit is contained in:
iamsivin
2026-02-20 15:23:37 +05:30
parent db7e02b93b
commit de0b78e273
56 changed files with 3989 additions and 2354 deletions
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -0,0 +1,23 @@
<script setup>
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
defineProps({
contactName: { type: String, required: true },
timestamp: { type: Number, required: true },
createdAt: { type: Number, required: true },
});
</script>
<template>
<div class="grid grid-cols-[1fr_auto] items-center gap-2 h-5">
<h4 class="text-sm my-0 capitalize truncate text-n-slate-12 font-medium">
{{ contactName }}
</h4>
<TimeAgo
:last-activity-timestamp="timestamp"
:created-at-timestamp="createdAt"
class="font-440 !text-xs shrink-0"
/>
</div>
</template>
@@ -1,95 +1,191 @@
<script setup>
import { ref, computed } from 'vue';
import {
ref,
computed,
inject,
nextTick,
useSlots,
watch,
useAttrs,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
conversationLabels: {
labels: {
type: Array,
required: true,
},
accountLabels: {
type: Array,
required: true,
disableToggle: {
type: Boolean,
default: false,
},
});
const WIDTH_CONFIG = Object.freeze({
DEFAULT_WIDTH: 80,
CHAR_WIDTH: {
SHORT: 8, // For labels <= 5 chars
LONG: 6, // For labels > 5 chars
},
BASE_WIDTH: 12, // dot + gap
THRESHOLD: 5, // character length threshold
});
defineOptions({ inheritAttrs: false });
const containerRef = ref(null);
const maxLabels = ref(1);
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
const labelSet = new Set(props.conversationLabels);
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const calculateLabelWidth = ({ title = '' }) => {
const charWidth =
title.length > WIDTH_CONFIG.THRESHOLD
? WIDTH_CONFIG.CHAR_WIDTH.LONG
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
};
const getAverageWidth = labels => {
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
const totalWidth = labels.reduce(
(sum, label) => sum + calculateLabelWidth(label),
0
);
return totalWidth / labels.length;
};
const visibleLabels = computed(() =>
activeLabels.value?.slice(0, maxLabels.value)
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const updateVisibleLabels = () => {
if (!containerRef.value) return;
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const containerWidth = containerRef.value.offsetWidth;
const avgWidth = getAverageWidth(activeLabels.value);
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
// Recalculate when parent triggers (e.g., tab changes in ChatList)
// Needed because inbox conversation view shows metadata inside CardLabels, affecting available width
const recalculateKey = inject('recalculateLabelsKey', null);
if (recalculateKey) {
watch(recalculateKey, () => nextTick(throttledCalculate));
}
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
ref="containerRef"
v-resize="updateVisibleLabels"
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<template v-for="(label, index) in visibleLabels" :key="label.id">
<div
class="flex items-center gap-1.5 min-w-0"
:class="[
index !== visibleLabels.length - 1
? 'flex-shrink-0 text-ellipsis'
: 'flex-shrink',
]"
>
<div
:style="{ backgroundColor: label.color }"
class="size-1.5 rounded-full flex-shrink-0"
/>
<span
class="text-sm text-n-slate-10 whitespace-nowrap"
:class="{ truncate: index === visibleLabels.length - 1 }"
>
{{ label.title }}
</span>
</div>
</template>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -0,0 +1,103 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
inbox: { type: Object, required: true },
showInboxName: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
assignee: { type: Object, default: () => ({}) },
inline: { type: Boolean, default: false },
isLabelsEmpty: { type: Boolean, default: false },
});
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const showSection = computed(() => {
if (props.inline) {
return (
hasSlaPolicyId.value ||
props.chat.priority ||
(props.showAssignee && props.assignee.name)
);
}
return (
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority ||
props.chat.status ||
hasSlaPolicyId.value
);
});
</script>
<template>
<!-- Full meta section (when not inline) -->
<div
v-if="showSection && !inline"
class="flex items-center justify-between min-w-0 gap-8 h-6 mb-1"
>
<div class="flex-1 min-w-0 flex items-center gap-0.5">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon v-if="chat.status" :status="chat.status" />
</div>
</div>
<!-- Inline meta section (compact mode for cases in inbox and label filter view) -->
<div
v-else-if="showSection && inline"
data-before-slot
class="flex items-center gap-1.5 flex-shrink-0"
>
<div class="flex items-center gap-1.5">
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon v-if="chat.status" :status="chat.status" />
</div>
<div v-if="hasSlaPolicyId" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
<SLACardLabel v-if="hasSlaPolicyId" ref="slaCardLabel" :chat="chat" />
<div v-if="!isLabelsEmpty" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
</div>
</template>
@@ -1,207 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
priority: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
<Icon
v-tooltip.top="{
content: priority,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,135 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardMetaSection from './CardMetaSection.vue';
import CardAvatar from './CardAvatar.vue';
import CardHeader from './CardHeader.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
const slaCardLabel = useTemplateRef('slaCardLabel');
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const showExpandedPreview = computed(
() => props.compact && !showLabelsSection.value
);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat?.unread_count);
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const onSelectConversation = checked => {
emit('selectConversation', checked);
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-colors duration-150"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0 py-3': compact,
'px-2 pt-2.5 pb-3 hover:bg-n-alpha-1 rounded-lg': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div class="min-w-0 w-full">
<CardMetaSection
v-if="!isInboxView"
:chat="chat"
:inbox="inbox"
:show-inbox-name="showInboxName"
:show-assignee="showAssignee"
:assignee="assignee"
/>
<div
class="grid gap-2.5 items-start"
:class="{
'mt-0.5 grid-cols-1': compact,
'grid-cols-[auto,1fr]': !compact,
}"
>
<CardAvatar
v-if="!compact"
:contact="currentContact"
:selected="selected"
:enable-selection="enableSelection"
:hide-thumbnail="hideThumbnail"
class="mt-0.5"
@select-conversation="onSelectConversation"
/>
<div class="min-w-0 flex flex-col gap-1.5">
<div class="min-w-0 flex flex-col gap-px">
<CardHeader
v-if="!compact"
:contact-name="currentContact.name"
:timestamp="chat.timestamp"
:created-at="chat.created_at"
/>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="showExpandedPreview"
/>
</div>
<CardLabels
v-if="showLabelsSection || isInboxView"
:labels="chat.labels"
>
<template #before>
<CardMetaSection
v-if="isInboxView"
:chat="chat"
:inbox="inbox"
:show-assignee="showAssignee"
:assignee="assignee"
:is-labels-empty="chat.labels.length === 0"
inline
/>
<SLACardLabel
v-else-if="hasSlaPolicyId"
ref="slaCardLabel"
data-before-slot
:chat="chat"
/>
</template>
</CardLabels>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,182 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabels.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="relative cursor-pointer group transition-colors duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,294 @@
<script setup>
import { computed, ref, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import ConversationCardExpanded from './ConversationCardExpanded.vue';
// import ConversationCardCompact from './ConversationCardCompact.vue';
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
// eslint-disable-next-line vue/no-unused-properties
enableSelection: { type: Boolean, default: true },
isExpandedLayout: { type: Boolean, default: false },
isPreviousConversations: { type: Boolean, default: false },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const isLargeScreen = inject('isLargeScreen', ref(false));
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
const showExpanded = computed(() => {
return props.isExpandedLayout && isLargeScreen.value;
});
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? contactGetter.value(senderId.value) : {}
);
const hasActiveInbox = computed(() => activeInbox.value);
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
const inbox = computed(() => {
const inboxId = props.chat.inbox_id;
return inboxId ? inboxGetter.value(inboxId) : {};
});
const showInboxName = computed(() => inboxesList.value.length > 1);
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@de-select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) - Using older ConversationCard -->
<ConversationCard
v-else
:active-label="activeLabel"
:team-id="teamId"
:folders-id="foldersId"
:chat="chat"
:conversation-type="conversationType"
:selected="selected"
:show-assignee="showAssignee"
:hide-thumbnail="hideThumbnail"
:compact="compact"
:enable-context-menu="enableContextMenu"
:allowed-context-menu-options="allowedContextMenuOptions"
@select-conversation="
(chatId, inboxId) => emit('selectConversation', chatId, inboxId)
"
@de-select-conversation="
(chatId, inboxId) => emit('deSelectConversation', chatId, inboxId)
"
@assign-agent="
(agent, conversationIds) => emit('assignAgent', agent, conversationIds)
"
@assign-label="
(labels, conversationIds) => emit('assignLabel', labels, conversationIds)
"
@remove-label="
(labels, conversationIds) => emit('removeLabel', labels, conversationIds)
"
@assign-team="
(team, conversationId) => emit('assignTeam', team, conversationId)
"
@mark-as-unread="conversationId => emit('markAsUnread', conversationId)"
@mark-as-read="conversationId => emit('markAsRead', conversationId)"
@assign-priority="
(priority, conversationId) =>
emit('assignPriority', priority, conversationId)
"
@update-conversation-status="
(conversationId, status, snoozedUntil) =>
emit('updateConversationStatus', conversationId, status, snoozedUntil)
"
@delete-conversation="
conversationId => emit('deleteConversation', conversationId)
"
@context-menu-toggle="state => emit('contextMenuToggle', state)"
/>
<!-- New ConversationCardCompact
<ConversationCardCompact
v-else
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:compact="compact"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:hide-thumbnail="hideThumbnail"
:enable-selection="enableSelection"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
-->
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="chat.unread_count > 0"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
() => uiSettings.value.is_contact_sidebar_open || false
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
() => uiSettings.value.is_copilot_panel_open || false
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
is_copilot_panel_open: !isCopilotPanelOpen.value,
});
};
@@ -0,0 +1,100 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import SLAPopoverCard from './SLAPopoverCard.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: conversation.value,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
</template>
@@ -0,0 +1,36 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const { SLA_MISS_TYPES } = wootConstants;
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('SLA.EVENTS.TITLE') }}
</span>
<SLAEventItem
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLAEventItem
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<Button
link
xs
slate
class="hover:!no-underline"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
:label="
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
"
@click="toggleShowAllNRT"
/>
</div>
</template>
</SLAEventItem>
<SLAEventItem
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>
@@ -11,7 +11,13 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
},
},
menuSections: {
@@ -22,6 +28,10 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -42,9 +52,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action', 'search', 'empty']);
const { t } = useI18n();
@@ -96,9 +114,13 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
};
const handleAction = item => {
@@ -190,22 +212,27 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
@@ -214,6 +241,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"
@@ -233,22 +263,27 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
</template>
<div
@@ -258,7 +293,9 @@ onMounted(() => {
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
}}
</div>
<slot name="footer" />
@@ -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,80 @@ 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 py-6 grid gap-6"
:class="{ 'overflow-x-auto': viewInModal }"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12 px-6">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView" class="px-6">
<div class="border-b border-n-strong 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 px-6">
<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 ltr:pl-3 ltr:pr-6 rtl:pr-3 rtl:pl-6"
>
<Button sm ghost blue class="px-0" @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm solid 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">
@@ -95,7 +95,7 @@ const toggleOption = option => {
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
class="bg-n-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
@@ -119,11 +119,13 @@ const toggleOption = option => {
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<Button v-else sm slate solid @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>
+119 -633
View File
@@ -1,77 +1,34 @@
<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,
} from 'dashboard/composables/store.js';
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
// from the current one. But we should consider using tanstack virtual in the future
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ConversationList from './ConversationList.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.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 { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
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';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 },
@@ -85,34 +42,19 @@ const props = defineProps({
const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
provide('contextMenuElementTarget', conversationDynamicScroller);
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 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 chatListFiltersRef = ref(null);
// Provide trigger for label recalculation (parent → child pattern)
const recalculateLabelsKey = ref(0);
provide('recalculateLabelsKey', recalculateLabelsKey);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
@@ -123,18 +65,10 @@ 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 getConversationById = useMapGetter('getConversationById');
const folders = useMapGetter('customViews/getConversationCustomViews');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -143,63 +77,28 @@ const {
selectAllConversations,
resetBulkActions,
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const intersectionObserverOptions = computed(() => {
return {
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
};
});
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length !== 0;
});
const activeFolder = computed(() => {
if (props.foldersId) {
const activeView = folders.value.filter(
view => view.id === Number(props.foldersId)
);
const [firstValue] = activeView;
return firstValue;
}
return undefined;
});
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
const hasActiveFolders = computed(() => {
return Boolean(activeFolder.value && props.foldersId !== 0);
return Boolean(props.foldersId !== 0);
});
const activeFolder = computed(() => {
return chatListFiltersRef.value?.activeFolder;
});
const isFilterModalOpen = computed(() => {
return chatListFiltersRef.value?.showAdvancedFilters ?? false;
});
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);
});
@@ -243,11 +142,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
@@ -317,7 +211,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');
@@ -339,7 +233,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);
@@ -350,7 +244,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -384,205 +278,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);
}
@@ -599,7 +359,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) {
@@ -607,17 +367,6 @@ function loadMoreConversations() {
}
}
// Add a method to handle scroll events
function handleScroll() {
const scroller = conversationDynamicScroller.value;
if (scroller && scroller.hasScrollbar) {
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
if (scrollHeight - (scrollTop + clientHeight) < 100) {
loadMoreConversations();
}
}
}
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -626,6 +375,8 @@ function updateAssigneeTab(selectedTab) {
if (!currentPage.value) {
fetchConversations();
}
// Trigger label position recalculation
recalculateLabelsKey.value += 1;
}
}
@@ -638,174 +389,10 @@ 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,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveConversation(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = getConversationById.value(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Pass conversation context through the modal's API
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = getConversationById.value(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
function allSelectedConversationsStatus(status) {
if (!selectedConversations.value.length) return false;
return selectedConversations.value.every(item => {
return getConversationById.value(item)?.status === status;
return store.getters.getConversationById(item)?.status === status;
});
}
@@ -822,8 +409,6 @@ useEmitter('fetch_conversation_stats', () => {
store.dispatch('conversationStats/get', conversationFilters.value);
});
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
onMounted(() => {
store.dispatch('setChatListFilters', conversationFilters.value);
setFiltersFromUISettings();
@@ -835,46 +420,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('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
watch(
computed(() => props.conversationInbox),
() => resetAndFetchData()
);
watch(
computed(() => props.teamId),
() => resetAndFetchData()
);
watch(
computed(() => props.label),
() => resetAndFetchData()
@@ -883,6 +441,10 @@ watch(
computed(() => props.conversationType),
() => resetAndFetchData()
);
watch(
computed(() => props.foldersId),
() => resetAndFetchData()
);
watch(activeFolder, (newVal, oldVal) => {
if (newVal !== oldVal) {
@@ -904,7 +466,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -919,32 +481,25 @@ 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"
:is-filter-modal-open="isFilterModalOpen"
@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
@@ -954,114 +509,45 @@ watch(conversationFilters, (newVal, oldVal) => {
is-compact
@chat-tab-change="updateAssigneeTab"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex overflow-auto justify-center items-center p-4"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<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"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
@select-all-conversations="toggleSelectAll"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ 'overflow-hidden': isContextMenuOpen }"
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
>
<DynamicScroller
ref="conversationDynamicScroller"
:items="conversationList"
:min-item-size="24"
class="overflow-auto w-full h-full"
>
<template #default="{ item, index, active }">
<!--
If we encounter resizing issues, we can set the `watchData` prop to true
this will deeply watch the entire object instead of just size dependencies
But it can impact performance
-->
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
:size-dependencies="[
item.messages,
item.labels,
item.uuid,
item.inbox_id,
]"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</template>
</DynamicScroller>
</div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
>
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationList
:items="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:is-context-menu-open="isContextMenuOpen"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
</div>
</template>
@@ -0,0 +1,309 @@
<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,
showAdvancedFilters,
});
</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>
@@ -16,15 +16,10 @@ const props = defineProps({
isOnExpandedLayout: { type: Boolean, required: true },
conversationStats: { type: Object, required: true },
isListLoading: { type: Boolean, required: true },
isFilterModalOpen: { type: Boolean, default: false },
});
const emit = defineEmits([
'addFolders',
'deleteFolders',
'resetFilters',
'basicFilterChange',
'filtersModal',
]);
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
const { uiSettings, updateUISettings } = useUISettings();
@@ -56,61 +51,33 @@ 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"
:title="pageTitle"
>
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
{{ pageTitle }}
</h1>
<span
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
@@ -118,8 +85,8 @@ const toggleConversationLayout = () => {
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
icon="i-lucide-pen-line"
slate
xs
faded
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
@click="emit('filtersModal')"
/>
<div
@@ -128,15 +95,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
@@ -144,8 +102,8 @@ const toggleConversationLayout = () => {
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
icon="i-lucide-list-filter"
slate
xs
faded
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
@click="emit('filtersModal')"
/>
<div
@@ -154,11 +112,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,51 +1,170 @@
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
<script setup>
import { inject } from 'vue';
import { useStore } from 'vuex';
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 { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardV5.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
source: {
type: Object,
required: true,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
};
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
isExpandedLayout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'deleteConversation',
'showResolveAttributesModal',
'redirectToList',
]);
const store = useStore();
const { t } = useI18n();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const toggleContextMenu = inject('toggleContextMenu');
const isConversationSelected = inject('isConversationSelected');
const { checkMissingAttributes } = useConversationRequiredAttributes();
const { onAssignAgent, onAssignLabels, onRemoveLabels } = 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 toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function updateConversationStatus(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = store.getters.getConversationById(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Emit event to parent to show modal
emit('showResolveAttributesModal', {
conversationId,
snoozedUntil,
missing,
currentCustomAttributes,
});
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
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,
});
emit('redirectToList');
} catch (error) {
// Ignore error
}
}
async function markAsRead(conversationId) {
try {
await store.dispatch('markMessagesRead', {
id: conversationId,
});
} catch (error) {
// Ignore error
}
}
function handleDelete() {
emit('deleteConversation', props.source.id);
}
</script>
<template>
@@ -58,18 +177,19 @@ export default {
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
:is-expanded-layout="isExpandedLayout"
enable-context-menu
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-agent="onAssignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@assign-label="onAssignLabels"
@remove-label="onRemoveLabels"
@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"
/>
</template>
@@ -0,0 +1,276 @@
<script setup>
import { provide, useTemplateRef, shallowRef, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { Virtualizer } from 'virtua/vue';
import { useInfiniteScroll, useBreakpoints } from '@vueuse/core';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAlert } from 'dashboard/composables';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
items: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
isContextMenuOpen: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const parentRef = useTemplateRef('parentRef');
const virtualizerRef = shallowRef(null);
const deleteConversationDialogRef = ref(null);
const conversationToDelete = ref(null);
const resolveAttributesModalRef = ref(null);
provide('contextMenuElementTarget', parentRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLargeScreen = breakpoints.greaterOrEqual('lg');
provide('isLargeScreen', isLargeScreen);
useInfiniteScroll(
parentRef,
() => {
if (props.isLoading || props.showEndOfListMessage) return;
emit('loadMore');
},
{
distance: 400,
}
);
// Keyboard navigation for conversation list
const handleConversationNavigation = direction => {
if (!parentRef.value || props.items.length === 0) return;
const activeElement = parentRef.value.querySelector('.active');
if (!activeElement) {
// No active conversation, select first one
const firstWrapper = parentRef.value.querySelector('[data-id]');
firstWrapper?.firstElementChild?.click();
return;
}
// Find current conversation ID from active element
const currentWrapper = activeElement.closest('[data-id]');
const currentId = currentWrapper?.dataset.id;
if (!currentId) return;
// Find index in data array
const currentIndex = props.items.findIndex(
item => String(item.id) === currentId
);
if (currentIndex === -1) return;
// Calculate target index
const delta = direction === 'previous' ? -1 : 1;
const targetIndex = currentIndex + delta;
// Clamp to valid range
if (targetIndex < 0 || targetIndex >= props.items.length) return;
// Find and click target conversation
const targetId = props.items[targetIndex].id;
const targetWrapper = parentRef.value.querySelector(
`[data-id="${targetId}"]`
);
targetWrapper?.firstElementChild?.click();
};
useKeyboardEvents({
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
});
function handleDeleteConversation(conversationId) {
conversationToDelete.value = conversationId;
deleteConversationDialogRef.value?.open();
}
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 (props.teamId) {
path = `/app/accounts/${accountId}/team/${props.teamId}`;
} else if (props.label) {
path = `/app/accounts/${accountId}/label/${props.label}`;
} else if (inboxId) {
path = `/app/accounts/${accountId}/inbox/${inboxId}`;
}
router.push(path);
}
async function deleteConversation() {
if (!conversationToDelete.value) return;
try {
await store.dispatch('deleteConversation', conversationToDelete.value);
redirectToConversationList();
deleteConversationDialogRef.value?.close();
conversationToDelete.value = null;
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
function handleShowResolveAttributesModal({
conversationId,
snoozedUntil,
missing,
currentCustomAttributes,
}) {
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
}
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = store.getters.getConversationById(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
</script>
<template>
<div
ref="parentRef"
class="flex-1 w-full h-full touch-pan-y overflow-x-hidden overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] [overflow-anchor:none] [will-change:scroll-position] px-2 pt-2"
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
>
<Virtualizer
ref="virtualizerRef"
:data="items"
:scroll-ref="parentRef"
:item-size="isLargeScreen && isExpandedLayout ? 48 : 76"
:buffer-size="100"
item="div"
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
>
<template #default="{ item }">
<div :key="item.id" :data-id="item.id">
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:is-expanded-layout="isExpandedLayout"
@delete-conversation="handleDeleteConversation"
@show-resolve-attributes-modal="handleShowResolveAttributesModal"
@redirect-to-list="redirectToConversationList"
/>
</div>
</template>
</Virtualizer>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: conversationToDelete,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
/>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
<div class="py-2">
<div v-if="isLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
>
<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>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { computed, inject } from 'vue';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
const props = defineProps({
index: {
@@ -28,11 +29,14 @@ const props = defineProps({
},
});
const { formatCompactWithDecimal } = useNumberFormatter();
const activeIndex = inject('activeIndex');
const updateActiveIndex = inject('updateActiveIndex');
const active = computed(() => props.index === activeIndex.value);
const getItemCount = computed(() => props.count);
const count = computed(() => props.count);
const getItemCount = computed(() => formatCompactWithDecimal(count.value));
const onTabClick = event => {
event.preventDefault();
@@ -59,7 +63,11 @@ const onTabClick = event => {
{{ name }}
<div
v-if="showBadge"
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
v-tooltip.top="{
content: count,
delay: { show: 500, hide: 0 },
}"
class="rounded-full h-5 flex items-center justify-center font-interDisplay text-xs font-medium 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 h-10"
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:left-0 rtl:right-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,18 +132,21 @@ 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"
slate
faded
xs
sm
:variant="showActionsDropdown ? 'faded' : 'ghost'"
@click="toggleDropdown()"
/>
<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,
@@ -243,7 +243,7 @@ const deleteConversation = () => {
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-3': !compact,
'px-2 rounded-lg': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -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-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
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 bottom-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 md:[&>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-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
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="bottom-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 mb-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 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
: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/get');
});
</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-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
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 bottom-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-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
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 bottom-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,313 +12,172 @@ 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">
<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"
/>
</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"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
<Transition
enter-active-class="transition-all duration-200 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-2"
>
<div
v-show="conversations.length > 0"
class="px-2 absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
<div
v-if="allConversationsSelected"
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<div
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
>
<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
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
ghost
class="!text-n-blue-11 !px-1 !h-6"
sm
@click="allSelected = false"
/>
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="onUpdateConversations"
/>
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="onAssignAgent"
/>
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</div>
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</Transition>
</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>
@@ -23,9 +23,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() {
@@ -39,6 +39,7 @@ export default {
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
LARGE_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -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",
@@ -38,6 +40,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."
},
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -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,22 +71,11 @@ 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>
@@ -107,56 +94,33 @@ const handleAssignLabels = labels => {
<Button
sm
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"
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
@assign="handleAssignLabels"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
sm
faded
ghost
ruby
icon="i-lucide-trash"
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
: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 md:[&>span:nth-child(2)]:inline-flex"
@click="emit('deleteSelected')"
/>
</Policy>
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, watch, onMounted, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { onBeforeRouteLeave } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import ChatList from 'dashboard/components/ChatList.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
@@ -11,187 +14,144 @@ import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
ChatList,
ConversationBox,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
const props = defineProps({
inboxId: {
type: [String, Number],
default: 0,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
conversationId: {
type: [String, Number],
default: 0,
},
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
});
return {
uiSettings,
updateUISettings,
accountId,
};
},
data() {
return {
showSearchModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
showMessageView() {
return this.conversationId ? true : !this.isOnExpandedLayout;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
const route = useRoute();
const store = useStore();
const { uiSettings } = useUISettings();
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
const chatList = useMapGetter('getAllConversations');
const currentChat = useMapGetter('getSelectedChat');
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
},
watch: {
conversationId() {
this.fetchConversationIfUnavailable();
},
},
const isOnExpandedLayout = computed(() => {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
uiSettings.value;
return conversationDisplayType !== CONDENSED;
});
created() {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
},
const showConversationList = computed(() =>
isOnExpandedLayout.value ? !props.conversationId : true
);
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('portals/index');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
this.setActiveChat();
});
},
const showMessageView = computed(() =>
props.conversationId ? true : !isOnExpandedLayout.value
);
methods: {
onConversationLoad() {
this.fetchConversationIfUnavailable();
},
initialize() {
this.$store.dispatch('setActiveInbox', this.inboxId);
this.setActiveChat();
},
toggleConversationLayout() {
const { LAYOUT_TYPES } = wootConstants;
const {
conversation_display_type:
conversationDisplayType = LAYOUT_TYPES.CONDENSED,
} = this.uiSettings;
const newViewType =
conversationDisplayType === LAYOUT_TYPES.CONDENSED
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
this.updateUISettings({
conversation_display_type: newViewType,
previously_used_conversation_display_type: newViewType,
});
},
fetchConversationIfUnavailable() {
if (!this.conversationId) {
return;
}
const chat = this.findConversation();
if (!chat) {
this.$store.dispatch('getConversation', this.conversationId);
}
},
findConversation() {
const conversationId = parseInt(this.conversationId, 10);
const [chat] = this.chatList.filter(c => c.id === conversationId);
return chat;
},
setActiveChat() {
if (this.conversationId) {
const selectedConversation = this.findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === this.currentChat.id
) {
return;
}
const { messageId } = this.$route.query;
this.$store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
}
},
onSearch() {
this.showSearchModal = true;
},
closeSearch() {
this.showSearchModal = false;
},
},
const shouldShowSidebar = computed(() => {
if (!currentChat.value.id) {
return false;
}
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
});
const findConversation = () => {
const conversationId = parseInt(props.conversationId, 10);
const [chat] = chatList.value.filter(c => c.id === conversationId);
return chat;
};
const fetchConversationIfUnavailable = () => {
if (!props.conversationId) {
return;
}
const chat = findConversation();
if (!chat) {
store.dispatch('getConversation', props.conversationId);
}
};
const setActiveChat = () => {
if (props.conversationId) {
const selectedConversation = findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === currentChat.value.id
) {
return;
}
const { messageId } = route.query;
store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
store.dispatch('clearSelectedState');
}
};
const initialize = () => {
store.dispatch('setActiveInbox', props.inboxId);
setActiveChat();
};
const onConversationLoad = () => {
fetchConversationIfUnavailable();
};
onBeforeRouteLeave((to, from, next) => {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (props.conversationId) {
store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
});
watch(() => props.conversationId, fetchConversationIfUnavailable);
watch(() => store.state.route, initialize);
watch(() => chatList.value.length, setActiveChat);
onBeforeMount(() => {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!props.conversationId) {
store.dispatch('clearSelectedState');
}
});
onMounted(() => {
store.dispatch('agents/get');
store.dispatch('portals/index');
initialize();
});
</script>
<template>
@@ -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"
sm
ghost
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>
@@ -361,4 +361,77 @@ describe('useNumberFormatter', () => {
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
});
});
describe('formatCompactWithDecimal', () => {
it('should return exact numbers for values under 1,000', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(0)).toBe('0');
expect(formatCompactWithDecimal(1)).toBe('1');
expect(formatCompactWithDecimal(42)).toBe('42');
expect(formatCompactWithDecimal(999)).toBe('999');
});
it('should return compact format with decimals for thousands', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1000)).toBe('1K');
expect(formatCompactWithDecimal(1500)).toBe('1.5K');
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
expect(formatCompactWithDecimal(15000)).toBe('15K');
expect(formatCompactWithDecimal(15500)).toBe('15.5K');
});
it('should return compact format with decimals for millions', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1000000)).toBe('1M');
expect(formatCompactWithDecimal(1200000)).toBe('1.2M');
expect(formatCompactWithDecimal(1234000)).toBe('1.2M');
expect(formatCompactWithDecimal(2500000)).toBe('2.5M');
expect(formatCompactWithDecimal(10000000)).toBe('10M');
});
it('should handle edge cases gracefully', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(null)).toBe('0');
expect(formatCompactWithDecimal(undefined)).toBe('0');
expect(formatCompactWithDecimal(NaN)).toBe('0');
expect(formatCompactWithDecimal('string')).toBe('0');
});
it('should handle negative numbers', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(-500)).toBe('-500');
expect(formatCompactWithDecimal(-1500)).toBe('-1.5K');
expect(formatCompactWithDecimal(-29400)).toBe('-29.4K');
expect(formatCompactWithDecimal(-1200000)).toBe('-1.2M');
});
it('should respect custom decimal places', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1234, 2)).toBe('1.23K');
expect(formatCompactWithDecimal(1234567, 2)).toBe('1.23M');
});
it('should format with de-DE locale', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
// German uses different compact notation
const result = formatCompactWithDecimal(29400);
expect(result).toMatch(/29[,.]?4/);
});
it('should handle underscore-based locale tags', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
});
});
});
@@ -60,8 +60,37 @@ export function useNumberFormatter() {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
};
/**
* Format a number with decimal precision using compact notation (standard Intl approach)
* - Up to 1,000: show exact number (e.g., 999)
* - 1,000+: show compact with decimals (e.g., 29400 → "29.4K", 1500 → "1.5K")
* - 1,000,000+: show as "X.XM" (e.g., 1,234,000 → "1.2M")
*
* Uses browser-native Intl.NumberFormat for proper localization
*
* @param {number} num - The number to format
* @param {number} [decimals=1] - Maximum decimal places to show
* @returns {string} Formatted number string with decimal precision
*/
const formatCompactWithDecimal = (num, decimals = 1) => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
if (Math.abs(num) < 1000) {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
}
return new Intl.NumberFormat(resolvedLocale.value, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: decimals,
}).format(num);
};
return {
formatCompactNumber,
formatFullNumber,
formatCompactWithDecimal,
};
}