diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 6093bf6b0..5ee6a1e8b 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -6,10 +6,15 @@
@click="insertMentionNode"
/>
+
@@ -31,6 +36,7 @@ import {
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
+import VariableList from '../conversation/VariableList';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -43,6 +49,7 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
+import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
const createState = (content, placeholder, plugins = []) => {
@@ -58,7 +65,7 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
- components: { TagAgents, CannedResponse },
+ components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin],
props: {
value: { type: String, default: '' },
@@ -68,13 +75,18 @@ export default {
enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false },
updateSelectionWith: { type: String, default: '' },
+ enableVariables: { type: Boolean, default: false },
+ enableCannedResponses: { type: Boolean, default: true },
+ variables: { type: Object, default: () => ({}) },
},
data() {
return {
showUserMentions: false,
showCannedMenu: false,
+ showVariables: false,
mentionSearchKey: '',
cannedSearchTerm: '',
+ variableSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -84,6 +96,14 @@ export default {
contentFromEditor() {
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
},
+ shouldShowVariables() {
+ return this.enableVariables && this.showVariables && !this.isPrivate;
+ },
+ shouldShowCannedResponses() {
+ return (
+ this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
+ );
+ },
plugins() {
if (!this.enableSuggestions) {
return [];
@@ -103,6 +123,7 @@ export default {
this.range = args.range;
this.mentionSearchKey = args.text.replace('@', '');
+
return false;
},
onExit: () => {
@@ -142,6 +163,34 @@ export default {
return event.keyCode === 13 && this.showCannedMenu;
},
}),
+ suggestionsPlugin({
+ matcher: triggerCharacters('{{'),
+ suggestionClass: '',
+ onEnter: args => {
+ if (this.isPrivate) {
+ return false;
+ }
+ this.showVariables = true;
+ this.range = args.range;
+ this.editorView = args.view;
+ return false;
+ },
+ onChange: args => {
+ this.editorView = args.view;
+ this.range = args.range;
+
+ this.variableSearchTerm = args.text.replace('{{', '');
+ return false;
+ },
+ onExit: () => {
+ this.variableSearchTerm = '';
+ this.showVariables = false;
+ return false;
+ },
+ onKeyDown: ({ event }) => {
+ return event.keyCode === 13 && this.showVariables;
+ },
+ }),
];
},
},
@@ -152,6 +201,9 @@ export default {
showCannedMenu(updatedValue) {
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
},
+ showVariables(updatedValue) {
+ this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
+ },
value(newValue = '') {
if (newValue !== this.contentFromEditor) {
this.reloadState();
@@ -267,19 +319,22 @@ export default {
return false;
},
-
insertCannedResponse(cannedItem) {
+ const updatedMessage = replaceVariablesInMessage({
+ message: cannedItem,
+ variables: this.variables,
+ });
if (!this.editorView) {
return null;
}
let from = this.range.from - 1;
let node = new MessageMarkdownTransformer(messageSchema).parse(
- cannedItem
+ updatedMessage
);
- if (node.textContent === cannedItem) {
- node = this.editorView.state.schema.text(cannedItem);
+ if (node.textContent === updatedMessage) {
+ node = this.editorView.state.schema.text(updatedMessage);
from = this.range.from;
}
@@ -296,6 +351,29 @@ export default {
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
+ insertVariable(variable) {
+ if (!this.editorView) {
+ return null;
+ }
+ let node = this.editorView.state.schema.text(`{{${variable}}}`);
+ const from = this.range.from;
+
+ const tr = this.editorView.state.tr.replaceWith(
+ from,
+ this.range.to,
+ node
+ );
+
+ this.state = this.editorView.state.apply(tr);
+ this.emitOnChange();
+
+ // The `{{ }}` are added to the message, but the cursor is placed
+ // and onExit of suggestionsPlugin is not called. So we need to manually hide
+ this.showVariables = false;
+ this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
+ tr.scrollIntoView();
+ return false;
+ },
emitOnChange() {
this.editorView.updateState(this.state);
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 56937b3ec..3f4bd012c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -63,12 +63,15 @@
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
+ :enable-variables="true"
+ :variables="messageVariables"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
+ @toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
@@ -126,6 +129,12 @@
@on-send="onSendWhatsAppReply"
@cancel="hideWhatsappTemplatesModal"
/>
+
+
@@ -152,7 +161,11 @@ import {
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
-
+import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
+import {
+ getMessageVariables,
+ getUndefinedVariablesInMessage,
+} from 'dashboard/helper/messageHelper';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
@@ -208,7 +221,6 @@ export default {
message: '',
isFocused: false,
showEmojiPicker: false,
- showMentions: false,
attachedFiles: [],
isRecordingAudio: false,
recordingAudioState: '',
@@ -216,13 +228,16 @@ export default {
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
- hasUserMention: false,
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
updateEditorSelectionWith: '',
+ undefinedVariableMessage: '',
+ showMentions: false,
+ showCannedMenu: false,
+ showVariablesMenu: false,
};
},
computed: {
@@ -469,6 +484,12 @@ export default {
}
return AUDIO_FORMATS.OGG;
},
+ messageVariables() {
+ const variables = getMessageVariables({
+ conversation: this.currentChat,
+ });
+ return variables;
+ },
},
watch: {
currentChat(conversation) {
@@ -610,8 +631,9 @@ export default {
},
isAValidEvent(selectedKey) {
return (
- !this.hasUserMention &&
+ !this.showMentions &&
!this.showCannedMenu &&
+ !this.showVariablesMenu &&
this.isFocused &&
isEditorHotKeyEnabled(this.uiSettings, selectedKey)
);
@@ -630,11 +652,14 @@ export default {
});
},
toggleUserMention(currentMentionState) {
- this.hasUserMention = currentMentionState;
+ this.showMentions = currentMentionState;
},
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
+ toggleVariablesMenu(value) {
+ this.showVariablesMenu = value;
+ },
openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true;
},
@@ -664,7 +689,7 @@ export default {
};
this.assignedAgent = selfAssign;
},
- async onSendReply() {
+ confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
return;
}
@@ -685,6 +710,30 @@ export default {
this.$emit('update:popoutReplyBox', false);
}
},
+ async onSendReply() {
+ const undefinedVariables = getUndefinedVariablesInMessage({
+ message: this.message,
+ variables: this.messageVariables,
+ });
+ if (undefinedVariables.length > 0) {
+ const undefinedVariablesCount =
+ undefinedVariables.length > 1 ? undefinedVariables.length : 1;
+ this.undefinedVariableMessage = this.$t(
+ 'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE',
+ {
+ undefinedVariablesCount,
+ undefinedVariables: undefinedVariables.join(', '),
+ }
+ );
+
+ const ok = await this.$refs.confirmDialog.showConfirmation();
+ if (ok) {
+ this.confirmOnSendReply();
+ }
+ } else {
+ this.confirmOnSendReply();
+ }
+ },
async sendMessage(messagePayload) {
try {
await this.$store.dispatch(
@@ -707,9 +756,13 @@ export default {
this.hideWhatsappTemplatesModal();
},
replaceText(message) {
+ const updatedMessage = replaceVariablesInMessage({
+ message,
+ variables: this.messageVariables,
+ });
setTimeout(() => {
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
- this.message = message;
+ this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
diff --git a/app/javascript/dashboard/components/widgets/conversation/VariableList.vue b/app/javascript/dashboard/components/widgets/conversation/VariableList.vue
new file mode 100644
index 000000000..91803327d
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/VariableList.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
index 2b8085e97..b55b5f2c2 100644
--- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js
+++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
@@ -3,6 +3,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SENT_MESSAGE: 'Sent a message',
SENT_PRIVATE_NOTE: 'Sent a private note',
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
+ INSERTED_A_VARIABLE: 'Inserted a variable',
USED_MENTIONS: 'Used mentions',
APPLY_FILTER: 'Applied filters in the conversation list',
diff --git a/app/javascript/dashboard/helper/messageHelper.js b/app/javascript/dashboard/helper/messageHelper.js
new file mode 100644
index 000000000..773dd086d
--- /dev/null
+++ b/app/javascript/dashboard/helper/messageHelper.js
@@ -0,0 +1,59 @@
+const MESSAGE_VARIABLES_REGEX = /{{(.*?)}}/g;
+export const replaceVariablesInMessage = ({ message, variables }) => {
+ return message.replace(MESSAGE_VARIABLES_REGEX, (match, replace) => {
+ return variables[replace.trim()]
+ ? variables[replace.trim().toLowerCase()]
+ : '';
+ });
+};
+
+const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
+
+export const getFirstName = ({ user }) => {
+ return user?.name ? user.name.split(' ').shift() : '';
+};
+
+export const getLastName = ({ user }) => {
+ if (user && user.name) {
+ return user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
+ }
+ return '';
+};
+
+export const getMessageVariables = ({ conversation }) => {
+ const {
+ meta: { assignee = {}, sender = {} },
+ id,
+ } = conversation;
+
+ return {
+ 'contact.name': sender?.name,
+ 'contact.first_name': getFirstName({ user: sender }),
+ 'contact.last_name': getLastName({ user: sender }),
+ 'contact.email': sender?.email,
+ 'contact.phone': sender?.phone_number,
+ 'contact.id': sender?.id,
+ 'conversation.id': id,
+ 'agent.name': assignee?.name ? assignee?.name : '',
+ 'agent.first_name': getFirstName({ user: assignee }),
+ 'agent.last_name': getLastName({ user: assignee }),
+ 'agent.email': assignee?.email ?? '',
+ };
+};
+
+export const getUndefinedVariablesInMessage = ({ message, variables }) => {
+ const messageWithOutCodeBlocks = skipCodeBlocks(message);
+ const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
+ if (!matches) return [];
+
+ return matches
+ .map(match => {
+ return match
+ .replace('{{', '')
+ .replace('}}', '')
+ .trim();
+ })
+ .filter(variable => {
+ return !variables[variable];
+ });
+};
diff --git a/app/javascript/dashboard/helper/specs/messageHelper.spec.js b/app/javascript/dashboard/helper/specs/messageHelper.spec.js
new file mode 100644
index 000000000..7eac6b55e
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/messageHelper.spec.js
@@ -0,0 +1,138 @@
+import {
+ replaceVariablesInMessage,
+ getFirstName,
+ getLastName,
+ getMessageVariables,
+ getUndefinedVariablesInMessage,
+} from '../messageHelper';
+
+const variables = {
+ 'contact.name': 'John Doe',
+ 'contact.first_name': 'John',
+ 'contact.last_name': 'Doe',
+ 'contact.email': 'john.p@example.com',
+ 'contact.phone': '1234567890',
+ 'conversation.id': 1,
+ 'agent.first_name': 'Samuel',
+ 'agent.last_name': 'Smith',
+ 'agent.email': 'samuel@gmail.com',
+};
+
+describe('#replaceVariablesInMessage', () => {
+ it('returns the message with variable name', () => {
+ const message =
+ 'No issues. Hey {{contact.first_name}}, we will send the reset instructions to your email {{ contact.email}}. The {{ agent.first_name }} {{ agent.last_name }} will take care of everything. Your conversation id is {{ conversation.id }}.';
+ expect(replaceVariablesInMessage({ message, variables })).toBe(
+ 'No issues. Hey John, we will send the reset instructions to your email john.p@example.com. The Samuel Smith will take care of everything. Your conversation id is 1.'
+ );
+ });
+
+ it('returns the message with variable name having white space', () => {
+ const message = 'hey {{contact.name}} how may I help you?';
+ expect(replaceVariablesInMessage({ message, variables })).toBe(
+ 'hey John Doe how may I help you?'
+ );
+ });
+
+ it('returns the message with variable email', () => {
+ const message =
+ 'No issues. We will send the reset instructions to your email at {{contact.email}}';
+ expect(replaceVariablesInMessage({ message, variables })).toBe(
+ 'No issues. We will send the reset instructions to your email at john.p@example.com'
+ );
+ });
+
+ it('returns the message with multiple variables', () => {
+ const message =
+ 'hey {{ contact.name }}, no issues. We will send the reset instructions to your email at {{contact.email}}';
+ expect(replaceVariablesInMessage({ message, variables })).toBe(
+ 'hey John Doe, no issues. We will send the reset instructions to your email at john.p@example.com'
+ );
+ });
+
+ it('returns the message if the variable is not present in variables', () => {
+ const message = 'Please dm me at {{contact.twitter}}';
+ expect(replaceVariablesInMessage({ message, variables })).toBe(
+ 'Please dm me at '
+ );
+ });
+});
+
+describe('#getFirstName', () => {
+ it('returns the first name of the contact', () => {
+ const assignee = { name: 'John Doe' };
+ expect(getFirstName({ user: assignee })).toBe('John');
+ });
+
+ it('returns the first name of the contact with multiple names', () => {
+ const assignee = { name: 'John Doe Smith' };
+ expect(getFirstName({ user: assignee })).toBe('John');
+ });
+});
+
+describe('#getLastName', () => {
+ it('returns the last name of the contact', () => {
+ const assignee = { name: 'John Doe' };
+ expect(getLastName({ user: assignee })).toBe('Doe');
+ });
+
+ it('returns the last name of the contact with multiple names', () => {
+ const assignee = { name: 'John Doe Smith' };
+ expect(getLastName({ user: assignee })).toBe('Smith');
+ });
+});
+
+describe('#getMessageVariables', () => {
+ it('returns the variables', () => {
+ const conversation = {
+ meta: {
+ assignee: {
+ name: 'Samuel Smith',
+ email: 'samuel@example.com',
+ },
+ sender: {
+ name: 'John Doe',
+ email: 'john.doe@gmail.com',
+ phone_number: '1234567890',
+ },
+ },
+ id: 1,
+ };
+ expect(getMessageVariables({ conversation })).toEqual({
+ 'contact.name': 'John Doe',
+ 'contact.first_name': 'John',
+ 'contact.last_name': 'Doe',
+ 'contact.email': 'john.doe@gmail.com',
+ 'contact.phone': '1234567890',
+ 'conversation.id': 1,
+ 'agent.name': 'Samuel Smith',
+ 'agent.first_name': 'Samuel',
+ 'agent.last_name': 'Smith',
+ 'agent.email': 'samuel@example.com',
+ });
+ });
+});
+
+describe('#getUndefinedVariablesInMessage', () => {
+ it('returns the undefined variables', () => {
+ const message = 'Please dm me at {{contact.twitter}}';
+ expect(
+ getUndefinedVariablesInMessage({ message, variables }).length
+ ).toEqual(1);
+ expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
+ expect.arrayContaining(['contact.twitter'])
+ );
+ });
+ it('skip variables in string with code blocks', () => {
+ const message =
+ 'hey {{contact_name}} how are you? ``` code: {{contact_name}} ```';
+ const undefinedVariables = getUndefinedVariablesInMessage({
+ message,
+ variables,
+ });
+ expect(undefinedVariables.length).toEqual(1);
+ expect(undefinedVariables).toEqual(
+ expect.arrayContaining(['contact_name'])
+ );
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index efb07d806..5d664e621 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -132,6 +132,14 @@
"PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses"
}
+ },
+ "UNDEFINED_VARIABLES": {
+ "TITLE": "Undefined variables",
+ "MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
+ "CONFIRM": {
+ "YES": "Send",
+ "CANCEL": "Cancel"
+ }
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue
index ff20d7c23..d21c00143 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue
@@ -79,6 +79,7 @@
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
+ :enable-variables="true"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@toggle-canned-menu="toggleCannedMenu"
@blur="$v.message.$touch"
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
index ea0ca4b01..95ef3af63 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
@@ -21,13 +21,17 @@