feat: Use new compose conversation in conversation sidebar (#11085)

# Pull Request Template

## Description

This PR includes the implementation of the new Compose Conversation form
in the conversation sidebar, replacing the old one.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/4312e20a63714eb892d7b5cd0dcda893?sid=9bd5254e-2b1f-462c-b2c1-a3048a111683

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Sivin Varghese
2025-03-18 15:09:10 +05:30
committed by GitHub
parent f67b20b203
commit 8291c84cc3
12 changed files with 113 additions and 893 deletions

View File

@@ -27,8 +27,14 @@ const props = defineProps({
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
@@ -61,6 +67,8 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (props.isModal) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
@@ -131,9 +139,14 @@ const clearSelectedContact = () => {
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
@@ -182,7 +195,15 @@ watch(
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!props.isModal) return;
handleClickOutside();
};
onMounted(() => resetContacts());
@@ -205,7 +226,7 @@ useKeyboardEvents(keyboardEvents);
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
@@ -218,29 +239,37 @@ useKeyboardEvents(keyboardEvents);
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
<div
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
isModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</div>
</template>

View File

@@ -232,4 +232,20 @@ useKeyboardEvents(keyboardEvents);
.emoji-dialog::before {
@apply hidden;
}
// The <label> tag inside the file-upload component overlaps the button due to its position.
// This causes the button's hover state to not work, as it's positioned below the label (z-index).
// Increasing the button's z-index would break the file upload functionality.
// This style ensures the label remains clickable while preserving the button's hover effect.
:deep() {
.file-uploads.file-uploads-html5 {
label {
@apply hover:cursor-pointer;
}
&:hover button {
@apply dark:bg-n-solid-2 bg-n-alpha-2;
}
}
}
</style>

View File

@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] 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"

View File

@@ -113,7 +113,8 @@ const handleInput = value => {
</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"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 min-h-7 min-w-0"
:class="!contactId ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
>
<span class="text-sm truncate text-n-slate-12">
{{
@@ -123,6 +124,7 @@ const handleInput = value => {
}}
</span>
<Button
v-if="!contactId"
variant="ghost"
icon="i-lucide-x"
color="slate"

View File

@@ -52,7 +52,7 @@ const targetInboxLabel = computed(() => {
</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"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}

View File

@@ -84,7 +84,7 @@ const handleSendMessage = template => {
/>
<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"
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-[21.875rem] 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" />

View File

@@ -106,7 +106,7 @@ onMounted(() => {
<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"
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{

View File

@@ -1,76 +0,0 @@
<script>
import {
getInboxClassByType,
getReadableInboxByType,
} from 'dashboard/helper/inbox';
export default {
components: {},
props: {
name: {
type: String,
default: '',
},
inboxIdentifier: {
type: String,
default: '',
},
channelType: {
type: String,
default: '',
},
},
computed: {
computedInboxIcon() {
if (!this.channelType) return 'chat';
const classByType = getInboxClassByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
computedInboxType() {
if (!this.channelType) return 'chat';
const classByType = getReadableInboxByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
},
};
</script>
<template>
<div class="flex items-center h-[2.375rem] min-w-0 py-0 px-1">
<span
class="inline-flex rounded mr-1 rtl:ml-1 rtl:mr-0 bg-slate-25 dark:bg-slate-700 p-0.5 items-center flex-shrink-0 justify-center w-6 h-6"
>
<fluent-icon
:icon="computedInboxIcon"
size="14"
class="text-slate-800 dark:text-slate-200"
/>
</span>
<div class="flex flex-col w-full min-w-0 ml-1 mr-1">
<h5 class="option__title">
{{ name }}
</h5>
<p
class="option__body overflow-hidden whitespace-nowrap text-ellipsis"
:title="inboxIdentifier"
>
{{ inboxIdentifier || computedInboxType }}
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.option__body {
@apply inline-block text-slate-600 dark:text-slate-200 leading-[1.3] min-w-0 m-0;
}
.option__title {
@apply leading-[1.1] text-xs m-0 text-slate-800 dark:text-slate-100;
}
</style>

View File

@@ -7,8 +7,8 @@ import ContactInfoRow from './ContactInfoRow.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import NewConversation from './NewConversation.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -25,8 +25,8 @@ export default {
ContactInfoRow,
EditContact,
Thumbnail,
ComposeConversation,
SocialIcons,
NewConversation,
ContactMergeModal,
},
props: {
@@ -49,7 +49,6 @@ export default {
data() {
return {
showEditModal: false,
showConversationModal: false,
showMergeModal: false,
showDeleteModal: false,
};
@@ -92,17 +91,29 @@ export default {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
emitter.emit(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.showConversationModal
);
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -113,7 +124,6 @@ export default {
},
closeDelete() {
this.showDeleteModal = false;
this.showConversationModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
@@ -250,14 +260,22 @@ export default {
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="toggleConversationModal"
/>
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@close="closeComposeConversationModal"
>
<template #trigger="{ toggle }">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="openComposeConversationModal(toggle)"
/>
</template>
</ComposeConversation>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -293,12 +311,6 @@ export default {
:contact="contact"
@cancel="toggleEditModal"
/>
<NewConversation
v-if="contact.id"
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
<ContactMergeModal
v-if="showMergeModal"
:primary-contact="contact"

View File

@@ -1,638 +0,0 @@
<script>
import { ref } from 'vue';
// constants & helpers
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox';
// store
import { mapGetters } from 'vuex';
// composables
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { required, requiredIf } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
// mixins
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import inboxMixin from 'shared/mixins/inboxMixin';
// components
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import FileUpload from 'vue-upload-component';
import WhatsappTemplates from './WhatsappTemplates.vue';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
export default {
components: {
Thumbnail,
WootMessageEditor,
ReplyEmailHead,
CannedResponse,
WhatsappTemplates,
InboxDropdownItem,
FileUpload,
AttachmentPreview,
MessageSignatureMissingAlert,
},
mixins: [inboxMixin, fileUploadMixin],
props: {
contact: {
type: Object,
default: () => ({}),
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } =
useUISettings();
const v$ = useVuelidate();
const uploadAttachment = ref(false);
return {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
v$,
uploadAttachment,
};
},
data() {
return {
name: '',
subject: '',
message: '',
showCannedResponseMenu: false,
cannedResponseSearchKey: '',
bccEmails: '',
ccEmails: '',
targetInbox: {},
whatsappTemplateSelected: false,
attachedFiles: [],
};
},
validations() {
return {
subject: {
required: requiredIf(this.isAnEmailInbox),
},
message: {
required,
},
targetInbox: {
required,
},
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
messageSignature: 'getMessageSignature',
inboxesList: 'inboxes/getInboxes',
}),
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToApply() {
return this.messageSignature;
},
newMessagePayload() {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
contactId: this.contact.id,
message: { content: this.message },
mailSubject: this.subject,
assigneeId: this.currentUser.id,
};
if (this.attachedFiles && this.attachedFiles.length) {
payload.files = [];
this.setAttachmentPayload(payload);
}
if (this.ccEmails) {
payload.message.cc_emails = this.ccEmails;
}
if (this.bccEmails) {
payload.message.bcc_emails = this.bccEmails;
}
return payload;
},
selectedInbox: {
get() {
const inboxList = this.contact.contact_inboxes || [];
const selectedContactInbox = inboxList.find(
inbox => inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id
);
if (!selectedContactInbox) {
return { inbox: {} };
}
// Find the matching inbox from the inboxesList
const matchingInbox =
this.inboxesList.find(
item => item.id === selectedContactInbox.inbox?.id
) || {};
// The entire inbox payload is not available in this object, so we need to patch it from the store
return {
...selectedContactInbox,
inbox: {
...matchingInbox,
...selectedContactInbox.inbox,
sourceId: selectedContactInbox.source_id || matchingInbox.sourceId,
},
};
},
set(value) {
this.targetInbox = value.inbox;
},
},
showNoInboxAlert() {
if (!this.contact.contact_inboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
},
isSignatureEnabledForInbox() {
return this.isAnEmailInbox && this.sendWithSignature;
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contact_inboxes || [];
if (!inboxList.length) return [];
return inboxList.map(inbox => {
const matchingInbox =
this.inboxesList.find(item => item.id === inbox.inbox?.id) || {};
// Create merged object with a clear property order
return {
...matchingInbox,
...inbox.inbox,
sourceId: inbox.source_id,
};
});
},
isAnEmailInbox() {
return (
this.selectedInbox &&
this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL
);
},
isAnWebWidgetInbox() {
return (
this.selectedInbox &&
this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB
);
},
isEmailOrWebWidgetInbox() {
return this.isAnEmailInbox || this.isAnWebWidgetInbox;
},
hasWhatsappTemplates() {
return !!this.selectedInbox.inbox?.message_templates;
},
hasAttachments() {
return this.attachedFiles.length;
},
inbox() {
return this.targetInbox;
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
},
watch: {
message(value) {
this.hasSlashCommand = value[0] === '/' && !this.isEmailOrWebWidgetInbox;
const hasNextWord = value.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.cannedResponseSearchKey = value.substring(1);
this.showCannedResponseMenu = true;
} else {
this.cannedResponseSearchKey = '';
this.showCannedResponseMenu = false;
}
},
targetInbox() {
this.setSignature();
},
},
mounted() {
this.setSignature();
},
methods: {
setSignature() {
if (this.messageSignature) {
if (this.isSignatureEnabledForInbox) {
this.message = appendSignature(this.message, this.signatureToApply);
} else {
this.message = removeSignature(this.message, this.signatureToApply);
}
}
},
setAttachmentPayload(payload) {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
payload.files.push(attachment.blobSignedId);
} else {
payload.files.push(attachment.resource.file);
}
});
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.contact.id,
resource: blob || file,
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
});
};
},
removeAttachment(attachments) {
this.attachedFiles = attachments;
},
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
replaceTextWithCannedResponse(message) {
this.message = message;
},
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
prepareWhatsAppMessagePayload({ message: content, templateParams }) {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
contactId: this.contact.id,
message: { content, template_params: templateParams },
assigneeId: this.currentUser.id,
};
return payload;
},
onFormSubmit() {
const isFromWhatsApp = false;
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.createConversation({
payload: this.newMessagePayload,
isFromWhatsApp,
});
},
async createConversation({ payload, isFromWhatsApp }) {
try {
const data = await this.onSubmit(payload, isFromWhatsApp);
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: this.$t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
this.onSuccess();
useAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
} catch (error) {
if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
}
}
},
toggleWaTemplate(val) {
this.whatsappTemplateSelected = val;
},
async onSendWhatsAppReply(messagePayload) {
const isFromWhatsApp = true;
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation({ payload, isFromWhatsApp });
},
inboxReadableIdentifier(inbox) {
return `${inbox.name} (${inbox.channel_type})`;
},
computedInboxSource(inbox) {
if (!inbox.channel_type) return '';
const classByType = getInboxSource(
inbox.channel_type,
inbox.phone_number,
inbox
);
return classByType;
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
this.setSignature();
},
},
};
</script>
<!-- eslint-disable vue/prefer-true-attribute-shorthand -->
<template>
<form class="w-full conversation--form" @submit.prevent="onFormSubmit">
<div
v-if="showNoInboxAlert"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-none text-sm border border-solid border-yellow-500 dark:border-yellow-700 bg-yellow-200/60 dark:bg-yellow-200/20 text-slate-700 dark:text-yellow-400"
>
<p class="mb-0">
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
</p>
</div>
<div v-else>
<div class="flex flex-row gap-2">
<div class="w-[50%]">
<label>
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
</label>
<div
class="multiselect-wrap--small"
:class="{ 'has-multi-select-error': v$.targetInbox.$error }"
>
<multiselect
v-model="targetInbox"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label=""
select-label=""
class="reset-base"
deselect-label=""
:max-height="160"
close-on-select
:options="[...inboxes]"
>
<template #singleLabel="{ option }">
<InboxDropdownItem
v-if="option.name"
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
<span v-else>
{{ $t('NEW_CONVERSATION.FORM.INBOX.PLACEHOLDER') }}
</span>
</template>
<template #option="{ option }">
<InboxDropdownItem
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
</template>
</multiselect>
</div>
<label :class="{ error: v$.targetInbox.$error }">
<span v-if="v$.targetInbox.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
</span>
</label>
</div>
<div class="w-[50%]">
<label>
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
<div
class="flex items-center h-[2.4735rem] rounded-sm py-1 px-2 bg-slate-25 dark:bg-slate-900 border border-solid border-slate-75 dark:border-slate-600"
>
<Thumbnail
:src="contact.thumbnail"
size="24px"
:username="contact.name"
:status="contact.availability_status"
/>
<h4
class="m-0 ml-2 mr-2 text-sm text-slate-700 dark:text-slate-100"
>
{{ contact.name }}
</h4>
</div>
</label>
</div>
</div>
<div v-if="isAnEmailInbox" class="w-full">
<div class="w-full">
<label :class="{ error: v$.subject.$error }">
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }}
<input
v-model="subject"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.SUBJECT.PLACEHOLDER')"
@input="v$.subject.$touch"
/>
<span v-if="v$.subject.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<div class="w-full">
<div class="relative">
<CannedResponse
v-if="showCannedResponseMenu && hasSlashCommand"
:search-key="cannedResponseSearchKey"
@replace="replaceTextWithCannedResponse"
/>
</div>
<div v-if="isEmailOrWebWidgetInbox">
<label>
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
</label>
<ReplyEmailHead
v-if="isAnEmailInbox"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
/>
<div class="editor-wrap">
<WootMessageEditor
v-model="message"
class="message-editor"
:class="{ editor_warning: v$.message.$error }"
enable-variables
:signature="signatureToApply"
allow-signature
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@toggle-canned-menu="toggleCannedMenu"
@blur="v$.message.$touch"
>
<template #footer>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !messageSignature"
class="!mx-0 mb-1"
/>
<div v-if="isAnEmailInbox" class="mt-px mb-3">
<woot-button
v-tooltip.top-end="signatureToggleTooltip"
icon="signature"
color-scheme="secondary"
variant="smooth"
size="small"
:title="signatureToggleTooltip"
@click.prevent="toggleMessageSignature"
/>
</div>
</template>
</WootMessageEditor>
<span v-if="v$.message.$error" class="editor-warning__message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</div>
</div>
<WhatsappTemplates
v-else-if="hasWhatsappTemplates"
:inbox-id="selectedInbox.inbox.id"
@on-select-template="toggleWaTemplate"
@on-send="onSendWhatsAppReply"
/>
<label v-else :class="{ error: v$.message.$error }">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<textarea
v-model="message"
class="min-h-[5rem]"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@input="v$.message.$touch"
/>
<span v-if="v$.message.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
<div v-if="isEmailOrWebWidgetInbox" class="flex flex-col">
<FileUpload
ref="uploadAttachment"
input-id="newConversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
multiple
:drop="true"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<woot-button
class-names="button--upload"
icon="attach"
emoji="📎"
color-scheme="secondary"
variant="smooth"
size="small"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.SELECT') }}
</woot-button>
<span
class="text-xs font-medium text-slate-500 ltr:ml-1 rtl:mr-1 dark:text-slate-400"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.HELP_TEXT') }}
</span>
</FileUpload>
<div
v-if="hasAttachments"
class="max-h-20 overflow-y-auto mb-4 mt-1.5"
>
<AttachmentPreview
class="[&>.preview-item]:dark:bg-slate-700 flex-row flex-wrap gap-x-3 gap-y-1"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!hasWhatsappTemplates"
class="flex flex-row justify-end w-full gap-2 px-0 py-2"
>
<button class="button clear" @click.prevent="onCancel">
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
</button>
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
</woot-button>
</div>
<transition v-if="isEmailOrWebWidgetInbox" name="modal-fade">
<div
v-show="uploadAttachment && uploadAttachment.dropActive"
class="absolute top-0 bottom-0 left-0 right-0 z-30 flex flex-col items-center justify-center w-full h-full gap-2 bg-white/80 dark:bg-slate-700/80"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-slate-600 dark:text-slate-200">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
</form>
</template>
<style scoped lang="scss">
.conversation--form {
@apply pt-4 px-8 pb-8;
}
.message-editor {
@apply px-3;
::v-deep {
.ProseMirror-menubar {
@apply rounded-tl-[4px];
}
}
}
.file-uploads {
@apply text-start;
}
.multiselect-wrap--small.has-multi-select-error {
::v-deep {
.multiselect__tags {
@apply border-red-500;
}
}
}
::v-deep {
.mention--box {
@apply left-0 m-auto right-0 top-auto h-fit;
}
}
</style>

View File

@@ -1,71 +0,0 @@
<script>
import ConversationForm from './ConversationForm.vue';
export default {
components: {
ConversationForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
watch: {
'contact.id'(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
},
mounted() {
const { id } = this.contact;
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(params, isFromWhatsApp) {
const data = await this.$store.dispatch('contactConversations/create', {
params,
isFromWhatsApp,
});
return data;
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onCancel">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('NEW_CONVERSATION.TITLE')"
:header-content="$t('NEW_CONVERSATION.DESC')"
/>
<ConversationForm
:contact="contact"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>

View File

@@ -1,54 +0,0 @@
<script>
import TemplatesPicker from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue';
import TemplateParser from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
export default {
components: {
TemplatesPicker,
TemplateParser,
},
props: {
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['pickTemplate', 'onSend', 'cancel'],
data() {
return {
selectedWaTemplate: null,
};
},
methods: {
pickTemplate(template) {
this.$emit('pickTemplate', true);
this.selectedWaTemplate = template;
},
onResetTemplate() {
this.$emit('pickTemplate', false);
this.selectedWaTemplate = null;
},
onSendMessage(message) {
this.$emit('onSend', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<template>
<div class="flex flex-wrap mx-0">
<TemplatesPicker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
/>
</div>
</template>