mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +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;
|
@apply m-0 !important;
|
||||||
|
|
||||||
&::before {
|
&::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]"
|
custom-label-class="min-w-[120px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between w-full gap-2 py-2">
|
<div class="flex justify-between w-full gap-3 py-2">
|
||||||
<label
|
<label
|
||||||
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
|
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,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
isSearching: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
const emit = defineEmits(['action']);
|
||||||
@@ -81,7 +85,6 @@ onMounted(() => {
|
|||||||
}"
|
}"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
@click="handleAction(item)"
|
@click="handleAction(item)"
|
||||||
@keydown.enter="handleAction(item)"
|
|
||||||
>
|
>
|
||||||
<slot name="thumbnail" :item="item">
|
<slot name="thumbnail" :item="item">
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -102,7 +105,11 @@ onMounted(() => {
|
|||||||
v-if="filteredMenuItems.length === 0"
|
v-if="filteredMenuItems.length === 0"
|
||||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { ref, onMounted, nextTick } from 'vue';
|
||||||
modelValue: {
|
|
||||||
type: [String, Number],
|
const props = defineProps({
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'text',
|
default: 'text',
|
||||||
@@ -32,13 +30,49 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
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 = () => {
|
const onEnterPress = () => {
|
||||||
emit('enterPress');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -49,7 +83,7 @@ const onEnterPress = () => {
|
|||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="id"
|
||||||
:class="customLabelClass"
|
: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 }}
|
||||||
</label>
|
</label>
|
||||||
@@ -57,13 +91,16 @@ const onEnterPress = () => {
|
|||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
:value="modelValue"
|
ref="inlineInputRef"
|
||||||
|
v-model="modelValue"
|
||||||
:type="type"
|
:type="type"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="customInputClass"
|
: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"
|
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="$emit('update:modelValue', $event.target.value)"
|
@input="handleInput"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@blur="handleBlur"
|
||||||
@keydown.enter.prevent="onEnterPress"
|
@keydown.enter.prevent="onEnterPress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,168 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
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 InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
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,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
validator: value =>
|
||||||
|
value.every(
|
||||||
|
({ action, value: tagValue, label }) => action && tagValue && label
|
||||||
|
),
|
||||||
},
|
},
|
||||||
placeholder: {
|
showDropdown: { type: Boolean, default: false },
|
||||||
|
mode: {
|
||||||
type: String,
|
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 tags = ref(props.modelValue);
|
||||||
const newTag = ref('');
|
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 = () => {
|
const v$ = useVuelidate(rules, { newTag });
|
||||||
if (newTag.value.trim()) {
|
const isNewTagInValidType = computed(() => v$.value.$invalid);
|
||||||
tags.value.push(newTag.value.trim());
|
|
||||||
newTag.value = '';
|
const showInput = computed(() =>
|
||||||
emit('update:modelValue', tags.value);
|
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 => {
|
const removeTag = index => {
|
||||||
tags.value.splice(index, 1);
|
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 = () => {
|
const handleFocus = () => {
|
||||||
|
emit('focus');
|
||||||
|
tagInputRef.value?.focus();
|
||||||
isFocused.value = true;
|
isFocused.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
if (tags.value.length > 0) {
|
|
||||||
isFocused.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeydown = event => {
|
const handleKeydown = event => {
|
||||||
if (event.key === ',') {
|
if (event.key === ',') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -53,43 +170,76 @@ const handleKeydown = event => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
if (tags.value.length) isFocused.value = false;
|
||||||
|
emit('onClickOutside');
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
newValue => {
|
newValue => {
|
||||||
tags.value = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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"
|
||||||
|
@click="handleFocus"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
|
v-for="(tag, index) in tags"
|
||||||
tabindex="0"
|
:key="index"
|
||||||
@focus="handleFocus"
|
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
|
||||||
@click="handleFocus"
|
|
||||||
>
|
>
|
||||||
<div
|
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{
|
||||||
v-for="(tag, index) in tags"
|
tag
|
||||||
:key="index"
|
}}</span>
|
||||||
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-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
|
||||||
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
|
@click.stop="removeTag(index)"
|
||||||
{{ tag }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
|
|
||||||
@click.stop="removeTag(index)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<InlineInput
|
|
||||||
v-if="showInput"
|
|
||||||
v-model="newTag"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
custom-input-class="flex-grow"
|
|
||||||
@enter-press="addTag"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</OnClickOutside>
|
<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"
|
||||||
|
: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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ onMounted(() => {
|
|||||||
}"
|
}"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
rows="1"
|
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"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
@@ -653,5 +653,53 @@
|
|||||||
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
|
"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 { createI18n } from 'vue-i18n';
|
||||||
import { vResizeObserver } from '@vueuse/components';
|
import { vResizeObserver } from '@vueuse/components';
|
||||||
import store from 'dashboard/store';
|
import store from 'dashboard/store';
|
||||||
|
import FloatingVue from 'floating-vue';
|
||||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||||
import { directive as onClickaway } from 'vue3-click-away';
|
import { directive as onClickaway } from 'vue3-click-away';
|
||||||
@@ -17,6 +18,12 @@ const i18n = createI18n({
|
|||||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||||
app.use(store);
|
app.use(store);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
|
app.use(FloatingVue, {
|
||||||
|
instantMove: true,
|
||||||
|
arrowOverflow: false,
|
||||||
|
disposeTimeout: 5000000,
|
||||||
|
});
|
||||||
|
|
||||||
app.directive('resize', vResizeObserver);
|
app.directive('resize', vResizeObserver);
|
||||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||||
app.directive('on-clickaway', onClickaway);
|
app.directive('on-clickaway', onClickaway);
|
||||||
|
|||||||
Reference in New Issue
Block a user