Merge branch 'develop' into feat/chat-list-header

This commit is contained in:
Sivin Varghese
2024-12-06 20:29:13 +05:30
committed by GitHub
35 changed files with 1136 additions and 214 deletions
@@ -1,28 +1,30 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
// import Button from 'dashboard/components-next/button/Button.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
// buttonLabel: {
// type: String,
// default: '',
// },
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([
// 'message',
'goToContactsList',
]);
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
return props.selectedContact?.name;
@@ -62,7 +64,11 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -41,11 +41,11 @@ const FORM_CONFIG = {
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
const defaultState = {
@@ -4,6 +4,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
@@ -18,10 +19,10 @@ defineProps({
type: String,
required: true,
},
// buttonLabel: {
// type: String,
// default: '',
// },
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
@@ -48,7 +49,6 @@ const emit = defineEmits([
'search',
'filter',
'update:sort',
// 'message',
'add',
'import',
'export',
@@ -131,9 +131,12 @@ const emit = defineEmits([
@export="emit('export')"
/>
</div>
<!-- TODO: Add this when we enabling message feature -->
<!-- <div class="w-px h-4 bg-n-strong" /> -->
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -55,7 +55,7 @@ const handleContactAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
@@ -109,7 +109,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'resetTab']);
const { t } = useI18n();
const store = useStore();
@@ -74,6 +74,9 @@ const onContactSearch = debounce(
);
const resetState = () => {
if (state.primaryContactId === null) {
emit('resetTab');
}
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
@@ -130,15 +130,28 @@ const handleAvatarDelete = async () => {
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
<div class="flex flex-col gap-1.5">
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
@@ -0,0 +1,215 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
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 {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
});
const store = useStore();
const { t } = useI18n();
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 contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
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;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
resetContacts();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
useAlert(
error instanceof ExceptionWithMessage
? error.data
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
);
return false; // Return failure
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
// Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
};
}
},
{ immediate: true, deep: true }
);
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</template>
@@ -3,7 +3,7 @@ import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
// import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -110,19 +110,10 @@ const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const useFileUpload = () => {
// Empty function for testing purposes
// TODO: Will use useFileUpload composable later
return {
onFileUpload: () => {},
};
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -141,7 +141,11 @@ const isAnyDropdownActive = computed(() => {
});
const handleContactSearch = value => {
emit('searchContacts', value);
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
});
};
const handleDropdownUpdate = (type, value) => {
@@ -156,12 +160,12 @@ const handleDropdownUpdate = (type, value) => {
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', value);
emit('searchContacts', { keys: ['email'], query: value });
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', value);
emit('searchContacts', { keys: ['email'], query: value });
};
const setSelectedContact = async ({ value, action, ...rest }) => {
@@ -250,7 +254,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="absolute right-0 w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
@@ -43,20 +42,19 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: `${name} (${email})`,
label: email ? `${name} (${email})` : name,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
@@ -67,7 +65,20 @@ const contactsList = computed(() => {
});
const selectedContactLabel = computed(() => {
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
const { name, email = '', phoneNumber = '' } = props.selectedContact || {};
if (email) {
return `${name} (${email})`;
}
if (phoneNumber) {
return `${name} (${phoneNumber})`;
}
return name || '';
});
const errorClass = computed(() => {
return props.hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
@@ -75,7 +86,7 @@ const selectedContactLabel = computed(() => {
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
{{ t(`${i18nPrefix}.LABEL`) }}
</label>
<div
@@ -83,9 +94,7 @@ const selectedContactLabel = computed(() => {
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
}}
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
</span>
</div>
<div
@@ -95,9 +104,7 @@ const selectedContactLabel = computed(() => {
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
)
? t(`${i18nPrefix}.CONTACT_CREATING`)
: selectedContactLabel
}}
</span>
@@ -112,11 +119,7 @@ const selectedContactLabel = computed(() => {
</div>
<TagInput
v-else
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
)
"
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
@@ -125,12 +128,8 @@ const selectedContactLabel = computed(() => {
allow-create
type="email"
class="flex-1 min-h-7"
:class="
hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: ''
"
@focus="emit('updateDropdown', 'contacts', true)"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@@ -54,7 +54,7 @@ const targetInboxLabel = computed(() => {
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
>
<span class="text-sm text-n-slate-12">
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
@@ -1,9 +1,7 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const convertChannelTypeToLabel = channelType => {
const [, type] = channelType.split('::');
return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType;
};
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
export const generateLabelForContactableInboxesList = ({
name,
@@ -20,7 +18,7 @@ export const generateLabelForContactableInboxesList = ({
) {
return `${name} (${phoneNumber})`;
}
return `${name} (${convertChannelTypeToLabel(channelType)})`;
return name;
};
export const buildContactableInboxesList = contactInboxes => {
@@ -28,6 +26,7 @@ export const buildContactableInboxesList = contactInboxes => {
return contactInboxes.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -45,6 +44,18 @@ export const buildContactableInboxesList = contactInboxes => {
);
};
export const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const processContactableInboxes = inboxes => {
return inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
}));
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
@@ -116,3 +127,62 @@ export const prepareWhatsAppMessagePayload = ({
assigneeId: currentUser.id,
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async email => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
};
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
return camelcaseKeys(newContact, { deep: true });
};
export const fetchContactableInboxes = async contactId => {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contactId);
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
return processContactableInboxes(convertInboxesToCamelKeys);
};
@@ -1,21 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import ContactAPI from 'dashboard/api/contacts';
import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('convertChannelTypeToLabel', () => {
it('converts channel type with namespace to capitalized label', () => {
expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email');
expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe(
'Whatsapp'
);
});
it('returns original value if no namespace found', () => {
expect(helpers.convertChannelTypeToLabel('email')).toBe('email');
});
});
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
@@ -56,7 +46,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe (Api)');
).toBe('John Doe');
});
});
@@ -80,6 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
@@ -90,6 +81,35 @@ describe('composeConversationHelper', () => {
});
});
describe('getCapitalizedNameFromEmail', () => {
it('extracts and capitalizes name from email', () => {
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
'John.doe'
);
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
'Jane'
);
});
});
describe('processContactableInboxes', () => {
it('processes inboxes with correct structure', () => {
const inboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
const result = helpers.processContactableInboxes(inboxes);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
@@ -168,4 +188,278 @@ describe('composeConversationHelper', () => {
});
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('transforms nested objects to camelCase', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
source_id: 'source1',
created_at: '2023-01-01',
},
],
custom_attributes: {
custom_field_name: 'value',
},
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
sourceId: 'source1',
createdAt: '2023-01-01',
},
],
customAttributes: {
customFieldName: 'value',
},
},
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('john@example.com');
expect(result).toEqual(mockContact);
expect(ContactAPI.create).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com',
});
});
});
describe('fetchContactableInboxes', () => {
it('fetches and processes contactable inboxes', async () => {
const mockInboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: mockInboxes },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
});
it('returns empty array when no inboxes found', async () => {
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result).toEqual([]);
});
});
});
});
@@ -75,8 +75,8 @@ onMounted(() => {
/>
</div>
<button
v-for="item in filteredMenuItems"
:key="item.action"
v-for="(item, index) in filteredMenuItems"
:key="index"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
@@ -95,7 +95,7 @@ onMounted(() => {
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
@@ -7,17 +7,17 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
@@ -27,7 +27,6 @@ const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
@@ -481,7 +480,7 @@ const menuItems = computed(() => {
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
@@ -493,14 +492,17 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
>
<span
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/>
</button>
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
@@ -518,12 +520,6 @@ const menuItems = computed(() => {
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</template>
@@ -120,7 +120,7 @@ const allowedMenuItems = computed(() => {
</div>
</button>
</template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
@@ -81,17 +81,19 @@ const filteredMenuItems = computed(() => {
item => !tags.value.includes(item.label)
);
// Only show typed value as suggestion if:
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
!props.isLoading
) {
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
@@ -117,7 +119,7 @@ const emitDataOnAdd = emailValue => {
};
const addTag = async () => {
const trimmedTag = newTag.value.trim();
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
@@ -185,7 +187,7 @@ watch(
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) {
if (props.type === 'email' && newValue?.trim()?.length > 2) {
await v$.value.$validate();
}
}
@@ -216,9 +218,11 @@ const handleBlur = e => emit('blur', e);
@click.stop="removeTag(index)"
/>
</div>
<div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
<div
v-if="showInput || showDropdownMenu"
class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"
>
<InlineInput
v-if="showInput"
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
@@ -3,18 +3,20 @@ import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({
parentNav: 'contacts',
routes: [
'contacts_dashboard',
'contacts_dashboard_index',
'contacts_dashboard_segments_index',
'contacts_dashboard_labels_index',
'contacts_edit',
'contacts_segments_dashboard',
'contacts_labels_dashboard',
'contacts_edit_segment',
'contacts_edit_label',
],
menuItems: [
{
icon: 'contact-card-group',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
],
});
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toStateName: 'contacts_dashboard_index',
},
{
icon: 'arrow-trending-lines',
@@ -134,7 +134,7 @@ export default {
icon: 'number-symbol',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
key: 'labels',
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@@ -147,7 +147,7 @@ export default {
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
`accounts/${this.accountId}/contacts/labels/${label.title}`
),
})),
};
@@ -194,7 +194,7 @@ export default {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'custom_view',
key: 'segments',
children: this.customViews
.filter(view => view.filter_type === 'contact')
.map(view => ({
@@ -202,7 +202,7 @@ export default {
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/custom_view/${view.id}`
`accounts/${this.accountId}/contacts/segments/${view.id}`
),
})),
};
@@ -247,7 +247,7 @@ export default {
<transition-group
name="menu-list"
tag="ul"
class="pt-2 reset-base list-none"
class="pt-2 list-none reset-base"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
@@ -119,6 +119,13 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isContactsDefaultRoute() {
return (
this.menuItem.toStateName === 'contacts_dashboard_index' &&
(this.$store.state.route.name === 'contacts_dashboard_index' ||
this.$store.state.route.name === 'contacts_edit')
);
},
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
@@ -130,6 +137,7 @@ export default {
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isContactsDefaultRoute ||
this.isCurrentRoute
) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
@@ -242,7 +250,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="reset-base list-none">
<ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"
@@ -0,0 +1,146 @@
import { useFileUpload } from '../useFileUpload';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
const mockTranslate = vi.fn();
const mockFile = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: true } },
};
return getterMap[getter];
});
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
});
it('should handle direct file upload when enabled', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
const mockBlob = { signed_id: 'test-blob' };
DirectUpload.mockImplementation(() => ({
create: callback => callback(null, mockBlob),
}));
onFileUpload(mockFile);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
expect.any(Object)
);
expect(mockAttachFile).toHaveBeenCalledWith({
file: mockFile,
blob: mockBlob,
});
});
it('should handle indirect file upload when direct upload is disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: false } },
};
return getterMap[getter];
});
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('should show alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should use different max file size for Twilio SMS channel', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: true,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(checkFileSizeLimit).toHaveBeenCalledWith(
mockFile,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
);
});
it('should handle direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith(mockError);
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should do nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
/**
* Composable for handling file uploads in conversations
* @param {Object} options - Configuration options
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
* @param {Function} options.attachFile - Callback function to handle file attachment
* @returns {Object} File upload methods and utilities
*/
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const maxFileSize = computed(() =>
isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE
);
const handleDirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const handleIndirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
attachFile({ file });
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const onFileUpload = file => {
if (globalConfig.value.directUploadsEnabled) {
handleDirectFileUpload(file);
} else {
handleIndirectFileUpload(file);
}
};
return {
onFileUpload,
};
};
+24 -8
View File
@@ -11,7 +11,7 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
};
const INBOX_ICON_MAP = {
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
@@ -22,7 +22,20 @@ const INBOX_ICON_MAP = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
};
const DEFAULT_ICON = 'i-ri-chat-1-fill';
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.WEB]: 'i-ri-global-line',
[INBOX_TYPES.FB]: 'i-ri-messenger-line',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-line',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-line',
[INBOX_TYPES.API]: 'i-ri-cloudy-line',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
@@ -110,15 +123,18 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, phoneNumber) => {
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO) {
return phoneNumber?.startsWith('whatsapp')
? 'i-ri-whatsapp-fill'
: 'i-ri-chat-1-fill';
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
return iconMap[INBOX_TYPES.WHATSAPP];
}
return INBOX_ICON_MAP[type] ?? DEFAULT_ICON;
return iconMap[type] ?? defaultIcon;
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
@@ -41,70 +41,112 @@ describe('#Inbox Helpers', () => {
});
describe('getInboxIconByType', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
describe('fill variant (default)', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
describe('line variant', () => {
it('returns correct line icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB, null, 'line')).toBe(
'i-ri-global-line'
);
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
it('returns correct line icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB, null, 'line')).toBe(
'i-ri-messenger-line'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'
);
});
});
describe('Twilio cases', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
describe('fill variant', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
describe('line variant', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(
INBOX_TYPES.TWILIO,
'whatsapp:+1234567890',
'line'
)
).toBe('i-ri-whatsapp-line');
});
it('returns SMS line icon for regular Twilio number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
).toBe('i-ri-chat-1-line');
});
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
@@ -12,7 +12,8 @@
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
"EMPTY_STATE": "No results found."
"EMPTY_STATE": "No results found.",
"SEARCHING": "Searching..."
},
"DIALOG": {
"BUTTONS": {
@@ -393,6 +393,7 @@
"SEARCH_TITLE": "Search contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
@@ -666,7 +667,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
"CONTACT_CREATING": "Creating contact..."
},
"INBOX_SELECTOR": {
@@ -677,9 +678,9 @@
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
"CC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
"BCC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
@@ -178,7 +178,6 @@ export default {
<NextSidebar
v-if="showNextSidebar"
@toggle-account-modal="toggleAccountModal"
@open-notification-panel="openNotificationPanel"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@show-create-account-modal="openCreateAccountModal"
@@ -63,6 +63,10 @@ const goToContactsList = () => {
const fetchActiveContact = async () => {
if (route.params.contactId) {
store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch(
'contacts/fetchContactableInbox',
route.params.contactId
);
}
};
@@ -97,7 +101,7 @@ onMounted(() => {
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsDetailsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@@ -141,6 +145,7 @@ onMounted(() => {
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
/>
</template>
</template>
@@ -138,7 +138,7 @@ export default {
},
selectedInbox: {
get() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return (
inboxList.find(inbox => {
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
@@ -152,7 +152,7 @@ export default {
},
},
showNoInboxAlert() {
if (!this.contact.contactableInboxes) {
if (!this.contact.contact_inboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
@@ -166,7 +166,7 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return inboxList.map(inbox => ({
...inbox.inbox,
sourceId: inbox.source_id,
@@ -205,8 +205,8 @@ export const actions = {
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id,
contactableInboxes: response.data.payload,
id: Number(id),
contact_inboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {
@@ -5,6 +5,7 @@ const state = {
mineCount: 0,
unAssignedCount: 0,
allCount: 0,
updatedOn: null,
};
export const getters = {
@@ -12,7 +13,17 @@ export const getters = {
};
export const actions = {
get: async ({ commit }, params) => {
get: async ({ commit, state: $state }, params) => {
const currentTime = new Date();
const lastUpdatedTime = new Date($state.updatedOn);
// Skip large accounts from making too many requests
if (currentTime - lastUpdatedTime < 10000 && $state.allCount > 1000) {
// eslint-disable-next-line no-console
console.warn('Skipping conversation meta fetch');
return;
}
try {
const response = await ConversationApi.meta(params);
const {
@@ -40,6 +51,7 @@ export const mutations = {
$state.mineCount = mineCount;
$state.allCount = allCount;
$state.unAssignedCount = unAssignedCount;
$state.updatedOn = new Date();
},
};
@@ -11,7 +11,7 @@ describe('#actions', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({ data: { meta: { mine_count: 1 } } });
await actions.get(
{ commit },
{ commit, state: { updatedOn: null } },
{ inboxId: 1, assigneeTpe: 'me', status: 'open' }
);
expect(commit.mock.calls).toEqual([
@@ -21,7 +21,7 @@ describe('#actions', () => {
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get(
{ commit },
{ commit, state: { updatedOn: null } },
{ inboxId: 1, assigneeTpe: 'me', status: 'open' }
);
expect(commit.mock.calls).toEqual([]);
@@ -14,6 +14,7 @@ describe('#mutations', () => {
mineCount: 1,
unAssignedCount: 1,
allCount: 2,
updatedOn: expect.any(Date),
});
});
});