Merge branch 'develop' into fix/CW-6859

This commit is contained in:
Sivin Varghese
2026-04-20 14:06:50 +05:30
committed by GitHub
55 changed files with 3018 additions and 1231 deletions
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<template #trigger>
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -114,8 +114,8 @@ const emit = defineEmits([
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
<template #trigger>
<Button :label="buttonLabel" size="sm" />
</template>
</ComposeConversation>
</div>
@@ -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,175 @@
<script setup>
import { ref, computed, 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({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// 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 });
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
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 }"
>
<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: 500, 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,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,183 @@
<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 './CardLabelsV5.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="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
'hover:bg-n-alpha-1': !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
/>
<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 v-if="!isInboxView" 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 flex-shrink-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-body-main 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
:conversation-id="chat.id"
: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,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-teal-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-body-main text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
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: () => ({}),
},
});
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 hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
});
};
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>
</div>
<template v-else />
</template>
@@ -2,13 +2,10 @@
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -18,22 +15,18 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
align: {
type: String,
default: 'end',
},
});
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const popoverRef = ref(null);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
};
const closeCompose = () => {
showComposeNewConversation.value = false;
popoverRef.value?.hide();
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
@@ -180,7 +158,6 @@ const closeCompose = () => {
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
const onPopoverShow = () => {
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onPopoverHide = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
emit('close');
};
watch(
@@ -242,64 +226,22 @@ watch(
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
handleClickOutside();
};
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{
'z-50': showComposeNewConversation && !viewInModal,
}"
<Popover
ref="popoverRef"
:align="align"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<div
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<template #default="{ isOpen }">
<slot name="trigger" :is-open="isOpen" />
</template>
<template #content>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -321,6 +263,6 @@ useKeyboardEvents(keyboardEvents);
@create-conversation="createConversation"
@discard="discardCompose"
/>
</div>
</div>
</template>
</Popover>
</template>
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -361,7 +361,7 @@ useKeyboardEvents({
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
@@ -0,0 +1,121 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
align: {
type: String,
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
});
const emit = defineEmits(['show', 'hide']);
const isActive = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
showPopover,
{ align: props.align }
);
const show = async () => {
isActive.value = true;
if (!isMobile.value) {
await nextTick();
updatePosition();
}
emit('show');
};
const hide = () => {
if (!isActive.value) return;
isActive.value = false;
emit('hide');
};
const toggle = async () => {
if (isActive.value) hide();
else await show();
};
// Recalculate position when switching from mobile to desktop while open
watch(isMobile, async mobile => {
if (!isActive.value || mobile) return;
await nextTick();
updatePosition();
});
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
hide();
};
// Selectors for teleported elements that should not trigger close
const clickOutsideIgnore = [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
];
useKeyboardEvents({
Escape: {
action: () => isActive.value && hide(),
allowOnFocusedInput: true,
},
});
defineExpose({ show, hide, toggle });
</script>
<template>
<span ref="triggerRef" class="inline-flex" @click="toggle">
<slot :is-open="isActive" />
</span>
<TeleportWithDirection to="body">
<!-- Mobile: centered modal with backdrop -->
<div
v-if="isActive && isMobile"
data-popover-backdrop
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
>
<div
ref="mobileContentRef"
v-on-click-outside="[
handleClickOutside,
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
<!-- Desktop: fixed popover -->
<div
v-else-if="showPopover"
ref="popoverRef"
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
>
<slot name="content" :hide="hide" />
</div>
</TeleportWithDirection>
</template>
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -734,7 +723,13 @@ const menuItems = computed(() => {
<aside
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
{
ignore: [
'#mobile-sidebar-launcher',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
@@ -802,8 +797,8 @@ const menuItems = computed(() => {
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle, isOpen }">
<ComposeConversation align="start">
<template #trigger="{ isOpen }">
<Button
icon="i-lucide-pen-line"
color="slate"
@@ -815,7 +810,6 @@ const menuItems = computed(() => {
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
@@ -1,15 +1,5 @@
<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, unref, provide, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -17,23 +7,19 @@ import {
useFunctionGetter,
} from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
@@ -85,10 +71,6 @@ const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', virtualListRef);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -100,7 +82,6 @@ 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 => ({
@@ -130,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -360,7 +340,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -605,14 +585,6 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesnt fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -806,10 +778,6 @@ function allSelectedConversationsStatus(status) {
});
}
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
}
function toggleSelectAll(check) {
selectAllConversations(check, conversationList);
}
@@ -857,7 +825,6 @@ provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
@@ -970,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
<ConversationList
:conversation-list="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-on-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
@@ -1,74 +1,244 @@
<script>
<script setup>
import { computed, ref, watch, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
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,
},
},
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
const props = defineProps({
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 },
showExpanded: { type: Boolean, default: false },
});
const router = useRouter();
const store = useStore();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const assignAgent = inject('assignAgent');
const assignTeam = inject('assignTeam');
const assignLabels = inject('assignLabels');
const removeLabels = inject('removeLabels');
const updateConversationStatus = inject('updateConversationStatus');
const toggleContextMenu = inject('toggleContextMenu');
const markAsUnread = inject('markAsUnread');
const markAsRead = inject('markAsRead');
const assignPriority = inject('assignPriority');
const isConversationSelected = inject('isConversationSelected');
const deleteConversation = inject('deleteConversation');
// --- Context menu state (shared by both layouts) ---
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset context menu state when the row is recycled to a different conversation.
watch(
() => props.source.id,
() => {
if (showContextMenu.value) {
toggleContextMenu(false);
}
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
}
);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.source.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
);
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
const inbox = computed(() => {
const inboxId = props.source.inbox_id;
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
});
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const isInboxView = computed(() => !!activeInbox.value);
const showAssigneeForExpandedCard = computed(
() => props.showExpanded || props.showAssignee
);
const conversationPath = computed(() =>
frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.source.id,
label: props.label,
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 onExpandedSelect = checked => {
if (checked) {
selectConversation(props.source.id, inbox.value.id);
} else {
deSelectConversation(props.source.id, inbox.value.id);
}
};
const openContextMenu = e => {
e.preventDefault();
toggleContextMenu(true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
toggleContextMenu(false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
updateConversationStatus(props.source.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
assignAgent(agent, [props.source.id]);
closeContextMenu();
};
const onAssignLabel = label => {
assignLabels([label.title], [props.source.id]);
};
const onRemoveLabel = label => {
removeLabels([label.title], [props.source.id]);
};
const onAssignTeam = team => {
assignTeam(team, props.source.id);
closeContextMenu();
};
const onMarkAsUnread = () => {
markAsUnread(props.source.id);
closeContextMenu();
};
const onMarkAsRead = () => {
markAsRead(props.source.id);
closeContextMenu();
};
const onAssignPriority = priority => {
assignPriority(priority, props.source.id);
closeContextMenu();
};
const onDeleteConversation = () => {
deleteConversation(props.source.id);
closeContextMenu();
};
</script>
<template>
<ConversationCard
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
<!-- Expanded layout: wide screen + expanded setting -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="source"
:conversation-type="conversationType"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssigneeForExpandedCard"
:show-inbox-name="showInboxName"
:is-inbox-view="isInboxView"
@select-conversation="onExpandedSelect"
@de-select-conversation="onExpandedSelect"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Default (condensed) layout -->
<ConversationCard
v-else
:chat="source"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
enable-context-menu
:show-inbox-name="showInboxName"
@click="onCardClick"
@contextmenu="openContextMenu"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
<!-- Shared context menu for both layouts -->
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="source.status"
:inbox-id="inbox.id"
:priority="source.priority"
:chat-id="source.id"
:has-unread-messages="source.unread_count > 0"
:conversation-labels="source.labels"
:conversation-url="conversationPath"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="onMarkAsUnread"
@mark-as-read="onMarkAsRead"
@assign-priority="onAssignPriority"
@delete-conversation="onDeleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { ref, computed, provide } from 'vue';
import { Virtualizer } from 'virtua/vue';
import { useBreakpoints } from '@vueuse/core';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
conversationList: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { 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 },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
const isContextMenuOpen = ref(false);
provide('contextMenuElementTarget', virtualListRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLgScreen = breakpoints.greaterOrEqual('lg');
const showExpandedCards = computed(
() => props.isOnExpandedLayout && isLgScreen.value
);
useChatListKeyboardEvents(conversationListRef);
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
const onContextMenuToggle = state => {
isContextMenuOpen.value = state;
};
const loadMoreConversations = () => {
emit('loadMore');
};
provide('toggleContextMenu', onContextMenuToggle);
defineExpose({ conversationListRef });
</script>
<template>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:key="index"
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:show-expanded="showExpandedCards"
/>
</Virtualizer>
<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-n-slate-11">
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
</template>
@@ -1,98 +1,41 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
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: '' },
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 },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// This prevents context menu/hover state from leaking to a different conversation
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
});
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
];
});
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;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
emit('deSelectConversation', props.chat.id, props.inbox.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 selectedModel = computed({
get: () => props.selected,
set: value => onSelectConversation(value),
});
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();
};
watch(
() => props.chat.id,
() => {
hovered.value = false;
}
);
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@@ -272,7 +120,6 @@ const deleteConversation = () => {
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
@@ -281,20 +128,12 @@ const deleteConversation = () => {
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="px-0 py-3 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
@@ -369,12 +208,11 @@ const deleteConversation = () => {
:conversation-id="chat.id"
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
<UnreadBadge
v-if="hasUnread"
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
@@ -386,32 +224,5 @@ const deleteConversation = () => {
</template>
</CardLabels>
</div>
<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="hasUnread"
: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>
</div>
</template>
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
<template>
<div
v-on-click-outside="() => closeContactPanel()"
v-on-click-outside="[
() => closeContactPanel(),
{
ignore: [
'dialog.ProseMirror-prompt-backdrop',
'[data-popover-content]',
'[data-popover-backdrop]',
],
},
]"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
@@ -0,0 +1,239 @@
import { ref } from 'vue';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
// Mock @vueuse/core — return reactive refs we can control per test
const mockBounding = () => ({
top: ref(0),
bottom: ref(0),
left: ref(0),
right: ref(0),
width: ref(0),
height: ref(0),
update: vi.fn(),
});
const triggerBounds = mockBounding();
const dropdownBounds = mockBounding();
const winWidth = ref(1024);
const winHeight = ref(768);
vi.mock('@vueuse/core', () => {
let callCount = 0;
return {
// First call = trigger, second = dropdown, third = container (if any)
useElementBounding: () => {
callCount += 1;
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
},
useWindowSize: () => ({ width: winWidth, height: winHeight }),
};
});
const setTrigger = ({ top, bottom, left, right }) => {
triggerBounds.top.value = top;
triggerBounds.bottom.value = bottom;
triggerBounds.left.value = left ?? 100;
triggerBounds.right.value = right ?? 200;
};
const setDropdown = ({ width, height }) => {
dropdownBounds.width.value = width ?? 200;
dropdownBounds.height.value = height;
};
describe('useDropdownPosition', () => {
beforeEach(() => {
winWidth.value = 1024;
winHeight.value = 768;
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
});
describe('verticalClass (relative mode)', () => {
it('places below when enough space', () => {
// Trigger at y=100, dropdown 200px tall
// Space below = 768 - 140 = 628 → fits (628 > 216)
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('places above when not enough space below but enough above', () => {
// Trigger near bottom at y=600
// Space below = 128 → doesn't fit. Space above = 600 → fits
setTrigger({ top: 600, bottom: 640 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('picks the side with more space when dropdown fits neither', () => {
// Dropdown 500px tall, won't fit above (300) or below (428)
// Below has more room → stays below
setTrigger({ top: 300, bottom: 340 });
setDropdown({ height: 500 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('top-full mt-2');
});
it('picks above when above has more space and neither fits', () => {
// Dropdown 600px tall, won't fit above (500) or below (228)
// Above has more room → flips above
setTrigger({ top: 500, bottom: 540 });
setDropdown({ height: 600 });
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
expect(position.value.class).toBe('bottom-full mb-2');
});
it('returns default when disabled', () => {
setTrigger({ top: 700, bottom: 740 });
setDropdown({ height: 200 });
const { position } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(position.value.class).toBe('top-full mt-2');
expect(position.value.style).toEqual({});
});
});
describe('fixedPosition', () => {
it('places below with correct top and maxHeight', () => {
// Trigger at y=140, space below = 628
// top = 140 + 8(gap) = 148
// maxHeight = 628 - 8(gap) - 16(margin) = 604
setTrigger({ top: 100, bottom: 140 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.top).toBe('148px');
expect(fixedPosition.value.style.bottom).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('604px');
});
it('flips above with correct bottom and maxHeight', () => {
// Trigger near bottom at y=650, space below = 78 → doesn't fit
// Flips above: bottom = 768 - 650 + 8 = 126
// maxHeight = 650 - 8 - 16 = 626
setTrigger({ top: 650, bottom: 690 });
setDropdown({ height: 200, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBe('126px');
expect(fixedPosition.value.style.top).toBeUndefined();
expect(fixedPosition.value.style.maxHeight).toBe('626px');
});
it('constrains maxHeight to available space on short viewports', () => {
// Short viewport (400px), trigger in the middle, dropdown 500px tall
// Neither side fits → above (200) > below (160) → places above
// maxHeight capped to 200 - 8 - 16 = 176
winHeight.value = 400;
setTrigger({ top: 200, bottom: 240 });
setDropdown({ height: 500, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.bottom).toBeDefined();
expect(fixedPosition.value.style.maxHeight).toBe('176px');
});
it('returns defaults when disabled', () => {
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(false)
);
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
expect(fixedPosition.value.style).toEqual({});
});
});
describe('horizontal positioning (fixedPosition)', () => {
it('anchors to the right edge by default (align=end, LTR)', () => {
// align=end + LTR → anchorLeft=false → uses style.right
// right = 1024 - 900 = 124
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.right).toBe('124px');
});
it('anchors to the left edge when align=start (LTR)', () => {
// align=start + LTR → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.left).toBe('100px');
});
it('shifts left when dropdown overflows right edge', () => {
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
// Falls back to right: 16px (margin)
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
setDropdown({ height: 100, width: 300 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true),
{ align: 'start' }
);
expect(fixedPosition.value.style.right).toBe('16px');
});
});
describe('RTL', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
});
it('flips anchor direction in RTL (align=end anchors left)', () => {
// align=end + RTL → anchorLeft=true → uses style.left
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
setDropdown({ height: 100, width: 200 });
const { fixedPosition } = useDropdownPosition(
ref(null),
ref(null),
ref(true)
);
expect(fixedPosition.value.style.left).toBe('100px');
});
});
});
@@ -0,0 +1,128 @@
import { computed, unref, watch } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
const FALLBACK_SIZE = 200;
const SAFE_MARGIN = 16;
const GAP = 8;
/**
* Auto-position a floating element based on available viewport space.
*
* @param {Ref} triggerRef - Trigger element ref
* @param {Ref} dropdownRef - Dropdown/popover element ref
* @param {Ref} enabled - Whether to calculate position
* @param {Object} options
* @param {Ref} [options.container] - Constraining container ref
* @param {number} [options.margin=16] - Min distance from viewport/container edges
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
*/
export function useDropdownPosition(
triggerRef,
dropdownRef,
enabled,
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
) {
const trigger = useElementBounding(triggerRef);
const dropdown = useElementBounding(dropdownRef);
const bounds = useElementBounding(container);
const { width: winWidth, height: winHeight } = useWindowSize();
const isRTL = computed(
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
);
// Whether to anchor to the left edge of the trigger
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
const verticalClass = computed(() => {
if (!unref(enabled)) return 'top-full mt-2';
const dh = dropdown.height.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const spaceAbove = trigger.top.value;
// Only flip above if it fits there; otherwise stay below (more room or equal)
if (spaceBelow >= dh + margin) return 'top-full mt-2';
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
});
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
const position = computed(() => {
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
const dw = dropdown.width.value || FALLBACK_SIZE;
const leftBound = container ? bounds.left.value : 0;
const rightBound = container ? bounds.right.value : winWidth.value;
const style = {};
if (anchorLeft.value) {
const available = rightBound - trigger.left.value;
const overflow = dw - available;
style.left = overflow > 0 ? `-${overflow}px` : '0px';
} else {
const available = trigger.right.value - leftBound;
const overflow = dw - available;
style.right = overflow > 0 ? `-${overflow}px` : '0px';
}
return { class: verticalClass.value, style };
});
// Fixed mode: styles for teleported popovers
const fixedPosition = computed(() => {
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
const dh = dropdown.height.value || FALLBACK_SIZE;
const dw = dropdown.width.value || FALLBACK_SIZE;
const spaceBelow = winHeight.value - trigger.bottom.value;
const style = {};
// Vertical: prefer below, flip above only if it fits, else pick the larger side
const spaceAbove = trigger.top.value;
const placeAbove =
spaceBelow < dh + margin &&
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
if (placeAbove) {
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
style.maxHeight = `${spaceAbove - GAP - margin}px`;
} else {
style.top = `${trigger.bottom.value + GAP}px`;
style.maxHeight = `${spaceBelow - GAP - margin}px`;
}
// Horizontal
if (anchorLeft.value) {
const left = trigger.left.value;
if (left + dw > winWidth.value - margin) {
style.right = `${margin}px`;
} else {
style.left = `${Math.max(margin, left)}px`;
}
} else {
const right = winWidth.value - trigger.right.value;
if (trigger.right.value - dw < margin) {
style.left = `${margin}px`;
} else {
style.right = `${right}px`;
}
}
return { class: 'fixed z-[9999]', style };
});
const updatePosition = () => {
trigger.update();
dropdown.update();
if (container) bounds.update();
};
// Update position when dropdown opens to ensure RTL state is current
watch(
() => unref(enabled),
isEnabled => {
if (isEnabled) updatePosition();
}
);
return { position, fixedPosition, updatePosition };
}
@@ -45,6 +45,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',
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -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",
@@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from 'dashboard/helper/routeHelpers';
const props = defineProps({
contact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close', 'deleted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const uiFlags = useMapGetter('contacts/getUIFlags');
const confirmMessage = computed(
() => `${t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${props.contact.name}?`
);
const onDelete = async hide => {
try {
await store.dispatch('contacts/delete', props.contact.id);
useAlert(t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
hide();
emit('deleted');
emit('close');
if (isAConversationRoute(route.name)) {
router.push({ name: getConversationDashboardRoute(route.name) });
} else if (isAInboxViewRoute(route.name)) {
router.push({ name: 'inbox_view' });
} else if (route.name !== 'contacts_dashboard') {
router.push({ name: 'contacts_dashboard' });
}
} catch (error) {
useAlert(error.message || t('DELETE_CONTACT.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-80 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ confirmMessage }}
</p>
</div>
<div class="flex items-center justify-end gap-2">
<Button
faded
slate
sm
:label="$t('DELETE_CONTACT.CONFIRM.NO')"
@click="hide"
/>
<Button
ruby
sm
:label="$t('DELETE_CONTACT.CONFIRM.YES')"
:is-loading="uiFlags.isDeleting"
:disabled="uiFlags.isDeleting"
@click="onDelete(hide)"
/>
</div>
</div>
</template>
</Popover>
</template>
@@ -5,8 +5,8 @@ import { useStore } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ContactAPI from 'dashboard/api/contacts';
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -23,7 +23,6 @@ const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const dialogRef = ref(null);
const isSearching = ref(false);
const searchResults = ref([]);
@@ -35,21 +34,6 @@ watch(
}
);
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
const onClose = () => {
close();
emit('close');
};
const onContactSearch = async query => {
isSearching.value = true;
searchResults.value = [];
@@ -68,7 +52,7 @@ const onContactSearch = async query => {
}
};
const onMergeContacts = async parentContactId => {
const onMergeContacts = async (parentContactId, hide) => {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
@@ -76,7 +60,7 @@ const onMergeContacts = async parentContactId => {
parentId: parentContactId,
});
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
close();
hide();
emit('close');
} catch (error) {
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
@@ -85,24 +69,31 @@ const onMergeContacts = async parentContactId => {
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
width="2xl"
:title="$t('MERGE_CONTACTS.TITLE')"
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
>
<MergeContact
:key="primaryContact.id"
:primary-contact="primaryContact"
:is-searching="isSearching"
:is-merging="uiFlags.isMerging"
:search-results="searchResults"
@search="onContactSearch"
@cancel="onClose"
@submit="onMergeContacts"
/>
</Dialog>
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-96 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('MERGE_CONTACTS.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('MERGE_CONTACTS.DESCRIPTION') }}
</p>
</div>
<MergeContact
:key="primaryContact.id"
:primary-contact="primaryContact"
:is-searching="isSearching"
:is-merging="uiFlags.isMerging"
:search-results="searchResults"
@search="onContactSearch"
@cancel="hide"
@submit="id => onMergeContacts(id, hide)"
/>
</div>
</template>
</Popover>
</template>
@@ -1,49 +1,131 @@
<script>
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
import { mapGetters } from 'vuex';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
ConversationCard,
Spinner,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
conversationId: {
type: [String, Number],
required: true,
},
},
computed: {
conversations() {
return this.$store.getters['contactConversations/getContactConversation'](
this.contactId
);
},
previousConversations() {
return this.conversations.filter(
conversation => conversation.id !== Number(this.conversationId)
);
},
...mapGetters({
uiFlags: 'contactConversations/getUIFlags',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contactConversations/get', newContactId);
}
},
},
mounted() {
this.$store.dispatch('contactConversations/get', this.contactId);
},
const props = defineProps({
contactId: { type: [String, Number], required: true },
conversationId: { type: [String, Number], required: true },
});
const store = useStore();
const route = useRoute();
const router = useRouter();
const currentChat = useMapGetter('getSelectedChat');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const activeInbox = useMapGetter('getSelectedInbox');
const inboxesList = useMapGetter('inboxes/getInboxes');
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const contactConversationGetter = useMapGetter(
'contactConversations/getContactConversation'
);
const conversations = computed(() =>
contactConversationGetter.value(props.contactId)
);
const previousConversations = computed(() =>
conversations.value.filter(c => c.id !== Number(props.conversationId))
);
const activeContextChat = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const buildConversationUrl = conversationId => {
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';
}
return frontendURL(
conversationUrl({
accountId,
activeInbox: inboxId,
id: conversationId,
label,
teamId,
foldersId: isOnFoldersView({ route: { name } }) ? route.params.id : 0,
conversationType,
})
);
};
const conversationPath = computed(() => {
if (!activeContextChat.value) return '';
return buildConversationUrl(activeContextChat.value.id);
});
const onCardClick = (conversation, e) => {
const path = buildConversationUrl(conversation.id);
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
router.push({ path });
};
const openContextMenu = (conversation, e) => {
e.preventDefault();
activeContextChat.value = conversation;
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
activeContextChat.value = null;
};
watch(
() => props.contactId,
(newId, oldId) => {
if (newId && newId !== oldId) {
showContextMenu.value = false;
activeContextChat.value = null;
store.dispatch('contactConversations/get', newId);
}
}
);
onMounted(() => {
store.dispatch('contactConversations/get', props.contactId);
});
</script>
<template>
@@ -53,18 +135,43 @@ export default {
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
</div>
<div v-else class="contact-conversation--list">
<div
v-else
class="contact-conversation--list [&>.conversation:last-child]:!border-b-0 [&>.conversation:last-child:hover]:!border-b-0 [&>.conversation:last-child]:!rounded-b-lg"
>
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
:chat="conversation"
:hide-inbox-name="false"
:current-contact="contactGetter(conversation.meta?.sender?.id) || {}"
:assignee="conversation.meta?.assignee || {}"
:inbox="inboxGetter(conversation.inbox_id) || {}"
:is-active-chat="currentChat.id === conversation.id"
:show-inbox-name="showInboxName"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
@click="onCardClick(conversation, $event)"
@contextmenu="openContextMenu(conversation, $event)"
/>
</div>
<ContextMenu
v-if="showContextMenu && activeContextChat"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="activeContextChat.status"
:inbox-id="activeContextChat.inbox_id"
:priority="activeContextChat.priority"
:chat-id="activeContextChat.id"
:has-unread-messages="activeContextChat.unread_count > 0"
:conversation-labels="activeContextChat.labels"
:conversation-url="conversationPath"
:allowed-options="['open-new-tab', 'copy-link']"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
<div v-else class="flex items-center justify-center py-5">
<Spinner />
@@ -12,19 +12,12 @@ import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ContactDeleteModal from 'dashboard/modules/contact/ContactDeleteModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt';
export default {
components: {
NextButton,
@@ -34,6 +27,7 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
@@ -57,7 +51,6 @@ export default {
data() {
return {
showEditModal: false,
showDeleteModal: false,
isEditingName: false,
editName: '',
};
@@ -99,10 +92,6 @@ export default {
telegram,
};
},
// Delete Modal
confirmDeleteMessage() {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
@@ -117,28 +106,6 @@ export default {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
@@ -151,36 +118,6 @@ export default {
return '';
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panelClose');
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
if (isAConversationRoute(this.$route.name)) {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
},
openMergeModal() {
this.$refs.mergeModal?.open();
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
@@ -354,19 +291,14 @@ export default {
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@close="closeComposeConversationModal"
>
<template #trigger="{ toggle }">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="openComposeConversationModal(toggle)"
/>
</template>
</ComposeConversation>
@@ -387,45 +319,41 @@ export default {
sm
@click="toggleEditModal"
/>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<NextButton
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
/>
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
</div>
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="confirmDeleteMessage"
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
/>
</div>
</template>
@@ -1,74 +1,70 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactForm from './ContactForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
components: {
ContactForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
const props = defineProps({
show: { type: Boolean, default: false },
contact: { type: Object, default: () => ({}) },
});
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contacts/update', contactItem);
await this.$store.dispatch(
'contacts/fetchContactableInbox',
this.contact.id
);
},
},
const emit = defineEmits(['cancel']);
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const onCancel = () => emit('cancel');
const onSubmit = async contactItem => {
await store.dispatch('contacts/update', contactItem);
await store.dispatch('contacts/fetchContactableInbox', props.contact.id);
};
// Restore Escape-to-close behavior that was provided by woot-modal before
// this drawer was reimplemented as a plain fixed panel.
useKeyboardEvents({
Escape: {
action: () => {
if (props.show) onCancel();
},
allowOnFocusedInput: true,
},
});
</script>
<template>
<woot-modal
v-model:show="localShow"
:on-close="onCancel"
modal-type="right-aligned"
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="ltr:translate-x-[30%] rtl:-translate-x-[30%] opacity-0"
>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="`${$t('EDIT_CONTACT.TITLE')} - ${
contact.name || contact.email
}`"
:header-content="$t('EDIT_CONTACT.DESC')"
/>
<div
v-if="show"
class="fixed inset-y-0 ltr:right-0 rtl:left-0 z-50 flex flex-col w-[30rem] max-w-full h-full bg-n-surface-2 ltr:border-l rtl:border-r border-n-weak shadow-lg overflow-auto"
>
<div class="flex items-center justify-between px-8 pt-8 pb-2">
<div>
<h2 class="text-lg font-medium text-n-slate-12 mb-1">
{{
`${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
}}
</h2>
<p class="text-sm text-n-slate-11 mb-0">
{{ $t('EDIT_CONTACT.DESC') }}
</p>
</div>
<Button icon="i-lucide-x" slate ghost sm @click="onCancel" />
</div>
<ContactForm
:contact="contact"
:in-progress="uiFlags.isUpdating"
:on-submit="onSubmit"
@success="onSuccess"
@success="onCancel"
@cancel="onCancel"
/>
</div>
</woot-modal>
</transition>
</template>