mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: Add compose conversation components (#10457)
Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
		| @@ -169,7 +169,7 @@ watch( | ||||
|           @apply m-0 !important; | ||||
|  | ||||
|           &::before { | ||||
|             @apply text-n-slate-10 dark:text-n-slate-10 !important; | ||||
|             @apply text-n-slate-11 dark:text-n-slate-11; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|   | ||||
| @@ -111,7 +111,7 @@ onMounted(() => { | ||||
|             custom-label-class="min-w-[120px]" | ||||
|           /> | ||||
|         </div> | ||||
|         <div class="flex justify-between w-full gap-2 py-2"> | ||||
|         <div class="flex justify-between w-full gap-3 py-2"> | ||||
|           <label | ||||
|             class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50" | ||||
|           > | ||||
|   | ||||
| @@ -0,0 +1,260 @@ | ||||
| <script setup> | ||||
| 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 { vOnClickOutside } from '@vueuse/components'; | ||||
| import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; | ||||
| import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; | ||||
| import FileUpload from 'vue-upload-component'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import WhatsAppOptions from './WhatsAppOptions.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   attachedFiles: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|   }, | ||||
|   isWhatsappInbox: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   isEmailOrWebWidgetInbox: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   isTwilioSmsInbox: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   messageTemplates: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|   }, | ||||
|   channelType: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   disableSendButton: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   hasNoInbox: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   isDropdownActive: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits([ | ||||
|   'discard', | ||||
|   'sendMessage', | ||||
|   'sendWhatsappMessage', | ||||
|   'insertEmoji', | ||||
|   'addSignature', | ||||
|   'removeSignature', | ||||
|   'attachFile', | ||||
| ]); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const uploadAttachment = ref(null); | ||||
| const isEmojiPickerOpen = ref(false); | ||||
|  | ||||
| const EmojiInput = defineAsyncComponent( | ||||
|   () => import('shared/components/emoji/EmojiInput.vue') | ||||
| ); | ||||
|  | ||||
| const messageSignature = useMapGetter('getMessageSignature'); | ||||
| const signatureToApply = computed(() => messageSignature.value); | ||||
|  | ||||
| const { | ||||
|   fetchSignatureFlagFromUISettings, | ||||
|   setSignatureFlagForInbox, | ||||
|   isEditorHotKeyEnabled, | ||||
| } = useUISettings(); | ||||
|  | ||||
| const sendWithSignature = computed(() => { | ||||
|   return fetchSignatureFlagFromUISettings(props.channelType); | ||||
| }); | ||||
|  | ||||
| const isSignatureEnabledForInbox = computed(() => { | ||||
|   return props.isEmailOrWebWidgetInbox && sendWithSignature.value; | ||||
| }); | ||||
|  | ||||
| const setSignature = () => { | ||||
|   if (signatureToApply.value) { | ||||
|     if (isSignatureEnabledForInbox.value) { | ||||
|       emit('addSignature', signatureToApply.value); | ||||
|     } else { | ||||
|       emit('removeSignature', signatureToApply.value); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const toggleMessageSignature = () => { | ||||
|   setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); | ||||
|   setSignature(); | ||||
| }; | ||||
|  | ||||
| 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 = () => { | ||||
|       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') && | ||||
|         !props.isWhatsappInbox && | ||||
|         !props.isDropdownActive | ||||
|       ) { | ||||
|         emit('sendMessage'); | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
|   '$mod+Enter': { | ||||
|     action: () => { | ||||
|       if ( | ||||
|         isEditorHotKeyEnabled('cmd_enter') && | ||||
|         !props.isWhatsappInbox && | ||||
|         !props.isDropdownActive | ||||
|       ) { | ||||
|         emit('sendMessage'); | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| useKeyboardEvents(keyboardEvents); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3" | ||||
|   > | ||||
|     <div class="flex items-center gap-2"> | ||||
|       <WhatsAppOptions | ||||
|         v-if="isWhatsappInbox" | ||||
|         :message-templates="messageTemplates" | ||||
|         @send-message="emit('sendWhatsappMessage', $event)" | ||||
|       /> | ||||
|       <div | ||||
|         v-if="!isWhatsappInbox && !hasNoInbox" | ||||
|         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="left-0 top-full mt-1.5" | ||||
|           :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="isEmailOrWebWidgetInbox" | ||||
|         icon="i-lucide-signature" | ||||
|         color="slate" | ||||
|         size="sm" | ||||
|         class="!w-10" | ||||
|         @click="toggleMessageSignature" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex items-center gap-2"> | ||||
|       <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="!isWhatsappInbox" | ||||
|         :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; | ||||
| } | ||||
| </style> | ||||
| @@ -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"> | ||||
|     <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-[72px] h-[72px]" | ||||
|       > | ||||
|         <img | ||||
|           class="object-cover w-[72px] h-[72px] rounded-lg" | ||||
|           :src="attachment.thumb" | ||||
|         /> | ||||
|         <Button | ||||
|           variant="ghost" | ||||
|           icon="i-lucide-trash" | ||||
|           color="slate" | ||||
|           class="absolute top-1 right-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-[300px] 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> | ||||
| @@ -0,0 +1,332 @@ | ||||
| <script setup> | ||||
| import { reactive, 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, | ||||
| } 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 }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits([ | ||||
|   'searchContacts', | ||||
|   'discard', | ||||
|   'updateSelectedContact', | ||||
|   'updateTargetInbox', | ||||
|   'clearSelectedContact', | ||||
|   'createConversation', | ||||
| ]); | ||||
|  | ||||
| const showContactsDropdown = ref(false); | ||||
| const showInboxesDropdown = ref(false); | ||||
| const showCcEmailsDropdown = ref(false); | ||||
| const showBccEmailsDropdown = ref(false); | ||||
|  | ||||
| const isCreating = computed(() => props.contactConversationsUiFlags.isCreating); | ||||
|  | ||||
| const state = reactive({ | ||||
|   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', | ||||
| })); | ||||
|  | ||||
| const whatsappMessageTemplates = computed(() => | ||||
|   Object.keys(props.targetInbox?.messageTemplates || {}).length | ||||
|     ? props.targetInbox.messageTemplates | ||||
|     : [] | ||||
| ); | ||||
|  | ||||
| const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); | ||||
|  | ||||
| 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 => { | ||||
|   emit('searchContacts', 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', value); | ||||
| }; | ||||
|  | ||||
| const searchBccEmails = value => { | ||||
|   showBccEmailsDropdown.value = true; | ||||
|   emit('searchContacts', value); | ||||
| }; | ||||
|  | ||||
| const setSelectedContact = async ({ value, action, ...rest }) => { | ||||
|   v$.value.$reset(); | ||||
|   emit('updateSelectedContact', { value, action, ...rest }); | ||||
|   showContactsDropdown.value = false; | ||||
|   showInboxesDropdown.value = true; | ||||
| }; | ||||
|  | ||||
| const handleInboxAction = ({ value, action, ...rest }) => { | ||||
|   v$.value.$reset(); | ||||
|   emit('updateTargetInbox', { ...rest }); | ||||
|   showInboxesDropdown.value = false; | ||||
|   state.attachedFiles = []; | ||||
| }; | ||||
|  | ||||
| const removeTargetInbox = value => { | ||||
|   v$.value.$reset(); | ||||
|   emit('updateTargetInbox', value); | ||||
|   state.attachedFiles = []; | ||||
| }; | ||||
|  | ||||
| const clearSelectedContact = () => { | ||||
|   emit('clearSelectedContact'); | ||||
|   state.attachedFiles = []; | ||||
| }; | ||||
|  | ||||
| const onClickInsertEmoji = emoji => { | ||||
|   state.message += emoji; | ||||
| }; | ||||
|  | ||||
| const handleAddSignature = signature => { | ||||
|   state.message = appendSignature(state.message, signature); | ||||
| }; | ||||
|  | ||||
| const handleRemoveSignature = signature => { | ||||
|   state.message = removeSignature(state.message, signature); | ||||
| }; | ||||
|  | ||||
| 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, | ||||
|   }); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <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" | ||||
|   > | ||||
|     <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="!inboxTypes.isWhatsapp && !showNoInboxAlert" | ||||
|       v-model="state.message" | ||||
|       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" | ||||
|       :has-errors="validationStates.isMessageInvalid" | ||||
|       :has-attachments="state.attachedFiles.length > 0" | ||||
|     /> | ||||
|  | ||||
|     <AttachmentPreviews | ||||
|       v-if="state.attachedFiles.length > 0" | ||||
|       :attachments="state.attachedFiles" | ||||
|       @update:attachments="state.attachedFiles = $event" | ||||
|     /> | ||||
|  | ||||
|     <ActionButtons | ||||
|       :attached-files="state.attachedFiles" | ||||
|       :is-whatsapp-inbox="inboxTypes.isWhatsapp" | ||||
|       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" | ||||
|       :is-twilio-sms-inbox="inboxTypes.isTwilioSMS" | ||||
|       :message-templates="whatsappMessageTemplates" | ||||
|       :channel-type="inboxChannelType" | ||||
|       :is-loading="isCreating" | ||||
|       :disable-send-button="isCreating" | ||||
|       :has-no-inbox="showNoInboxAlert" | ||||
|       :is-dropdown-active="isAnyDropdownActive" | ||||
|       @insert-emoji="onClickInsertEmoji" | ||||
|       @add-signature="handleAddSignature" | ||||
|       @remove-signature="handleRemoveSignature" | ||||
|       @attach-file="handleAttachFile" | ||||
|       @discard="$emit('discard')" | ||||
|       @send-message="handleSendMessage" | ||||
|       @send-whatsapp-message="handleSendWhatsappMessage" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,141 @@ | ||||
| <script setup> | ||||
| import { computed } 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'; | ||||
|  | ||||
| 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 { t } = useI18n(); | ||||
|  | ||||
| const contactsList = computed(() => { | ||||
|   return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({ | ||||
|     id, | ||||
|     label: `${name} (${email})`, | ||||
|     value: id, | ||||
|     thumbnail: { name, src: thumbnail }, | ||||
|     ...rest, | ||||
|     name, | ||||
|     email, | ||||
|     action: 'contact', | ||||
|   })); | ||||
| }); | ||||
|  | ||||
| const selectedContactLabel = computed(() => { | ||||
|   return `${props.selectedContact?.name} (${props.selectedContact?.email})`; | ||||
| }); | ||||
| </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') }} | ||||
|       </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('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING') | ||||
|           }} | ||||
|         </span> | ||||
|       </div> | ||||
|       <div | ||||
|         v-else-if="selectedContact" | ||||
|         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"> | ||||
|           {{ | ||||
|             isCreatingContact | ||||
|               ? t( | ||||
|                   'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING' | ||||
|                 ) | ||||
|               : selectedContactLabel | ||||
|           }} | ||||
|         </span> | ||||
|         <Button | ||||
|           variant="ghost" | ||||
|           icon="i-lucide-x" | ||||
|           color="slate" | ||||
|           :disabled="contactId" | ||||
|           size="xs" | ||||
|           @click="emit('clearSelectedContact')" | ||||
|         /> | ||||
|       </div> | ||||
|       <TagInput | ||||
|         v-else | ||||
|         :placeholder=" | ||||
|           t( | ||||
|             'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER' | ||||
|           ) | ||||
|         " | ||||
|         mode="single" | ||||
|         :menu-items="contactsList" | ||||
|         :show-dropdown="showContactsDropdown" | ||||
|         :is-loading="isLoading" | ||||
|         :disabled="contactableInboxesList?.length > 0 && showInboxesDropdown" | ||||
|         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)" | ||||
|         @input="emit('searchContacts', $event)" | ||||
|         @on-click-outside="emit('updateDropdown', 'contacts', false)" | ||||
|         @add="emit('setSelectedContact', $event)" | ||||
|         @remove="emit('clearSelectedContact')" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,139 @@ | ||||
| <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" | ||||
|           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" | ||||
|         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> | ||||
| @@ -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> | ||||
| @@ -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 px-3 h-7 min-w-0" | ||||
|     > | ||||
|       <span class="text-sm 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="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5" | ||||
|         @action="emit('handleInboxAction', $event)" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,65 @@ | ||||
| <script setup> | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| import Editor from 'dashboard/components-next/Editor/Editor.vue'; | ||||
| import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||
|  | ||||
| defineProps({ | ||||
|   isEmailOrWebWidgetInbox: { | ||||
|     type: Boolean, | ||||
|     required: true, | ||||
|   }, | ||||
|   hasErrors: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   hasAttachments: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const modelValue = defineModel({ | ||||
|   type: String, | ||||
|   default: '', | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     v-if="isEmailOrWebWidgetInbox" | ||||
|     class="flex-1 h-full" | ||||
|     :class="!hasAttachments && 'min-h-[200px]'" | ||||
|   > | ||||
|     <Editor | ||||
|       v-model="modelValue" | ||||
|       :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-[200px]" | ||||
|       :class=" | ||||
|         hasErrors | ||||
|           ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' | ||||
|           : '' | ||||
|       " | ||||
|       :show-character-count="false" | ||||
|     /> | ||||
|   </div> | ||||
|   <div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'"> | ||||
|     <TextArea | ||||
|       v-model="modelValue" | ||||
|       :placeholder=" | ||||
|         t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') | ||||
|       " | ||||
|       class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent" | ||||
|       auto-height | ||||
|       :custom-text-area-class=" | ||||
|         hasErrors | ||||
|           ? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9' | ||||
|           : '' | ||||
|       " | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,126 @@ | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import WhatsappTemplateParser from './WhatsappTemplateParser.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   messageTemplates: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['sendMessage']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| // TODO: Remove this when we support all formats | ||||
| const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO']; | ||||
|  | ||||
| const searchQuery = ref(''); | ||||
| const selectedTemplate = ref(null); | ||||
|  | ||||
| const showTemplatesMenu = ref(false); | ||||
|  | ||||
| const whatsAppTemplateMessages = computed(() => { | ||||
|   // Add null check and ensure it's an array | ||||
|   const templates = Array.isArray(props.messageTemplates) | ||||
|     ? props.messageTemplates | ||||
|     : []; | ||||
|  | ||||
|   // TODO: Remove the last filter when we support all formats | ||||
|   return templates | ||||
|     .filter(template => template?.status?.toLowerCase() === 'approved') | ||||
|     .filter(template => { | ||||
|       return template?.components?.every(component => { | ||||
|         return !formatsToRemove.includes(component.format); | ||||
|       }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| 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 left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg" | ||||
|     > | ||||
|       <div class="relative w-full"> | ||||
|         <span class="absolute i-lucide-search size-3.5 top-2 left-3" /> | ||||
|         <input | ||||
|           v-model="searchQuery" | ||||
|           type="search" | ||||
|           :placeholder=" | ||||
|             t( | ||||
|               'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER' | ||||
|             ) | ||||
|           " | ||||
|           class="w-full h-8 py-2 pl-10 pr-2 text-sm 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 w-full gap-2 p-2 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="w-full pt-2 text-sm text-n-slate-11"> | ||||
|           {{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }} | ||||
|         </p> | ||||
|       </template> | ||||
|     </div> | ||||
|     <WhatsappTemplateParser | ||||
|       v-if="selectedTemplate" | ||||
|       :template="selectedTemplate" | ||||
|       @send-message="handleSendMessage" | ||||
|       @back="handleBack" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,176 @@ | ||||
| <script setup> | ||||
| import { computed, ref, onMounted } from 'vue'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
| import { requiredIf } from '@vuelidate/validators'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   template: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['sendMessage', 'back']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const processedParams = ref({}); | ||||
|  | ||||
| const templateName = computed(() => { | ||||
|   return props.template?.name || ''; | ||||
| }); | ||||
|  | ||||
| const templateString = computed(() => { | ||||
|   return props.template?.components?.find( | ||||
|     component => component.type === 'BODY' | ||||
|   ).text; | ||||
| }); | ||||
|  | ||||
| const processVariable = str => { | ||||
|   return str.replace(/{{|}}/g, ''); | ||||
| }; | ||||
|  | ||||
| const processedString = computed(() => { | ||||
|   return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { | ||||
|     const variableKey = processVariable(variable); | ||||
|     return processedParams.value[variableKey] || `{{${variable}}}`; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const processedStringWithVariableHighlight = computed(() => { | ||||
|   const variables = templateString.value.match(/{{([^}]+)}}/g) || []; | ||||
|  | ||||
|   return variables.reduce((result, variable) => { | ||||
|     const variableKey = processVariable(variable); | ||||
|     const value = processedParams.value[variableKey] || variable; | ||||
|     return result.replace( | ||||
|       variable, | ||||
|       `<span class="break-all text-n-slate-12">${value}</span>` | ||||
|     ); | ||||
|   }, templateString.value); | ||||
| }); | ||||
|  | ||||
| const rules = computed(() => { | ||||
|   const paramRules = {}; | ||||
|   Object.keys(processedParams.value).forEach(key => { | ||||
|     paramRules[key] = { required: requiredIf(true) }; | ||||
|   }); | ||||
|   return { | ||||
|     processedParams: paramRules, | ||||
|   }; | ||||
| }); | ||||
|  | ||||
| const v$ = useVuelidate(rules, { processedParams }); | ||||
|  | ||||
| const getFieldErrorType = key => { | ||||
|   if (!v$.value.processedParams[key]?.$error) return 'info'; | ||||
|   return 'error'; | ||||
| }; | ||||
|  | ||||
| const generateVariables = () => { | ||||
|   const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); | ||||
|   if (!matchedVariables) return; | ||||
|  | ||||
|   const finalVars = matchedVariables.map(i => processVariable(i)); | ||||
|   processedParams.value = finalVars.reduce((acc, variable) => { | ||||
|     acc[variable] = ''; | ||||
|     return acc; | ||||
|   }, {}); | ||||
| }; | ||||
|  | ||||
| const sendMessage = async () => { | ||||
|   const isValid = await v$.value.$validate(); | ||||
|   if (!isValid) return; | ||||
|  | ||||
|   const payload = { | ||||
|     message: processedString.value, | ||||
|     templateParams: { | ||||
|       name: props.template.name, | ||||
|       category: props.template.category, | ||||
|       language: props.template.language, | ||||
|       namespace: props.template.namespace, | ||||
|       processed_params: processedParams.value, | ||||
|     }, | ||||
|   }; | ||||
|   emit('sendMessage', payload); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   generateVariables(); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg" | ||||
|   > | ||||
|     <span class="text-sm text-n-slate-12"> | ||||
|       {{ | ||||
|         t( | ||||
|           'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME', | ||||
|           { templateName: templateName } | ||||
|         ) | ||||
|       }} | ||||
|     </span> | ||||
|     <p | ||||
|       class="mb-0 text-sm text-n-slate-11" | ||||
|       v-html="processedStringWithVariableHighlight" | ||||
|     /> | ||||
|  | ||||
|     <span | ||||
|       v-if="Object.keys(processedParams).length" | ||||
|       class="text-sm font-medium text-n-slate-12" | ||||
|     > | ||||
|       {{ | ||||
|         t( | ||||
|           'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES' | ||||
|         ) | ||||
|       }} | ||||
|     </span> | ||||
|  | ||||
|     <div | ||||
|       v-for="(variable, key) in processedParams" | ||||
|       :key="key" | ||||
|       class="flex items-center w-full gap-2" | ||||
|     > | ||||
|       <span | ||||
|         class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10" | ||||
|       > | ||||
|         {{ key }} | ||||
|       </span> | ||||
|       <Input | ||||
|         v-model="processedParams[key]" | ||||
|         custom-input-class="!h-8 w-full !bg-transparent" | ||||
|         class="w-full" | ||||
|         :message-type="getFieldErrorType(key)" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex items-end justify-between w-full gap-3 h-14"> | ||||
|       <Button | ||||
|         :label=" | ||||
|           t( | ||||
|             'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK' | ||||
|           ) | ||||
|         " | ||||
|         color="slate" | ||||
|         variant="faded" | ||||
|         class="w-full font-medium" | ||||
|         @click="emit('back')" | ||||
|       /> | ||||
|       <Button | ||||
|         :label=" | ||||
|           t( | ||||
|             'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE' | ||||
|           ) | ||||
|         " | ||||
|         class="w-full font-medium" | ||||
|         @click="sendMessage" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -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> | ||||
| @@ -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', | ||||
| }; | ||||
| @@ -0,0 +1,118 @@ | ||||
| import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||
|  | ||||
| export const convertChannelTypeToLabel = channelType => { | ||||
|   const [, type] = channelType.split('::'); | ||||
|   return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType; | ||||
| }; | ||||
|  | ||||
| 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 `${name} (${phoneNumber})`; | ||||
|   } | ||||
|   return `${name} (${convertChannelTypeToLabel(channelType)})`; | ||||
| }; | ||||
|  | ||||
| export const buildContactableInboxesList = contactInboxes => { | ||||
|   if (!contactInboxes) return []; | ||||
|   return contactInboxes.map( | ||||
|     ({ name, id, email, channelType, phoneNumber, ...rest }) => ({ | ||||
|       id, | ||||
|       label: generateLabelForContactableInboxesList({ | ||||
|         name, | ||||
|         email, | ||||
|         channelType, | ||||
|         phoneNumber, | ||||
|       }), | ||||
|       action: 'inbox', | ||||
|       value: id, | ||||
|       name, | ||||
|       email, | ||||
|       phoneNumber, | ||||
|       channelType, | ||||
|       ...rest, | ||||
|     }) | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| 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, | ||||
|   }; | ||||
| }; | ||||
| @@ -0,0 +1,171 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||
| import * as helpers from '../composeConversationHelper'; | ||||
|  | ||||
| describe('composeConversationHelper', () => { | ||||
|   describe('convertChannelTypeToLabel', () => { | ||||
|     it('converts channel type with namespace to capitalized label', () => { | ||||
|       expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email'); | ||||
|       expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe( | ||||
|         'Whatsapp' | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('returns original value if no namespace found', () => { | ||||
|       expect(helpers.convertChannelTypeToLabel('email')).toBe('email'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('generateLabelForContactableInboxesList', () => { | ||||
|     const contact = { | ||||
|       name: 'John Doe', | ||||
|       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 (Api)'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   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, | ||||
|         label: 'Email Inbox (support@example.com)', | ||||
|         action: 'inbox', | ||||
|         value: 1, | ||||
|         name: 'Email Inbox', | ||||
|         email: 'support@example.com', | ||||
|         channelType: INBOX_TYPES.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, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -25,6 +25,10 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   isSearching: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['action']); | ||||
| @@ -81,7 +85,6 @@ onMounted(() => { | ||||
|       }" | ||||
|       :disabled="item.disabled" | ||||
|       @click="handleAction(item)" | ||||
|       @keydown.enter="handleAction(item)" | ||||
|     > | ||||
|       <slot name="thumbnail" :item="item"> | ||||
|         <Avatar | ||||
| @@ -102,7 +105,11 @@ onMounted(() => { | ||||
|       v-if="filteredMenuItems.length === 0" | ||||
|       class="text-sm text-n-slate-11 px-2 py-1.5" | ||||
|     > | ||||
|       {{ t('DROPDOWN_MENU.EMPTY_STATE') }} | ||||
|       {{ | ||||
|         isSearching | ||||
|           ? t('DROPDOWN_MENU.SEARCHING') | ||||
|           : t('DROPDOWN_MENU.EMPTY_STATE') | ||||
|       }} | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| <script setup> | ||||
| defineProps({ | ||||
|   modelValue: { | ||||
|     type: [String, Number], | ||||
|     default: '', | ||||
|   }, | ||||
| import { ref, onMounted, nextTick } from 'vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   type: { | ||||
|     type: String, | ||||
|     default: 'text', | ||||
| @@ -32,13 +30,49 @@ defineProps({ | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   focusOnMount: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue', 'enterPress']); | ||||
| const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']); | ||||
|  | ||||
| const modelValue = defineModel({ | ||||
|   type: [String, Number], | ||||
|   default: '', | ||||
| }); | ||||
|  | ||||
| const inlineInputRef = ref(null); | ||||
|  | ||||
| const onEnterPress = () => { | ||||
|   emit('enterPress'); | ||||
| }; | ||||
|  | ||||
| const handleInput = event => { | ||||
|   emit('input', event.target.value); | ||||
|   modelValue.value = event.target.value; | ||||
| }; | ||||
|  | ||||
| const handleBlur = event => { | ||||
|   emit('blur', event.target.value); | ||||
| }; | ||||
|  | ||||
| const handleFocus = event => { | ||||
|   emit('focus', event.target.value); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   nextTick(() => { | ||||
|     if (props.focusOnMount) { | ||||
|       inlineInputRef.value?.focus(); | ||||
|     } | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| defineExpose({ | ||||
|   focus: () => inlineInputRef.value?.focus(), | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -49,7 +83,7 @@ const onEnterPress = () => { | ||||
|       v-if="label" | ||||
|       :for="id" | ||||
|       :class="customLabelClass" | ||||
|       class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50" | ||||
|       class="mb-0.5 text-sm font-medium text-n-slate-11" | ||||
|     > | ||||
|       {{ label }} | ||||
|     </label> | ||||
| @@ -57,13 +91,16 @@ const onEnterPress = () => { | ||||
|     <slot name="prefix" /> | ||||
|     <input | ||||
|       :id="id" | ||||
|       :value="modelValue" | ||||
|       ref="inlineInputRef" | ||||
|       v-model="modelValue" | ||||
|       :type="type" | ||||
|       :placeholder="placeholder" | ||||
|       :disabled="disabled" | ||||
|       :class="customInputClass" | ||||
|       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" | ||||
|       @input="$emit('update:modelValue', $event.target.value)" | ||||
|       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out" | ||||
|       @input="handleInput" | ||||
|       @focus="handleFocus" | ||||
|       @blur="handleBlur" | ||||
|       @keydown.enter.prevent="onEnterPress" | ||||
|     /> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,51 +1,168 @@ | ||||
| <script setup> | ||||
| import { ref, computed, watch } from 'vue'; | ||||
| import { OnClickOutside } from '@vueuse/components'; | ||||
| import { vOnClickOutside } from '@vueuse/components'; | ||||
| import { email } from '@vuelidate/validators'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
|  | ||||
| import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   modelValue: { | ||||
|   placeholder: { type: String, default: '' }, | ||||
|   disabled: { type: Boolean, default: false }, | ||||
|   type: { type: String, default: 'text' }, | ||||
|   isLoading: { type: Boolean, default: false }, | ||||
|   menuItems: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|     validator: value => | ||||
|       value.every( | ||||
|         ({ action, value: tagValue, label }) => action && tagValue && label | ||||
|       ), | ||||
|   }, | ||||
|   placeholder: { | ||||
|   showDropdown: { type: Boolean, default: false }, | ||||
|   mode: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|     default: 'multiple', | ||||
|     validator: value => ['single', 'multiple'].includes(value), | ||||
|   }, | ||||
|   focusOnMount: { type: Boolean, default: false }, | ||||
|   allowCreate: { type: Boolean, default: false }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue']); | ||||
| const emit = defineEmits([ | ||||
|   'update:modelValue', | ||||
|   'input', | ||||
|   'blur', | ||||
|   'focus', | ||||
|   'onClickOutside', | ||||
|   'add', | ||||
|   'remove', | ||||
| ]); | ||||
|  | ||||
| const modelValue = defineModel({ | ||||
|   type: Array, | ||||
|   default: () => [], | ||||
| }); | ||||
|  | ||||
| const MODE = { | ||||
|   SINGLE: 'single', | ||||
|   MULTIPLE: 'multiple', | ||||
| }; | ||||
|  | ||||
| const tagInputRef = ref(null); | ||||
| const tags = ref(props.modelValue); | ||||
| const newTag = ref(''); | ||||
| const isFocused = ref(false); | ||||
| const isFocused = ref(true); | ||||
|  | ||||
| const showInput = computed(() => isFocused.value || tags.value.length === 0); | ||||
| const rules = computed(() => ({ | ||||
|   newTag: props.type === 'email' ? { email } : {}, | ||||
| })); | ||||
|  | ||||
| const addTag = () => { | ||||
|   if (newTag.value.trim()) { | ||||
|     tags.value.push(newTag.value.trim()); | ||||
|     newTag.value = ''; | ||||
|     emit('update:modelValue', tags.value); | ||||
| const v$ = useVuelidate(rules, { newTag }); | ||||
| const isNewTagInValidType = computed(() => v$.value.$invalid); | ||||
|  | ||||
| const showInput = computed(() => | ||||
|   props.mode === MODE.SINGLE | ||||
|     ? isFocused.value && !tags.value.length | ||||
|     : isFocused.value || !tags.value.length | ||||
| ); | ||||
|  | ||||
| const showDropdownMenu = computed(() => | ||||
|   props.mode === MODE.SINGLE && tags.value.length >= 1 | ||||
|     ? false | ||||
|     : props.showDropdown | ||||
| ); | ||||
|  | ||||
| const filteredMenuItems = computed(() => { | ||||
|   if (props.mode === MODE.SINGLE && tags.value.length >= 1) return []; | ||||
|  | ||||
|   const availableMenuItems = props.menuItems.filter( | ||||
|     item => !tags.value.includes(item.label) | ||||
|   ); | ||||
|  | ||||
|   // Only show typed value as suggestion if: | ||||
|   // 1. There's a value being typed | ||||
|   // 2. The value isn't already in the tags | ||||
|   // 3. There are no menu items available | ||||
|   const trimmedNewTag = newTag.value.trim(); | ||||
|   if ( | ||||
|     trimmedNewTag && | ||||
|     !tags.value.includes(trimmedNewTag) && | ||||
|     !availableMenuItems.length && | ||||
|     !props.isLoading | ||||
|   ) { | ||||
|     return [ | ||||
|       { | ||||
|         label: trimmedNewTag, | ||||
|         value: trimmedNewTag, | ||||
|         email: trimmedNewTag, | ||||
|         thumbnail: { name: trimmedNewTag, src: '' }, | ||||
|         action: 'create', | ||||
|       }, | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   return availableMenuItems; | ||||
| }); | ||||
|  | ||||
| const emitDataOnAdd = emailValue => { | ||||
|   const matchingMenuItem = props.menuItems.find( | ||||
|     item => item.email === emailValue | ||||
|   ); | ||||
|  | ||||
|   return matchingMenuItem | ||||
|     ? emit('add', { email: emailValue, ...matchingMenuItem }) | ||||
|     : emit('add', { value: emailValue, action: 'create' }); | ||||
| }; | ||||
|  | ||||
| const addTag = async () => { | ||||
|   const trimmedTag = newTag.value.trim(); | ||||
|   if (!trimmedTag) return; | ||||
|  | ||||
|   if (props.mode === MODE.SINGLE && tags.value.length >= 1) { | ||||
|     newTag.value = ''; | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   if (props.type === 'email' || props.allowCreate) { | ||||
|     if (!(await v$.value.$validate())) return; | ||||
|     emitDataOnAdd(trimmedTag); | ||||
|   } | ||||
|  | ||||
|   tags.value.push(trimmedTag); | ||||
|   newTag.value = ''; | ||||
|   modelValue.value = tags.value; | ||||
|   tagInputRef.value?.focus(); | ||||
| }; | ||||
|  | ||||
| const removeTag = index => { | ||||
|   tags.value.splice(index, 1); | ||||
|   emit('update:modelValue', tags.value); | ||||
|   modelValue.value = tags.value; | ||||
|   emit('remove'); | ||||
| }; | ||||
|  | ||||
| const handleDropdownAction = async ({ email: emailAddress, ...rest }) => { | ||||
|   if (props.mode === MODE.SINGLE && tags.value.length >= 1) return; | ||||
|  | ||||
|   if (props.type === 'email' && props.showDropdown) { | ||||
|     newTag.value = emailAddress; | ||||
|     if (!(await v$.value.$validate())) return; | ||||
|     emit('add', { email: emailAddress, ...rest }); | ||||
|   } | ||||
|  | ||||
|   tags.value.push(emailAddress); | ||||
|   newTag.value = ''; | ||||
|   modelValue.value = tags.value; | ||||
|   tagInputRef.value?.focus(); | ||||
| }; | ||||
|  | ||||
| const handleFocus = () => { | ||||
|   emit('focus'); | ||||
|   tagInputRef.value?.focus(); | ||||
|   isFocused.value = true; | ||||
| }; | ||||
|  | ||||
| const handleClickOutside = () => { | ||||
|   if (tags.value.length > 0) { | ||||
|     isFocused.value = false; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleKeydown = event => { | ||||
|   if (event.key === ',') { | ||||
|     event.preventDefault(); | ||||
| @@ -53,17 +170,34 @@ const handleKeydown = event => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleClickOutside = () => { | ||||
|   if (tags.value.length) isFocused.value = false; | ||||
|   emit('onClickOutside'); | ||||
| }; | ||||
|  | ||||
| watch( | ||||
|   () => props.modelValue, | ||||
|   newValue => { | ||||
|     tags.value = newValue; | ||||
|   } | ||||
| ); | ||||
|  | ||||
| watch( | ||||
|   () => newTag.value, | ||||
|   async newValue => { | ||||
|     if (props.type === 'email' && newValue.trim()?.length > 2) { | ||||
|       await v$.value.$validate(); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| const handleInput = e => emit('input', e); | ||||
| const handleBlur = e => emit('blur', e); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <OnClickOutside @trigger="handleClickOutside"> | ||||
|   <div | ||||
|     v-on-click-outside="() => handleClickOutside()" | ||||
|     class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none" | ||||
|     tabindex="0" | ||||
|     @focus="handleFocus" | ||||
| @@ -74,22 +208,38 @@ watch( | ||||
|       :key="index" | ||||
|       class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2" | ||||
|     > | ||||
|         <span class="flex-grow min-w-0 text-sm truncate text-n-slate-12"> | ||||
|           {{ tag }} | ||||
|         </span> | ||||
|       <span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{ | ||||
|         tag | ||||
|       }}</span> | ||||
|       <span | ||||
|         class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11" | ||||
|         @click.stop="removeTag(index)" | ||||
|       /> | ||||
|     </div> | ||||
|     <div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"> | ||||
|       <InlineInput | ||||
|         v-if="showInput" | ||||
|         ref="tagInputRef" | ||||
|         v-model="newTag" | ||||
|         :placeholder="placeholder" | ||||
|         custom-input-class="flex-grow" | ||||
|         :type="type" | ||||
|         :disabled="disabled" | ||||
|         class="w-full" | ||||
|         :focus-on-mount="focusOnMount" | ||||
|         :custom-input-class="`w-full ${isNewTagInValidType ? '!text-n-ruby-9 dark:!text-n-ruby-9' : ''}`" | ||||
|         @enter-press="addTag" | ||||
|         @focus="handleFocus" | ||||
|         @input="handleInput" | ||||
|         @blur="handleBlur" | ||||
|         @keydown="handleKeydown" | ||||
|       /> | ||||
|       <DropdownMenu | ||||
|         v-if="showDropdownMenu" | ||||
|         :menu-items="filteredMenuItems" | ||||
|         :is-searching="isLoading" | ||||
|         class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5" | ||||
|         @action="handleDropdownAction" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
|   </OnClickOutside> | ||||
| </template> | ||||
|   | ||||
| @@ -179,7 +179,7 @@ onMounted(() => { | ||||
|         }" | ||||
|         :disabled="disabled" | ||||
|         rows="1" | ||||
|         class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" | ||||
|         class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" | ||||
|         @input="handleInput" | ||||
|         @focus="handleFocus" | ||||
|         @blur="handleBlur" | ||||
|   | ||||
| @@ -653,5 +653,53 @@ | ||||
|       "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", | ||||
|       "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   "COMPOSE_NEW_CONVERSATION": { | ||||
|     "CONTACT_SEARCH": { | ||||
|       "ERROR_MESSAGE": "We couldn’t complete the search. Please try again." | ||||
|     }, | ||||
|     "FORM": { | ||||
|       "GO_TO_CONVERSATION": "View", | ||||
|       "SUCCESS_MESSAGE": "The message was sent successfully!", | ||||
|       "ERROR_MESSAGE": "An error occurred while creating the conversation. Please try again later.", | ||||
|       "NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.", | ||||
|       "CONTACT_SELECTOR": { | ||||
|         "LABEL": "To:", | ||||
|         "TAG_INPUT_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||
|         "CONTACT_CREATING": "Creating contact..." | ||||
|       }, | ||||
|       "INBOX_SELECTOR": { | ||||
|         "LABEL": "Via:", | ||||
|         "BUTTON": "Show inboxes" | ||||
|       }, | ||||
|       "EMAIL_OPTIONS": { | ||||
|         "SUBJECT_LABEL": "Subject :", | ||||
|         "SUBJECT_PLACEHOLDER": "Enter your email subject here", | ||||
|         "CC_LABEL": "Cc:", | ||||
|         "CC_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||
|         "BCC_LABEL": "Bcc:", | ||||
|         "BCC_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||
|         "BCC_BUTTON": "Bcc" | ||||
|       }, | ||||
|       "MESSAGE_EDITOR": { | ||||
|         "PLACEHOLDER": "Write your message here..." | ||||
|       }, | ||||
|       "WHATSAPP_OPTIONS": { | ||||
|         "LABEL": "Select template", | ||||
|         "SEARCH_PLACEHOLDER": "Search templates", | ||||
|         "EMPTY_STATE": "No templates found", | ||||
|         "TEMPLATE_PARSER": { | ||||
|           "TEMPLATE_NAME": "WhatsApp template: {templateName}", | ||||
|           "VARIABLES": "Variables", | ||||
|           "BACK": "Go back", | ||||
|           "SEND_MESSAGE": "Send message" | ||||
|         } | ||||
|       }, | ||||
|       "ACTION_BUTTONS": { | ||||
|         "DISCARD": "Discard", | ||||
|         "SEND": "Send ({keyCode})" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import i18nMessages from 'dashboard/i18n'; | ||||
| import { createI18n } from 'vue-i18n'; | ||||
| import { vResizeObserver } from '@vueuse/components'; | ||||
| import store from 'dashboard/store'; | ||||
| import FloatingVue from 'floating-vue'; | ||||
| import VueDOMPurifyHTML from 'vue-dompurify-html'; | ||||
| import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js'; | ||||
| import { directive as onClickaway } from 'vue3-click-away'; | ||||
| @@ -17,6 +18,12 @@ const i18n = createI18n({ | ||||
| export const setupVue3 = defineSetupVue3(({ app }) => { | ||||
|   app.use(store); | ||||
|   app.use(i18n); | ||||
|   app.use(FloatingVue, { | ||||
|     instantMove: true, | ||||
|     arrowOverflow: false, | ||||
|     disposeTimeout: 5000000, | ||||
|   }); | ||||
|  | ||||
|   app.directive('resize', vResizeObserver); | ||||
|   app.use(VueDOMPurifyHTML, domPurifyConfig); | ||||
|   app.directive('on-clickaway', onClickaway); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese