chore: Calls page UI improvements (#15129)

This commit is contained in:
Sivin Varghese
2026-07-23 18:45:56 +05:30
committed by GitHub
parent bae20ca83e
commit a98666030b
27 changed files with 283 additions and 92 deletions
@@ -28,7 +28,12 @@ const inboxes = computed(() => {
return { return {
name: inbox.name, name: inbox.name,
id: inbox.id, id: inbox.id,
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'), icon: getInboxIconByType(
inbox.channelType,
inbox.medium,
'line',
inbox.voiceEnabled
),
}; };
}); });
}); });
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper'; import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue'; import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
@@ -60,7 +61,7 @@ const resultLabel = computed(() => {
}); });
const providerIcon = computed(() => const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone' getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
); );
const createdAtLabel = computed(() => const createdAtLabel = computed(() =>
@@ -150,7 +151,7 @@ const conversationRoute = computed(() => ({
<div <div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5" class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
> >
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5"> <div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
<Avatar <Avatar
:src="call.contact.avatar" :src="call.contact.avatar"
:name="contactName" :name="contactName"
@@ -169,9 +170,12 @@ const conversationRoute = computed(() => ({
> >
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5"> <div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" /> <CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel"> <div
v-if="agentActionLabel"
class="gap-x-1.5 min-w-0 flex items-center"
>
<span <span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8" class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
> >
{{ agentActionLabel }} {{ agentActionLabel }}
</span> </span>
@@ -192,7 +196,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }} {{ call.agent.name }}
</span> </span>
</span> </span>
</template> </div>
<span <span
v-else-if="resultLabel" v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]" class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
@@ -212,10 +216,10 @@ const conversationRoute = computed(() => ({
content: call.inbox.name, content: call.inbox.name,
delay: { show: 500, hide: 0 }, delay: { show: 500, hide: 0 },
}" }"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5" class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
> >
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" /> <Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11"> <span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }} {{ call.inbox.name }}
</span> </span>
</div> </div>
@@ -232,7 +236,7 @@ const conversationRoute = computed(() => ({
content: createdAtLabel, content: createdAtLabel,
delay: { show: 500, hide: 0 }, delay: { show: 500, hide: 0 },
}" }"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0" class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
> >
{{ createdAtLabel }} {{ createdAtLabel }}
</span> </span>
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown. // Null while a fetch is in flight so stale counts are never shown.
@@ -117,10 +118,16 @@ const moreFiltersSections = computed(() => [
}, },
]); ]);
const selectedAssignee = computed(
() => props.agents.find(agent => agent.id === assigneeId.value) || null
);
const selectedAssigneeLabel = computed( const selectedAssigneeLabel = computed(
() => () => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
props.agents.find(agent => agent.id === assigneeId.value)?.name || );
t('CALLS_PAGE.FILTERS.ASSIGNEE')
const isOtherActivitySelected = computed(() =>
OTHER_ACTIVITIES.includes(activity.value)
); );
const hasMoreFilters = computed(() => Boolean(inboxId.value)); const hasMoreFilters = computed(() => Boolean(inboxId.value));
@@ -143,7 +150,7 @@ const applyMoreFilter = ({ action, value }) => {
<template> <template>
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-2">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0"> <span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{ {{
totalCount === null totalCount === null
@@ -157,13 +164,13 @@ const applyMoreFilter = ({ action, value }) => {
color="blue" color="blue"
size="sm" size="sm"
:icon="ACTIVITY_ICONS[activity]" :icon="ACTIVITY_ICONS[activity]"
class="shrink-0" class="shrink-0 !h-7 !px-2"
@click="setActivity(null)" @click="setActivity(null)"
> >
{{ activeChipLabel }} {{ activeChipLabel }}
<Icon icon="i-lucide-x" /> <Icon icon="i-lucide-x" />
</Button> </Button>
<div class="w-px h-4 bg-n-strong shrink-0" /> <div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
<Button <Button
v-for="chip in inactiveChips" v-for="chip in inactiveChips"
:key="chip" :key="chip"
@@ -172,7 +179,7 @@ const applyMoreFilter = ({ action, value }) => {
size="sm" size="sm"
:icon="ACTIVITY_ICONS[chip]" :icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)" :label="activityLabel(chip)"
class="shrink-0 text-n-slate-12" class="shrink-0 text-n-slate-11 !h-7 !px-2"
@click="setActivity(chip)" @click="setActivity(chip)"
/> />
<OnClickOutside <OnClickOutside
@@ -184,7 +191,10 @@ const applyMoreFilter = ({ action, value }) => {
color="slate" color="slate"
size="sm" size="sm"
icon="i-lucide-phone" icon="i-lucide-phone"
class="text-n-slate-12" class="!h-7 !px-2"
:class="
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
"
@click="toggleMenu('activity')" @click="toggleMenu('activity')"
> >
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }} {{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
@@ -208,10 +218,19 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline" variant="outline"
color="slate" color="slate"
size="sm" size="sm"
icon="i-lucide-user-round-cog" icon="i-woot-empty-assignee"
class="max-w-52 text-n-slate-12" class="max-w-52 !h-7 !px-2"
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
@click="toggleMenu('assignee')" @click="toggleMenu('assignee')"
> >
<template v-if="selectedAssignee" #icon>
<Avatar
:src="selectedAssignee.thumbnail"
:name="selectedAssignee.name"
:size="16"
rounded-full
/>
</template>
<span class="truncate">{{ selectedAssigneeLabel }}</span> <span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" /> <Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button> </Button>
@@ -228,8 +247,9 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-list-filter" icon="i-lucide-list-filter"
class="!h-7 !px-2"
:color="hasMoreFilters ? 'blue' : 'slate'" :color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'" :class="hasMoreFilters ? '' : 'text-n-slate-11'"
@click="toggleMenu('more')" @click="toggleMenu('more')"
> >
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }} {{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
@@ -83,8 +83,12 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || ''); const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox; const {
return getInboxIconByType(type, medium); medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
}); });
</script> </script>
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name); const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value; const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium); return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
}); });
const lastActivityAt = computed(() => { const lastActivityAt = computed(() => {
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox); const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value; const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium); return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
}); });
const hasSlaThreshold = computed(() => { const hasSlaThreshold = computed(() => {
@@ -37,10 +37,11 @@ const transformInbox = ({
channelType, channelType,
phoneNumber, phoneNumber,
medium, medium,
voiceEnabled,
...rest ...rest
}) => ({ }) => ({
id, id,
icon: getInboxIconByType(channelType, medium, 'line'), icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
label: generateLabelForContactableInboxesList({ label: generateLabelForContactableInboxesList({
name, name,
email, email,
@@ -54,6 +55,7 @@ const transformInbox = ({
phoneNumber, phoneNumber,
channelType, channelType,
medium, medium,
voiceEnabled,
...rest, ...rest,
}); });
@@ -79,6 +79,20 @@ describe('composeConversationHelper', () => {
channelType: INBOX_TYPES.EMAIL, channelType: INBOX_TYPES.EMAIL,
}); });
}); });
it('uses the voice glyph for a voice-enabled inbox', () => {
const inboxes = [
{
id: 2,
name: 'WhatsApp Cloud',
channelType: INBOX_TYPES.WHATSAPP,
voiceEnabled: true,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
});
}); });
describe('getCapitalizedNameFromEmail', () => { describe('getCapitalizedNameFromEmail', () => {
@@ -117,8 +117,8 @@ const downloadRecording = () => {
> >
<template #icon> <template #icon>
<Icon <Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" :icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
class="size-4 flex-shrink-0" class="size-4 flex-shrink-0 text-n-slate-11"
/> />
</template> </template>
</Button> </Button>
@@ -127,10 +127,10 @@ const downloadRecording = () => {
min="0" min="0"
:max="duration || 0" :max="duration || 0"
:value="currentTime" :value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11" class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
@input="seek" @input="seek"
/> />
<span class="text-sm tabular-nums text-n-slate-11 shrink-0"> <span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }} {{ displayedTime }}
</span> </span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" /> <div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
@@ -58,8 +58,12 @@ const menuItems = computed(() => [
]); ]);
const icon = computed(() => { const icon = computed(() => {
const { medium, channel_type: type } = props.inbox; const {
return getInboxIconByType(type, medium, 'outline'); medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
}); });
const handleAction = ({ action, value }) => { const handleAction = ({ action, value }) => {
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed, toRef } from 'vue'; import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider'; import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@@ -21,7 +20,6 @@ defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox'); const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef); const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef); const brandIcon = useChannelBrandIcon(inboxRef);
@@ -31,13 +29,7 @@ const icon = computed(() =>
</script> </script>
<template> <template>
<span class="relative inline-flex" v-bind="$attrs"> <span class="inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" /> <Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span> </span>
</template> </template>
@@ -1,4 +1,9 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; import {
INBOX_TYPES,
TWILIO_CHANNEL_MEDIUM,
isVoiceCallEnabled,
getInboxVoiceIcon,
} from 'dashboard/helper/inbox';
import { computed } from 'vue'; import { computed } from 'vue';
const channelTypeIconMap = { const channelTypeIconMap = {
@@ -61,16 +66,9 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp'; icon = 'i-woot-whatsapp';
} }
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium) // Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
// is presented as a Voice channel, so show the phone icon. if (isVoiceCallEnabled(inboxDetails)) {
const voiceEnabled = icon = getInboxVoiceIcon(type, inboxDetails.medium);
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
} }
return icon ?? 'i-ri-global-fill'; return icon ?? 'i-ri-global-fill';
@@ -19,13 +19,32 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp'); expect(icon).toBe('i-woot-whatsapp');
}); });
it('returns correct icon for voice-enabled Twilio channel', () => { it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
const inbox = { const inbox = {
channel_type: 'Channel::TwilioSms', channel_type: 'Channel::TwilioSms',
voice_enabled: true, voice_enabled: true,
}; };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice'); expect(icon).toBe('i-woot-voice-call');
});
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::Whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
}); });
it('returns correct icon for Line channel', () => { it('returns correct icon for Line channel', () => {
@@ -457,10 +457,11 @@ function handleReplyTo() {
const avatarInfo = computed(() => { const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) { if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value; const { name, avatar_url, channel_type, medium, voice_enabled } =
inbox.value;
const iconName = avatar_url const iconName = avatar_url
? null ? null
: getInboxIconByType(channel_type, medium); : getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
return { return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'), name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '', src: avatar_url || '',
+27 -1
View File
@@ -55,11 +55,30 @@ export const getVoiceCallProvider = inbox => {
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null; export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
// Combined channel + voice-wave badge glyph per voice-call provider.
export const VOICE_CALL_ICONS = {
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
};
export const getVoiceCallIcon = provider =>
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
export const TWILIO_CHANNEL_MEDIUM = { export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp', WHATSAPP: 'whatsapp',
SMS: 'sms', SMS: 'sms',
}; };
export const getInboxVoiceIcon = (channelType, medium) => {
const isWhatsapp =
channelType === INBOX_TYPES.WHATSAPP ||
(channelType === INBOX_TYPES.TWILIO &&
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
return getVoiceCallIcon(
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
);
};
const INBOX_ICON_MAP_FILL = { const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill', [INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill', [INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -182,7 +201,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
} }
}; };
export const getInboxIconByType = (type, medium, variant = 'fill') => { export const getInboxIconByType = (
type,
medium,
variant = 'fill',
voiceEnabled = false
) => {
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
const iconMap = const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE; variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon = const defaultIcon =
@@ -1,8 +1,11 @@
import { import {
INBOX_TYPES, INBOX_TYPES,
VOICE_CALL_PROVIDERS,
getInboxClassByType, getInboxClassByType,
getInboxIconByType, getInboxIconByType,
getInboxVoiceIcon,
getInboxWarningIconClass, getInboxWarningIconClass,
getVoiceCallIcon,
} from '../inbox'; } from '../inbox';
describe('#Inbox Helpers', () => { describe('#Inbox Helpers', () => {
@@ -166,4 +169,63 @@ describe('#Inbox Helpers', () => {
); );
}); });
}); });
describe('getVoiceCallIcon', () => {
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for the twilio provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
'i-woot-voice-call'
);
});
it('falls back to the generic voice-call glyph for an unknown provider', () => {
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
});
});
describe('getInboxVoiceIcon', () => {
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-woot-voice-call'
);
});
});
describe('getInboxIconByType with voice enabled', () => {
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
).toBe('i-woot-whatsapp-voice');
});
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
'i-woot-voice-call'
);
});
it('returns the normal channel icon when voice is not enabled', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
).toBe('i-woot-whatsapp');
});
});
}); });
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
if (!inbox.value) return null; if (!inbox.value) return null;
const { channelType, medium } = inbox.value; const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium); return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
}); });
</script> </script>
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
if (!inbox.value) return null; if (!inbox.value) return null;
const { channelType, medium } = inbox.value; const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium); return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
}); });
const fileAttachments = computed(() => { const fileAttachments = computed(() => {
@@ -129,22 +129,22 @@ onMounted(async () => {
v-else v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1" class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
> >
<header class="px-6 pt-6 pb-4 shrink-0"> <header class="shrink-0">
<div class="w-full"> <div class="w-full px-6 pt-6">
<h1 class="text-xl font-medium text-n-slate-12"> <h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }} {{ t('CALLS_PAGE.HEADER') }}
</h1> </h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div> </div>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5 pb-4 border-b border-n-weak mx-6"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full"> <div class="w-full">
@@ -79,13 +79,15 @@ const breadcrumbItems = computed(() => {
}); });
const buildInboxList = allInboxes => const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({ allInboxes?.map(
name, ({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
id, name,
email, id,
phoneNumber, email,
icon: getInboxIconByType(channelType, medium, 'line'), phoneNumber,
})) || []; icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || [];
const policyInboxes = computed(() => const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes) buildInboxList(selectedPolicy.value?.inboxes)
@@ -64,13 +64,23 @@ const allInboxes = computed(
inboxes.value inboxes.value
?.slice() ?.slice()
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({ .map(
name, ({
id, name,
email, id,
phoneNumber, email,
icon: getInboxIconByType(channelType, medium, 'line'), phoneNumber,
})) || [] channelType,
medium,
voiceEnabled,
}) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || []
); );
const formData = computed(() => ({ const formData = computed(() => ({
@@ -29,7 +29,8 @@ const inboxIcon = computed(() => {
return getInboxIconByType( return getInboxIconByType(
props.inbox.channelType, props.inbox.channelType,
props.inbox.medium, props.inbox.medium,
'line' 'line',
props.inbox.voiceEnabled
); );
}); });
@@ -281,8 +281,12 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId); return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
}, },
inboxIcon() { inboxIcon() {
const { medium, channel_type: type } = this.inbox; const {
return getInboxIconByType(type, medium, 'line'); medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = this.inbox;
return getInboxIconByType(type, medium, 'line', voiceEnabled);
}, },
bannerMaxWidth() { bannerMaxWidth() {
const narrowTabs = ['collaborators', 'bot-configuration']; const narrowTabs = ['collaborators', 'bot-configuration'];
+1 -1
View File
@@ -62,7 +62,7 @@ class CallFinder
end end
def paginated_calls def paginated_calls
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent) @calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
.order(created_at: :desc) .order(created_at: :desc)
.page(@params[:page] || 1) .page(@params[:page] || 1)
.per(RESULTS_PER_PAGE) .per(RESULTS_PER_PAGE)
@@ -19,6 +19,8 @@ end
json.inbox do json.inbox do
json.id call.inbox_id json.id call.inbox_id
json.name call.inbox.name json.name call.inbox.name
json.channel_type call.inbox.channel_type
json.medium call.inbox.channel.try(:medium)
end end
if call.accepted_by_agent if call.accepted_by_agent
@@ -32,6 +32,7 @@ RSpec.describe 'Calls API', type: :request do
item = body['payload'].find { |c| c['id'] == agent_call.id } item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world') expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number) expect(item['contact']['phone_number']).to eq(contact.phone_number)
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
end end
it 'scopes the list to calls the agent accepted' do it 'scopes the list to calls the agent accepted' do
+20
View File
@@ -269,6 +269,16 @@ export const icons = {
width: 24, width: 24,
height: 24, height: 24,
}, },
'voice-call': {
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
'whatsapp-voice': {
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
instagram: { instagram: {
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`, body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24, width: 24,
@@ -470,5 +480,15 @@ export const icons = {
width: 24, width: 24,
height: 24, height: 24,
}, },
'audio-play': {
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
'audio-pause': {
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
/** Ends */ /** Ends */
}; };