Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,322 @@
<script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
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 messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
);
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
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);
// Merge the processed contactableInboxes with the inboxesList
selectedContact.value.contactInboxes = mergeInboxDetails(
contactableInboxes,
inboxesList.value
);
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
showComposeNewConversation.value = false;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
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'),
};
discardCompose();
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,
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
currentContact.contactInboxes || []
);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...currentContact,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
},
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
handleClickOutside();
};
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
:class="{
'z-50': showComposeNewConversation && !viewInModal,
}"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<div
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
: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"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,301 @@
<script setup>
import { defineAsyncComponent, ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disableSendButton: { type: Boolean, default: false },
hasSelectedInbox: { type: Boolean, default: false },
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'sendTwilioMessage',
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
isEditorHotKeyEnabled,
} = useUISettings();
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const showTwilioContentTemplates = computed(() => {
return props.isTwilioWhatsAppInbox && props.inboxId;
});
const shouldShowEmojiButton = computed(() => {
return (
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
);
});
const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
const setSignature = () => {
if (props.messageSignature) {
if (sendWithSignature.value) {
emit('addSignature', props.messageSignature);
} else {
emit('removeSignature', props.messageSignature);
}
}
};
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
};
// Added this watch to dynamically set signature on target inbox change.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true
watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
});
},
{ immediate: true }
);
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
emit('attachFile', [...props.attachedFiles, newFile]);
};
},
});
const sendButtonLabel = computed(() => {
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
});
});
const keyboardEvents = {
Enter: {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
'$mod+Enter': {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
};
useKeyboardEvents(keyboardEvents);
const onPaste = e => {
if (!props.isEmailOrWebWidgetInbox) return;
const files = e.clipboardData?.files;
if (!files?.length) return;
// Filter valid files (non-zero size)
Array.from(files)
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
});
};
useEventListener(document, 'paste', onPaste);
</script>
<template>
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex gap-2 items-center">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
v-if="showTwilioContentTemplates"
:inbox-id="inboxId"
@send-message="emit('sendTwilioMessage', $event)"
/>
<div
v-if="shouldShowEmojiButton"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
v-if="isEmailOrWebWidgetInbox"
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
class="p-px"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
/>
</FileUpload>
<Button
v-if="shouldShowSignatureButton"
icon="i-lucide-signature"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex gap-2 items-center">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="isRegularMessageMode"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
:disabled="isLoading || disableSendButton"
:is-loading="isLoading"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
// The <label> tag inside the file-upload component overlaps the button due to its position.
// This causes the button's hover state to not work, as it's positioned below the label (z-index).
// Increasing the button's z-index would break the file upload functionality.
// This style ensures the label remains clickable while preserving the button's hover effect.
:deep() {
.file-uploads.file-uploads-html5 {
label {
@apply hover:cursor-pointer;
}
&:hover button {
@apply dark:bg-n-solid-2 bg-n-alpha-2;
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import { fileNameWithEllipsis } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:attachments']);
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const filteredImageAttachments = computed(() => {
return props.attachments.filter(attachment =>
isTypeImage(attachment.resource)
);
});
const filteredNonImageAttachments = computed(() => {
return props.attachments.filter(
attachment => !isTypeImage(attachment.resource)
);
});
const removeAttachment = id => {
const updatedAttachments = props.attachments.filter(
attachment => attachment.resource.id !== id
);
emit('update:attachments', updatedAttachments);
};
</script>
<template>
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[4.5rem] h-[4.5rem]"
>
<img
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
:src="attachment.thumb"
/>
<Button
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
<div
v-if="filteredNonImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="shrink-0 !h-5 !w-5"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,433 @@
<script setup>
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
prepareNewMessagePayload,
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
contactId: { type: String, default: null },
selectedContact: { type: Object, default: null },
targetInbox: { type: Object, default: null },
currentUser: { type: Object, default: null },
isCreatingContact: { type: Boolean, default: false },
isFetchingInboxes: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
'searchContacts',
'discard',
'updateSelectedContact',
'updateTargetInbox',
'clearSelectedContact',
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = props.formState || {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
};
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
isTwilioWhatsapp:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'whatsapp',
}));
const whatsappMessageTemplates = computed(() =>
Object.keys(props.targetInbox?.messageTemplates || {}).length
? props.targetInbox.messageTemplates
: []
);
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
targetInbox: computed(() => props.targetInbox),
message: computed(() => state.message),
subject: computed(() => state.subject),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid,
isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid,
isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid,
}));
const newMessagePayload = () => {
const { message, subject, ccEmails, bccEmails, attachedFiles } = state;
return prepareNewMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser: props.currentUser,
attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled,
});
};
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const showNoInboxAlert = computed(() => {
return (
props.selectedContact &&
contactableInboxesList.value.length === 0 &&
!props.contactsUiFlags.isFetchingInboxes &&
!props.isFetchingInboxes
);
});
const isAnyDropdownActive = computed(() => {
return (
showContactsDropdown.value ||
showInboxesDropdown.value ||
showCcEmailsDropdown.value ||
showBccEmailsDropdown.value
);
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
};
const handleDropdownUpdate = (type, value) => {
if (type === 'cc') {
showCcEmailsDropdown.value = value;
} else if (type === 'bcc') {
showBccEmailsDropdown.value = value;
} else {
showContactsDropdown.value = value;
}
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
showInboxesDropdown.value = true;
};
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('createConversation', {
payload: newMessagePayload(),
isFromWhatsApp: false,
});
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
const handleSendWhatsappMessage = async ({ message, templateParams }) => {
const whatsappMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: whatsappMessagePayload,
isFromWhatsApp: true,
});
};
const handleSendTwilioMessage = async ({ message, templateParams }) => {
const twilioMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: twilioMessagePayload,
isFromWhatsApp: true,
});
};
const shouldShowMessageEditor = computed(() => {
return (
!inboxTypes.value.isWhatsapp &&
!showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp
);
});
</script>
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<ActionButtons
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@send-twilio-message="handleSendTwilioMessage"
/>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
required: true,
},
selectedContact: {
type: Object,
default: null,
},
showContactsDropdown: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
isCreatingContact: {
type: Boolean,
required: true,
},
contactId: {
type: String,
default: null,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const inputType = ref(INPUT_TYPES.EMAIL);
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: email ? `${name} (${email})` : name,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
name,
email,
action: 'contact',
}));
});
const selectedContactLabel = computed(() => {
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'
: '';
});
const handleInput = value => {
// Update input type based on whether input starts with '+'
// If it does, set input type to 'tel'
// Otherwise, set input type to 'email'
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
emit('searchContacts', value);
};
</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(`${i18nPrefix}.LABEL`) }}
</label>
<div
v-if="isCreatingContact"
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(`${i18nPrefix}.CONTACT_CREATING`) }}
</span>
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 min-h-7 min-w-0"
:class="!contactId ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
>
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(`${i18nPrefix}.CONTACT_CREATING`)
: selectedContactLabel
}}
</span>
<Button
v-if="!contactId"
variant="ghost"
icon="i-lucide-x"
color="slate"
:disabled="contactId"
size="xs"
@click="emit('clearSelectedContact')"
/>
</div>
<TagInput
v-else
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
:type="inputType"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="handleInput"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<ContentTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</ContentTemplateParser>
</div>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
return inboxData?.content_templates?.templates || [];
});
const filteredTemplates = computed(() => {
return contentTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase()) &&
template.status === 'approved'
);
});
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
contacts: { type: Array, required: true },
showCcEmailsDropdown: { type: Boolean, required: false },
showBccEmailsDropdown: { type: Boolean, required: false },
isLoading: { type: Boolean, default: false },
hasErrors: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchCcEmails',
'searchBccEmails',
'updateDropdown',
]);
const i18nPrefix = `COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS`;
const showBccInput = ref(false);
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const subject = defineModel('subject', { type: String, default: '' });
const ccEmails = defineModel('ccEmails', { type: String, default: '' });
const bccEmails = defineModel('bccEmails', { type: String, default: '' });
const { t } = useI18n();
// Convert string to array for TagInput
const ccEmailsArray = computed(() =>
props.ccEmails ? props.ccEmails.split(',').map(email => email.trim()) : []
);
const bccEmailsArray = computed(() =>
props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : []
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
const handleCcUpdate = value => {
ccEmails.value = value.join(',');
};
const handleBccUpdate = value => {
bccEmails.value = value.join(',');
};
const inputClass = computed(() => {
return props.hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
<template>
<div class="flex flex-col divide-y divide-n-strong">
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)"
:label="t(`${i18nPrefix}.SUBJECT_LABEL`)"
focus-on-mount
:custom-input-class="inputClass"
/>
</div>
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.CC_LABEL`) }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
/>
<Button
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="toggleBccInput"
/>
</div>
</div>
<div
v-if="showBccInput"
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
targetInbox: {
type: Object,
default: null,
},
selectedContact: {
type: Object,
default: null,
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'updateInbox',
'toggleDropdown',
'handleInboxAction',
]);
const { t } = useI18n();
const targetInboxLabel = computed(() => {
return generateLabelForContactableInboxesList(props.targetInbox);
});
</script>
<template>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.LABEL') }}
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="emit('updateInbox', null)"
/>
</div>
<div
v-else
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"
:color="hasErrors ? 'ruby' : 'slate'"
:disabled="!selectedContact"
class="hover:!no-underline"
@click="emit('toggleDropdown', !showInboxesDropdown)"
/>
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
</script>
<template>
<div class="flex-1 h-full">
<Editor
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
</div>
</template>

View File

@@ -0,0 +1,119 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
const filteredTemplates = computed(() => {
return whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
import { contacts, activeContact, emailInbox, currentUser } from './fixtures';
import ComposeNewConversationForm from '../ComposeNewConversationForm.vue';
const selectedContact = ref(activeContact);
const targetInbox = ref(emailInbox);
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
console.log('Target inbox updated:', inbox);
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,168 @@
export const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
export const activeContact = {
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
sourceId: '123456',
phoneNumber: '+1223233434',
channelType: 'Channel::Whatsapp',
messageTemplates: [
{
id: '1',
name: 'shipment_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.',
type: 'BODY',
example: {
bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/',
text: 'Track shipment',
type: 'URL',
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'shipment_confirmation_2',
},
{
id: '2',
name: 'otp_test',
status: 'APPROVED',
category: 'AUTHENTICATION',
language: 'en_US',
components: [
{
text: 'Use code *{{1}}* to authorize your transaction.',
type: 'BODY',
example: {
bodyText: [['123456']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}',
text: 'Copy code',
type: 'URL',
example: [
'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456',
],
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'verify_transaction_1',
messageSendTtlSeconds: 900,
},
{
id: '3',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameterFormat: 'POSITIONAL',
},
],
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
};
export const emailInbox = {
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
};
export const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};

View File

@@ -0,0 +1,238 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
};
export const generateLabelForContactableInboxesList = ({
name,
email,
channelType,
phoneNumber,
}) => {
if (channelType === INBOX_TYPES.EMAIL) {
return `${name} (${email})`;
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return phoneNumber ? `${name} (${phoneNumber})` : name;
}
return name;
};
const transformInbox = ({
name,
id,
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
medium,
...rest,
});
export const compareInboxes = (a, b) => {
// Channels that have no priority defined should come at the end.
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(transformInbox).sort(compareInboxes);
};
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 mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
) => {
const files = [];
attachedFiles.forEach(attachment => {
if (directUploadsEnabled) {
files.push(attachment.blobSignedId);
} else {
files.push(attachment.resource.file);
}
});
return files;
};
export const prepareNewMessagePayload = ({
targetInbox,
selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
}) => {
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
assigneeId: currentUser.id,
};
if (attachedFiles?.length) {
payload.files = prepareAttachmentPayload(
attachedFiles,
directUploadsEnabled
);
}
if (subject) {
payload.mailSubject = subject;
}
if (ccEmails) {
payload.message.cc_emails = ccEmails;
}
if (bccEmails) {
payload.message.bcc_emails = bccEmails;
}
return payload;
};
export const prepareWhatsAppMessagePayload = ({
targetInbox,
selectedContact,
message,
templateParams,
currentUser,
}) => {
return {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: selectedContact.id,
message: { content: message, template_params: templateParams },
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 input => {
const payload = {
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
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);
};

View File

@@ -0,0 +1,705 @@
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('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
it('generates label for email inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
});
});
describe('buildContactableInboxesList', () => {
it('returns empty array if no contact inboxes', () => {
expect(helpers.buildContactableInboxesList(null)).toEqual([]);
expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
});
it('builds list of contactable inboxes with correct format', () => {
const inboxes = [
{
id: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
phoneNumber: null,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-woot-mail',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
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('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
'signed1',
]);
});
it('prepares regular files', () => {
const files = [{ resource: { file: 'file1' } }];
expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
});
});
describe('prepareNewMessagePayload', () => {
const baseParams = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: '2' },
message: 'Hello',
currentUser: { id: 3 },
};
it('prepares basic message payload', () => {
const result = helpers.prepareNewMessagePayload(baseParams);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: { content: 'Hello' },
assigneeId: 3,
});
});
it('includes optional fields when provided', () => {
const result = helpers.prepareNewMessagePayload({
...baseParams,
subject: 'Test',
ccEmails: 'cc@test.com',
bccEmails: 'bcc@test.com',
attachedFiles: [{ blobSignedId: 'file1' }],
directUploadsEnabled: true,
});
expect(result).toMatchObject({
mailSubject: 'Test',
message: {
content: 'Hello',
cc_emails: 'cc@test.com',
bcc_emails: 'bcc@test.com',
},
files: ['file1'],
});
});
});
describe('prepareWhatsAppMessagePayload', () => {
it('prepares whatsapp message payload', () => {
const params = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: 2 },
message: 'Hello',
templateParams: { param1: 'value1' },
currentUser: { id: 3 },
};
const result = helpers.prepareWhatsAppMessagePayload(params);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: {
content: 'Hello',
template_params: { param1: 'value1' },
},
assigneeId: 3,
});
});
});
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',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
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([]);
});
});
});
});
describe('compareInboxes', () => {
it('should sort inboxes by channel priority', () => {
const inboxes = [
{ channelType: 'Channel::Api', name: 'API Inbox' },
{ channelType: 'Channel::Email', name: 'Email Inbox' },
{ channelType: 'Channel::WebWidget', name: 'Widget' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Email');
expect(sorted[1].channelType).toBe('Channel::Whatsapp');
expect(sorted[2].channelType).toBe('Channel::WebWidget');
expect(sorted[3].channelType).toBe('Channel::Api');
});
it('should sort SMS channels correctly', () => {
const inboxes = [
{ channelType: 'Channel::TwilioSms', name: 'Twilio' },
{ channelType: 'Channel::Sms', name: 'Regular SMS' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Sms');
expect(sorted[1].channelType).toBe('Channel::TwilioSms');
});
it('should sort by name when channel types are same', () => {
const inboxes = [
{ channelType: 'Channel::Email', name: 'Support' },
{ channelType: 'Channel::Email', name: 'Marketing' },
{ channelType: 'Channel::Email', name: 'Billing' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(inbox => inbox.name)).toEqual([
'Billing',
'Marketing',
'Support',
]);
});
it('should put channels without priority at the end', () => {
const inboxes = [
{ channelType: 'Channel::Unknown', name: 'Unknown' },
{ channelType: 'Channel::Email', name: 'Email' },
{ channelType: 'Channel::LineChannel', name: 'Line' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(i => i.channelType)).toEqual([
'Channel::Email',
'Channel::Whatsapp',
'Channel::LineChannel',
'Channel::Unknown',
]);
});
it('should handle empty array', () => {
const inboxes = [];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted).toEqual([]);
});
});