Merge branch 'develop' into feat/v5-ui-redesign

This commit is contained in:
Sivin Varghese
2025-11-25 09:03:35 +05:30
committed by GitHub
44 changed files with 1817 additions and 359 deletions
+6
View File
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
});
}
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
@@ -1,15 +1,18 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -18,10 +21,17 @@ const props = defineProps({
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const dialogRef = ref(null);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
const onClick = () => {
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
const path = frontendURL(
conversationUrl({
accountId,
id: conversationId,
})
);
router.push({ path });
}
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
const apiError = error?.message;
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
}
};
const onClick = async () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const [inbox] = voiceInboxes.value;
await startCall(inbox.id);
};
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
const onPickInbox = async inbox => {
dialogRef.value?.close();
await startCall(inbox.id);
};
</script>
@@ -55,6 +96,8 @@ const onPickInbox = () => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
:size="size"
@@ -1,41 +1,102 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { useMessageContext } from '../provider.js';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
const { contentAttributes } = useMessageContext();
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[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 BG_COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const status = computed(() => data.value?.status);
const direction = computed(() => data.value?.call_direction);
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
useVoiceCallStatus(status, direction);
const containerRingClass = computed(() => {
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === 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 subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
:class="containerRingClass"
>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bubbleIconBg"
:class="bgColor"
>
<span class="text-xl" :class="bubbleIconName" />
<Icon
class="size-5"
:icon="iconName"
:class="{
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
}"
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
</div>
</div>
</div>
@@ -73,3 +73,16 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
};
export const VOICE_CALL_DIRECTION = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -24,6 +25,7 @@ const props = defineProps({
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
@@ -43,21 +45,27 @@ const changePage = newPage => {
};
const currentPageInformation = computed(() => {
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
return t(
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
translationKey,
{
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
}
startItem: formatFullNumber(startItem.value),
endItem: formatFullNumber(endItem.value),
totalItems: formatCompactNumber(props.totalItems),
},
Number(props.totalItems)
);
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
return t(
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
{
currentPage: '',
totalPages: formatCompactNumber(totalPages.value),
},
Number(totalPages.value)
);
});
</script>
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }}
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate">
{{ pageInfo }}
</span>
<span class="truncate">{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const callStatus = computed(
() => props.chat.additional_attributes?.call_status
);
const callDirection = computed(
() => props.chat.additional_attributes?.call_direction
);
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
useVoiceCallStatus(callStatus, callDirection);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
@@ -317,20 +312,13 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
</h4>
<div
v-if="callStatus"
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<span
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
:class="[voiceIconColor]"
/>
<span class="mx-1">
{{ $t(voiceLabelKey) }}
</span>
</div>
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
@@ -0,0 +1,77 @@
<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: '' },
messagePreviewClass: { type: [String, Array, Object], 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="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -1,161 +0,0 @@
import { computed, unref } from 'vue';
const CALL_STATUSES = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
NO_ANSWER: 'no-answer',
BUSY: 'busy',
FAILED: 'failed',
COMPLETED: 'completed',
CANCELED: 'canceled',
};
const CALL_DIRECTIONS = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
/**
* Composable for handling voice call status display logic
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
* @returns {Object} UI properties for displaying call status
*/
export function useVoiceCallStatus(statusRef, directionRef) {
const status = computed(() => unref(statusRef)?.toString());
const direction = computed(() => unref(directionRef)?.toString());
// Status group helpers
const isFailedStatus = computed(() =>
[
CALL_STATUSES.NO_ANSWER,
CALL_STATUSES.BUSY,
CALL_STATUSES.FAILED,
].includes(status.value)
);
const isEndedStatus = computed(() =>
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
);
const isOutbound = computed(
() => direction.value === CALL_DIRECTIONS.OUTBOUND
);
const labelKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
}
if (s === CALL_STATUSES.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (s === CALL_STATUSES.NO_ANSWER) {
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.RINGING) {
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const bubbleIconName = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
}
if (isFailedStatus.value) {
return 'i-ph-phone-x-fill';
}
// For ringing/completed/canceled show direction when possible
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
});
const bubbleIconBg = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return 'bg-n-teal-9';
}
if (isFailedStatus.value) {
return 'bg-n-ruby-9';
}
if (isEndedStatus.value) {
return 'bg-n-slate-11';
}
// default (e.g., ringing)
return 'bg-n-teal-9 animate-pulse';
});
const listIconColor = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
return 'text-n-teal-9';
}
if (isFailedStatus.value) {
return 'text-n-ruby-9';
}
if (isEndedStatus.value) {
return 'text-n-slate-11';
}
return 'text-n-teal-9';
});
return {
status,
labelKey,
subtextKey,
bubbleIconName,
bubbleIconBg,
listIconColor,
};
}
@@ -26,7 +26,7 @@
},
"COMPANIES_LAYOUT": {
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
"SHOWING": "Showing {startItem} {endItem} of {totalItems} company | Showing {startItem} {endItem} of {totalItems} companies"
}
}
}
@@ -1,7 +1,7 @@
{
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
},
"COMBOBOX": {
"PLACEHOLDER": "Select an option...",
@@ -18,7 +18,8 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
@@ -376,7 +377,7 @@
}
},
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
},
"FILTER": {
"NAME": "Name",
@@ -282,6 +282,7 @@ export default {
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
@@ -302,4 +302,22 @@ export const actions = {
clearContactFilters({ commit }) {
commit(types.CLEAR_CONTACT_FILTERS);
},
initiateCall: async ({ commit }, { contactId, inboxId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
const response = await ContactAPI.initiateCall(contactId, inboxId);
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else if (error.response?.data?.error) {
throw new ExceptionWithMessage(error.response.data.error);
} else {
throw new Error(error);
}
}
},
};
@@ -17,6 +17,7 @@ const state = {
isDeleting: false,
isExporting: false,
isImporting: false,
isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
@@ -359,4 +359,83 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
describe('#initiateCall', () => {
const contactId = 123;
const inboxId = 456;
it('sends correct mutations if API is success', async () => {
const mockResponse = {
data: {
conversation_id: 789,
status: 'initiated',
},
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.initiateCall(
{ commit },
{ contactId, inboxId }
);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
expect(result).toEqual(mockResponse.data);
});
it('sends correct actions if API returns error with message', async () => {
const errorMessage = 'Failed to initiate call';
axios.post.mockRejectedValue({
response: {
data: {
message: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns error with error field', async () => {
const errorMessage = 'Call initiation error';
axios.post.mockRejectedValue({
response: {
data: {
error: errorMessage,
},
},
});
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(ExceptionWithMessage);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
it('sends correct actions if API returns generic error', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
await expect(
actions.initiateCall({ commit }, { contactId, inboxId })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
]);
});
});
});
@@ -0,0 +1,240 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useNumberFormatter } from '../useNumberFormatter';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useNumberFormatter', () => {
beforeEach(() => {
vi.mocked(useI18n).mockReturnValue({
locale: ref('en-US'),
});
});
describe('formatCompactNumber', () => {
it('should return exact numbers for values under 1,000', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(0)).toBe('0');
expect(formatCompactNumber(1)).toBe('1');
expect(formatCompactNumber(42)).toBe('42');
expect(formatCompactNumber(999)).toBe('999');
});
it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000)).toBe('1k');
expect(formatCompactNumber(1020)).toBe('1k+');
expect(formatCompactNumber(1500)).toBe('1k+');
expect(formatCompactNumber(1999)).toBe('1k+');
expect(formatCompactNumber(2000)).toBe('2k');
expect(formatCompactNumber(15000)).toBe('15k');
expect(formatCompactNumber(15500)).toBe('15k+');
expect(formatCompactNumber(999999)).toBe('999k+');
});
it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1000000)).toBe('1M');
expect(formatCompactNumber(1000001)).toBe('1.0M');
expect(formatCompactNumber(1200000)).toBe('1.2M');
expect(formatCompactNumber(1234000)).toBe('1.2M');
expect(formatCompactNumber(2500000)).toBe('2.5M');
expect(formatCompactNumber(10000000)).toBe('10M');
expect(formatCompactNumber(1000000000)).toBe('1B');
expect(formatCompactNumber(1100000000)).toBe('1.1B');
expect(formatCompactNumber(10000000000)).toBe('10B');
expect(formatCompactNumber(11000000000)).toBe('11B');
expect(formatCompactNumber(1000000000000)).toBe('1T');
expect(formatCompactNumber(1100000000000)).toBe('1.1T');
expect(formatCompactNumber(10000000000000)).toBe('10T');
expect(formatCompactNumber(11000000000000)).toBe('11T');
});
it('should handle edge cases gracefully', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(null)).toBe('0');
expect(formatCompactNumber(undefined)).toBe('0');
expect(formatCompactNumber(NaN)).toBe('0');
expect(formatCompactNumber('string')).toBe('0');
});
it('should handle negative numbers', () => {
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(-500)).toBe('-500');
expect(formatCompactNumber(-1000)).toBe('-1k');
expect(formatCompactNumber(-1500)).toBe('-1k+');
expect(formatCompactNumber(-2000)).toBe('-2k');
expect(formatCompactNumber(-1200000)).toBe('-1.2M');
});
it('should format with en-US locale', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(1500)).toBe('1k+');
});
it('should format with de-DE locale', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(2000)).toBe('2k');
});
it('should format with fr-FR locale (compact notation)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(1000000);
expect(result).toMatch(/1\s*M/); // French uses space before M
});
it('should format with ja-JP locale', () => {
const mockLocale = ref('ja-JP');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(999)).toBe('999');
});
it('should format with ar-SA locale (Arabic numerals)', () => {
const mockLocale = ref('ar-SA');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(5000);
expect(result).toContain('k');
expect(typeof result).toBe('string');
});
it('should format with es-ES locale', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(7500)).toBe('7k+');
});
it('should format with hi-IN locale', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(100000)).toBe('100k');
});
it('should format with ru-RU locale', () => {
const mockLocale = ref('ru-RU');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(3000)).toBe('3k');
});
it('should format with ko-KR locale (uses 만 for 10,000)', () => {
const mockLocale = ref('ko-KR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
const result = formatCompactNumber(2500000);
expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
});
it('should format with pt-BR locale', () => {
const mockLocale = ref('pt-BR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactNumber } = useNumberFormatter();
expect(formatCompactNumber(8888)).toBe('8k+');
});
});
describe('formatFullNumber', () => {
it('should format numbers with locale-specific formatting', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000)).toBe('1,000');
expect(formatFullNumber(1234567)).toBe('1,234,567');
expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
});
it('should handle edge cases gracefully', () => {
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(null)).toBe('0');
expect(formatFullNumber(undefined)).toBe('0');
expect(formatFullNumber(NaN)).toBe('0');
expect(formatFullNumber('string')).toBe('0');
});
it('should format with en-US locale (comma separator)', () => {
const mockLocale = ref('en-US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1234567)).toBe('1,234,567');
});
it('should format with de-DE locale (period separator)', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9876543)).toBe('9.876.543');
});
it('should format with es-ES locale (period separator)', () => {
const mockLocale = ref('es-ES');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(5555555)).toBe('5.555.555');
});
it('should format with zh-CN locale', () => {
const mockLocale = ref('zh-CN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(1000000)).toBe('1,000,000');
});
it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
const mockLocale = ref('ar-EG');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(7654321);
// Arabic locale uses Eastern Arabic numerals (٠-٩)
// Just verify it's formatted (length should be reasonable)
expect(result.length).toBeGreaterThan(6);
});
it('should format with fr-FR locale (narrow no-break space)', () => {
const mockLocale = ref('fr-FR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(3333333);
expect(result).toContain('3');
expect(result).toContain('333');
});
it('should format with hi-IN locale (Indian numbering system)', () => {
const mockLocale = ref('hi-IN');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(9999999)).toBe('99,99,999');
});
it('should format with th-TH locale', () => {
const mockLocale = ref('th-TH');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(4444444)).toBe('4,444,444');
});
it('should format with tr-TR locale', () => {
const mockLocale = ref('tr-TR');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
expect(formatFullNumber(6666666)).toBe('6.666.666');
});
it('should format with pt-PT locale (space separator)', () => {
const mockLocale = ref('pt-PT');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatFullNumber } = useNumberFormatter();
const result = formatFullNumber(2222222);
// Portuguese (Portugal) uses narrow no-break space as separator
expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
});
});
});
@@ -0,0 +1,67 @@
import { useI18n } from 'vue-i18n';
/**
* Composable for number formatting with i18n locale support
* Provides methods to format numbers in compact and full display formats
*/
export function useNumberFormatter() {
const { locale } = useI18n();
/**
* Formats numbers for display with clean, minimal formatting
* - Up to 1,000: show exact number (e.g., 999)
* - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 → "1k", 1500 → "1k+")
* - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 → "1.2M")
*
* Uses browser-native Intl.NumberFormat with proper i18n locale support
*
* @param {number} num - The number to format
* @returns {string} Formatted number string
*/
const formatCompactNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
// For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
if (Math.abs(num) < 1000) {
return new Intl.NumberFormat(locale.value).format(num);
}
// For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
if (Math.abs(num) >= 1000000) {
const millions = num / 1000000;
return new Intl.NumberFormat(locale.value, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
}).format(num);
}
// For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
// For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
const remainder = Math.abs(num) % 1000;
const suffix = remainder === 0 ? 'k' : 'k+';
return `${new Intl.NumberFormat(locale.value).format(thousands)}${suffix}`;
};
/**
* Format a number for full display with locale-specific formatting
* @param {number} num - The number to format
* @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
*/
const formatFullNumber = num => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
return new Intl.NumberFormat(locale.value).format(num);
};
return {
formatCompactNumber,
formatFullNumber,
};
}