feat: Add support for Whatsapp template messages in the UI (#4711)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed
2022-06-07 17:33:33 +05:30
committed by GitHub
co-authored by Pranav Raj S
parent 56f668db6b
commit bad24f97ab
22 changed files with 733 additions and 54 deletions
@@ -10,6 +10,7 @@ export const buildCreatePayload = ({
files,
ccEmails = '',
bccEmails = '',
templateParams,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -32,6 +33,7 @@ export const buildCreatePayload = ({
content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
template_params: templateParams,
};
}
return payload;
@@ -51,6 +53,7 @@ class MessageApi extends ApiClient {
files,
ccEmails = '',
bccEmails = '',
templateParams,
}) {
return axios({
method: 'post',
@@ -63,6 +66,7 @@ class MessageApi extends ApiClient {
files,
ccEmails,
bccEmails,
templateParams,
}),
});
}
@@ -98,4 +98,7 @@ export default {
width: 48rem;
}
}
.modal-big {
width: 60%;
}
</style>
@@ -79,6 +79,16 @@
:title="signatureToggleTooltip"
@click="toggleMessageSignature"
/>
<woot-button
v-if="showWhatsappTemplatesButton"
v-tooltip.top-end="'Whatsapp Templates'"
icon="whatsapp"
color-scheme="secondary"
variant="smooth"
size="small"
:title="'Whatsapp Templates'"
@click="$emit('selectWhatsappTemplate')"
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
@@ -218,6 +228,10 @@ export default {
type: Boolean,
default: true,
},
hasWhatsappTemplates: {
type: Boolean,
default: false,
},
},
computed: {
isNote() {
@@ -261,6 +275,9 @@ export default {
showMessageSignatureButton() {
return !this.isPrivate && this.isAnEmailChannel;
},
showWhatsappTemplatesButton() {
return !this.isOnPrivateNote && this.hasWhatsappTemplates;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
return isEnabled;
@@ -101,7 +101,7 @@
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:show-emoji-picker="showEmojiPicker"
:on-send="sendMessage"
:on-send="onSendReply"
:is-send-disabled="isReplyButtonDisabled"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
@@ -112,7 +112,16 @@
:enable-rich-editor="isRichEditorEnabled"
:enter-to-send-enabled="enterToSendEnabled"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
@toggleEnterToSend="toggleEnterToSend"
@selectWhatsappTemplate="openWhatsappTemplateModal"
/>
<whatsapp-templates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@close="hideWhatsappTemplatesModal"
@on-send="onSendWhatsAppReply"
@cancel="hideWhatsappTemplatesModal"
/>
</div>
</template>
@@ -137,7 +146,7 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import {
isEscape,
isEnter,
@@ -162,6 +171,7 @@ export default {
WootMessageEditor,
WootAudioRecorder,
Banner,
WhatsappTemplates,
},
mixins: [
clickaway,
@@ -201,6 +211,7 @@ export default {
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
showWhatsAppTemplatesModal: false,
};
},
computed: {
@@ -212,7 +223,6 @@ export default {
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
}),
showRichContentEditor() {
if (this.isOnPrivateNote) {
return true;
@@ -256,7 +266,9 @@ export default {
return false;
},
hasWhatsappTemplates() {
return !!this.inbox.message_templates;
},
enterToSendEnabled() {
return !!this.uiSettings.enter_to_send_enabled;
},
@@ -484,7 +496,7 @@ export default {
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
if (shouldSendMessage) {
e.preventDefault();
this.sendMessage();
this.onSendReply();
}
} else if (hasPressedCommandPlusKKey(e)) {
this.openCommandBar();
@@ -497,6 +509,12 @@ export default {
toggleEnterToSend(enterToSendEnabled) {
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
},
openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true;
},
hideWhatsappTemplatesModal() {
this.showWhatsAppTemplatesModal = false;
},
onClickSelfAssign() {
const {
account_id,
@@ -520,7 +538,7 @@ export default {
};
this.assignedAgent = selfAssign;
},
async sendMessage() {
async onSendReply() {
if (this.isReplyButtonDisabled) {
return;
}
@@ -531,22 +549,31 @@ export default {
}
const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage();
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
messagePayload
);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
} catch (error) {
const errorMessage =
error?.response?.data?.error ||
this.$t('CONVERSATION.MESSAGE_ERROR');
this.showAlert(errorMessage);
}
this.sendMessage(messagePayload);
this.hideEmojiPicker();
this.$emit('update:popoutReplyBox', false);
}
},
async sendMessage(messagePayload) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
messagePayload
);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
this.showAlert(errorMessage);
}
},
async onSendWhatsAppReply(messagePayload) {
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
});
this.hideWhatsappTemplatesModal();
},
replaceText(message) {
setTimeout(() => {
this.message = message;
@@ -0,0 +1,76 @@
<template>
<woot-modal :show.sync="show" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('WHATSAPP_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="row modal-content">
<templates-picker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@onSelect="pickTemplate"
/>
<template-parser
v-else
:template="selectedWaTemplate"
@resetTemplate="onResetTemplate"
@sendMessage="onSendMessage"
/>
</div>
</woot-modal>
</template>
<script>
import TemplatesPicker from './TemplatesPicker.vue';
import TemplateParser from './TemplateParser.vue';
export default {
components: {
TemplatesPicker,
TemplateParser,
},
props: {
inboxId: {
type: Number,
default: undefined,
},
show: {
type: Boolean,
default: true,
},
},
data() {
return {
selectedWaTemplate: null,
};
},
computed: {
modalHeaderContent() {
return this.selectedWaTemplate
? this.$t('WHATSAPP_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: this.selectedWaTemplate.name,
})
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
},
},
methods: {
pickTemplate(template) {
this.selectedWaTemplate = template;
},
onResetTemplate() {
this.selectedWaTemplate = null;
},
onSendMessage(message) {
this.$emit('on-send', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<style scoped>
.modal-content {
padding: 2.5rem 3.2rem;
}
</style>
@@ -0,0 +1,183 @@
<template>
<div class="medium-12 columns">
<textarea
v-model="processedString"
rows="4"
readonly
class="template-input"
></textarea>
<div>
<div class="template__variables-container">
<p class="variables-label">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="template__variable-item"
>
<span class="variable-label">
{{ key }}
</span>
<woot-input
v-model="processedParams[key]"
type="text"
class="variable-input"
:styles="{ marginBottom: 0 }"
/>
</div>
<p v-if="showRequiredMessage" class="error">
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
</div>
<footer>
<woot-button variant="smooth" @click="$emit('resetTemplate')">
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
</woot-button>
<woot-button @click="sendMessage">
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
</woot-button>
</footer>
</div>
</template>
<script>
import { required } from 'vuelidate/lib/validators';
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
export default {
props: {
template: {
type: Object,
default: () => {},
},
},
validations: {
processedParams: {
required,
allKeysRequired,
},
},
data() {
return {
message: this.template.message,
processedParams: {},
showRequiredMessage: false,
};
},
computed: {
variables() {
const variables = this.templateString.match(/{{([^}]+)}}/g);
return variables;
},
templateString() {
return this.template.components.find(
component => component.type === 'BODY'
).text;
},
processedString() {
return this.templateString.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = this.processVariable(variable);
return this.processedParams[variableKey] || `{{${variable}}}`;
});
},
},
mounted() {
this.generateVariables();
},
methods: {
sendMessage() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showRequiredMessage = true;
return;
}
const message = {
message: this.processedString,
templateParams: {
name: this.template.name,
category: this.template.category,
language: this.template.language,
namespace: this.template.namespace,
processed_params: this.processedParams,
},
};
this.$emit('sendMessage', message);
},
processVariable(str) {
return str.replace(/{{|}}/g, '');
},
generateVariables() {
const templateString = this.template.components.find(
component => component.type === 'BODY'
).text;
const variables = templateString.match(/{{([^}]+)}}/g).map(variable => {
return this.processVariable(variable);
});
this.processedParams = variables.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
},
},
};
</script>
<style scoped lang="scss">
.template__variables-container {
padding: var(--space-one);
}
.variables-label {
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-one);
}
.template__variable-item {
align-items: center;
display: flex;
margin-bottom: var(--space-one);
.label {
font-size: var(--font-size-mini);
}
.variable-input {
flex: 1;
font-size: var(--font-size-small);
margin-left: var(--space-one);
}
.variable-label {
background-color: var(--s-75);
border-radius: var(--border-radius-normal);
display: inline-block;
font-size: var(--font-size-mini);
padding: var(--space-one) var(--space-medium);
}
}
footer {
display: flex;
justify-content: flex-end;
button {
margin-left: var(--space-one);
}
}
.error {
background-color: var(--r-100);
border-radius: var(--border-radius-normal);
color: var(--r-800);
padding: var(--space-one);
text-align: center;
}
.template-input {
background-color: var(--s-25);
}
</style>
@@ -0,0 +1,163 @@
<template>
<div class="medium-12 columns">
<div class="templates__list-search">
<fluent-icon icon="search" class="search-icon" size="16" />
<input
ref="search"
v-model="query"
type="search"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="templates__search-input"
/>
</div>
<div class="template__list-container">
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="template__list-item"
@click="$emit('onSelect', template)"
>
<div>
<div class="flex-between">
<p class="label-title">
{{ template.name }}
</p>
<span class="label-lang label">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }}
</span>
</div>
<div>
<p class="strong">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
</div>
<div class="label-category">
<p class="strong">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p>{{ template.category }}</p>
</div>
</div>
</button>
<hr v-if="i != filteredTemplateMessages.length - 1" :key="`hr-${i}`" />
</div>
<div v-if="!filteredTemplateMessages.length">
<p>
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
inboxId: {
type: Number,
default: undefined,
},
},
data() {
return {
query: '',
};
},
computed: {
whatsAppTemplateMessages() {
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId);
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
template.name.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
},
},
};
</script>
<style scoped lang="scss">
.flex-between {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-one);
}
.templates__list-search {
align-items: center;
background-color: var(--s-25);
border-radius: var(--border-radius-medium);
border: 1px solid var(--s-100);
display: flex;
margin-bottom: var(--space-one);
padding: 0 var(--space-one);
.search-icon {
color: var(--s-400);
}
.templates__search-input {
background-color: transparent;
border: var(--space-large);
font-size: var(--font-size-mini);
height: unset;
margin: var(--space-zero);
}
}
.template__list-container {
background-color: var(--s-25);
border-radius: var(--border-radius-medium);
max-height: 30rem;
overflow-y: auto;
padding: var(--space-one);
.template__list-item {
border-radius: var(--border-radius-medium);
cursor: pointer;
display: block;
padding: var(--space-one);
text-align: left;
width: 100%;
&:hover {
background-color: var(--w-50);
}
.label-title {
font-size: var(--font-size-small);
}
.label-category {
margin-top: var(--space-two);
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
}
}
.label-body {
font-family: monospace;
}
}
}
.strong {
font-size: var(--font-size-mini);
font-weight: var(--font-weight-bold);
}
hr {
border-bottom: 1px solid var(--s-100);
margin: var(--space-one) auto;
max-width: 95%;
}
</style>
@@ -7,7 +7,7 @@
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
icon="checkmark"
size="16"
size="14"
/>
</span>
<fluent-icon
@@ -165,7 +165,11 @@ export default {
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
},
showSentIndicator() {
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
return (
this.isOutgoing &&
this.sourceId &&
(this.isAnEmailChannel || this.isAWhatsappChannel)
);
},
},
methods: {
@@ -6,6 +6,7 @@
:type="type"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@input="onChange"
@blur="onBlur"
/>
@@ -47,6 +48,10 @@ export default {
type: Boolean,
deafaut: false,
},
styles: {
type: Object,
default: () => {},
},
},
methods: {
onChange(e) {
@@ -21,6 +21,7 @@ import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _teamsSettings } from './teamsSettings.json';
import { default as _whatsappTemplates } from './whatsappTemplates.json';
import { default as _bulkActions } from './bulkActions.json';
export default {
@@ -47,5 +48,6 @@ export default {
..._settings,
..._signup,
..._teamsSettings,
..._whatsappTemplates,
..._bulkActions,
};
@@ -0,0 +1,25 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process %{templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter %{variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
}
}
@@ -1,12 +1,12 @@
<template>
<form class="conversation--form" @submit.prevent="handleSubmit">
<form class="conversation--form" @submit.prevent="onFormSubmit">
<div v-if="showNoInboxAlert" class="callout warning">
<p>
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
</p>
</div>
<div v-else>
<div class="row">
<div class="row gutter-small">
<div class="columns">
<label :class="{ error: $v.targetInbox.$error }">
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
@@ -88,6 +88,12 @@
</label>
</label>
</div>
<whatsapp-templates
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
@@ -104,7 +110,7 @@
</div>
</div>
</div>
<div class="modal-footer">
<div v-if="!hasWhatsappTemplates" class="modal-footer">
<button class="button clear" @click.prevent="onCancel">
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
</button>
@@ -121,7 +127,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import WhatsappTemplates from './WhatsappTemplates.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
@@ -133,6 +139,7 @@ export default {
WootMessageEditor,
ReplyEmailHead,
CannedResponse,
WhatsappTemplates,
},
mixins: [alertMixin],
props: {
@@ -155,6 +162,7 @@ export default {
selectedInbox: '',
bccEmails: '',
ccEmails: '',
whatsappTemplateSelected: false,
};
},
validations: {
@@ -174,7 +182,7 @@ export default {
conversationsUiFlags: 'contactConversations/getUIFlags',
currentUser: 'getCurrentUser',
}),
getNewConversation() {
emailMessagePayload() {
const payload = {
inboxId: this.targetInbox.inbox.id,
sourceId: this.targetInbox.source_id,
@@ -194,7 +202,7 @@ export default {
},
targetInbox: {
get() {
return this.selectedInbox || '';
return this.selectedInbox || {};
},
set(value) {
this.selectedInbox = value;
@@ -221,6 +229,9 @@ export default {
this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB
);
},
hasWhatsappTemplates() {
return !!this.selectedInbox.inbox?.message_templates;
},
},
watch: {
message(value) {
@@ -243,13 +254,30 @@ export default {
onSuccess() {
this.$emit('success');
},
async handleSubmit() {
replaceTextWithCannedResponse(message) {
setTimeout(() => {
this.message = message;
}, 50);
},
prepareWhatsAppMessagePayload({ message: content, templateParams }) {
const payload = {
inboxId: this.targetInbox.inbox.id,
sourceId: this.targetInbox.source_id,
contactId: this.contact.id,
message: { content, templateParams },
assigneeId: this.currentUser.id,
};
return payload;
},
onFormSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.createConversation(this.emailMessagePayload);
},
async createConversation(payload) {
try {
const payload = this.getNewConversation;
const data = await this.onSubmit(payload);
const action = {
type: 'link',
@@ -269,10 +297,13 @@ export default {
}
}
},
replaceTextWithCannedResponse(message) {
setTimeout(() => {
this.message = message;
}, 50);
toggleWaTemplate(val) {
this.whatsappTemplateSelected = val;
},
async onSendWhatsAppReply(messagePayload) {
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation(payload);
},
},
};
@@ -321,4 +352,7 @@ export default {
display: flex;
justify-content: flex-end;
}
.row.gutter-small {
gap: var(--space-small);
}
</style>
@@ -0,0 +1,59 @@
<template>
<div class="row">
<templates-picker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@onSelect="pickTemplate"
/>
<template-parser
v-else
:template="selectedWaTemplate"
@resetTemplate="onResetTemplate"
@sendMessage="onSendMessage"
/>
</div>
</template>
<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,
},
show: {
type: Boolean,
default: true,
},
},
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('on-send', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<style></style>
@@ -426,7 +426,7 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxName() {
if (this.isATwilioSMSChannel || this.isATwilioWhatsappChannel) {
if (this.isATwilioSMSChannel || this.isAWhatsappChannel) {
return `${this.inbox.name} (${this.inbox.phone_number})`;
}
if (this.isAnEmailChannel) {
@@ -47,6 +47,20 @@ export const getters = {
getInboxes($state) {
return $state.records;
},
getWhatsAppTemplates: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
// filtering out the whatsapp templates with media
if (inbox.message_templates) {
return inbox.message_templates.filter(template => {
return !template.components.some(
i => i.format === 'IMAGE' || i.format === 'VIDEO'
);
});
}
return [];
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
const {