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 @@