feat(v4): Compose new conversation without multiple clicks (#10545)
--------- Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
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 contactId = computed(() => route.params.contactId || null);
|
||||
const activeContact = computed(() => contactById.value(contactId.value));
|
||||
|
||||
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 && contactId.value) {
|
||||
// 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 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,
|
||||
@@ -69,13 +67,19 @@ const contactsList = computed(() => {
|
||||
const selectedContactLabel = computed(() => {
|
||||
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
|
||||
});
|
||||
|
||||
const errorClass = computed(() => {
|
||||
return props.hasErrors
|
||||
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
||||
: '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 +87,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 +97,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 +112,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 +121,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,4 +1,6 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
export const convertChannelTypeToLabel = channelType => {
|
||||
const [, type] = channelType.split('::');
|
||||
@@ -45,6 +47,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 +130,57 @@ 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 })
|
||||
);
|
||||
return camelcaseKeys(payload, { deep: true });
|
||||
};
|
||||
|
||||
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,7 +1,10 @@
|
||||
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', () => {
|
||||
@@ -90,6 +93,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 +200,210 @@ 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('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,
|
||||
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,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user