Compare commits
22 commits
develop
...
feat/suppo
Author | SHA1 | Date | |
---|---|---|---|
|
ea5a25934d | ||
|
9d12826cb0 | ||
|
ea69d46b9e | ||
|
efa3d41ea6 | ||
|
0f8e5c6fe1 | ||
|
c3135d1d2f | ||
|
3848f38dde | ||
|
4d763b36a3 | ||
|
8dd1f588ba | ||
|
322b253c97 | ||
|
289ad1e7f3 | ||
|
9be03bdbe8 | ||
|
7f0d2097cb | ||
|
ddd6750df5 | ||
|
faaf409d09 | ||
|
ea25651bc6 | ||
|
c4682eb57c | ||
|
e03285e702 | ||
|
f65d45a01a | ||
|
b01f0744d7 | ||
|
5fb282dcbd | ||
|
7892b039a7 |
17 changed files with 688 additions and 20 deletions
17
app/drops/contact_drop.rb
Normal file
17
app/drops/contact_drop.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class ContactDrop < BaseDrop
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
|
||||
def phone_number
|
||||
@obj.try(:phone_number)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split, ' ').try(:first)
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split, ' ').try(:last) if @obj.try(:name).try(:split, ' ').try(:size) > 1
|
||||
end
|
||||
end
|
|
@ -2,4 +2,12 @@ class UserDrop < BaseDrop
|
|||
def available_name
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split, ' ').try(:first)
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split, ' ').try(:last) if @obj.try(:name).try(:split, ' ').try(:size) > 1
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,10 +6,15 @@
|
|||
@click="insertMentionNode"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showCannedMenu && !isPrivate"
|
||||
v-if="shouldShowCannedResponses"
|
||||
:search-key="cannedSearchTerm"
|
||||
@click="insertCannedResponse"
|
||||
/>
|
||||
<variable-list
|
||||
v-if="shouldShowVariables"
|
||||
:search-key="variableSearchTerm"
|
||||
@click="insertVariable"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -34,6 +39,7 @@ import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
|
|||
|
||||
import TagAgents from '../conversation/TagAgents';
|
||||
import CannedResponse from '../conversation/CannedResponse';
|
||||
import VariableList from '../conversation/VariableList';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
|
||||
|
@ -50,6 +56,7 @@ import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
|||
import AnalyticsHelper, {
|
||||
ANALYTICS_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper';
|
||||
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
return EditorState.create({
|
||||
|
@ -64,7 +71,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: '' },
|
||||
|
@ -74,13 +81,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,
|
||||
|
@ -92,6 +104,14 @@ export default {
|
|||
defaultMarkdownSerializer
|
||||
).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 [];
|
||||
|
@ -111,6 +131,7 @@ export default {
|
|||
this.range = args.range;
|
||||
|
||||
this.mentionSearchKey = args.text.replace('@', '');
|
||||
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
|
@ -150,6 +171,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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
},
|
||||
|
@ -277,17 +326,22 @@ export default {
|
|||
},
|
||||
|
||||
insertCannedResponse(cannedItem) {
|
||||
const updatedCannedResponse = replaceVariablesInMessage({
|
||||
message: cannedItem,
|
||||
variables: this.variables,
|
||||
});
|
||||
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let from = this.range.from - 1;
|
||||
let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
|
||||
cannedItem
|
||||
updatedCannedResponse
|
||||
);
|
||||
|
||||
if (node.childCount === 1) {
|
||||
node = this.editorView.state.schema.text(cannedItem);
|
||||
node = this.editorView.state.schema.text(updatedCannedResponse);
|
||||
from = this.range.from;
|
||||
}
|
||||
|
||||
|
@ -302,6 +356,34 @@ export default {
|
|||
|
||||
tr.scrollIntoView();
|
||||
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.showCannedMenu = false;
|
||||
return false;
|
||||
},
|
||||
insertVariable(variable) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let from = this.range.from - 1;
|
||||
let node = addMentionsToMarkdownParser(defaultMarkdownParser).parse(
|
||||
variable
|
||||
);
|
||||
|
||||
if (node.childCount === 1) {
|
||||
node = this.editorView.state.schema.text(`{{ ${variable} }}`);
|
||||
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();
|
||||
|
||||
tr.scrollIntoView();
|
||||
return false;
|
||||
},
|
||||
|
||||
|
|
|
@ -63,6 +63,8 @@
|
|||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
:enable-variables="true"
|
||||
:variables="messageVariables"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
|
@ -125,6 +127,12 @@
|
|||
@on-send="onSendWhatsAppReply"
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
:description="undefinedVariableMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -152,6 +160,10 @@ import {
|
|||
} from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
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';
|
||||
|
@ -224,6 +236,7 @@ export default {
|
|||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -470,6 +483,12 @@ export default {
|
|||
}
|
||||
return AUDIO_FORMATS.OGG;
|
||||
},
|
||||
messageVariables() {
|
||||
const variables = getMessageVariables({
|
||||
conversation: this.currentChat,
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
|
@ -665,7 +684,7 @@ export default {
|
|||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
async onSendReply() {
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
|
@ -686,6 +705,25 @@ 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 = `You have ${undefinedVariablesCount} undefined variables in your message: ${undefinedVariables.join(
|
||||
', '
|
||||
)}. Would you like to send the message anyway?`;
|
||||
const ok = await this.$refs.confirmDialog.showConfirmation();
|
||||
if (ok) {
|
||||
this.confirmOnSendReply();
|
||||
}
|
||||
} else {
|
||||
this.confirmOnSendReply();
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<variable :items="items" @mention-select="handleVariableClick" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
|
||||
import Variable from '../variable/Variable.vue';
|
||||
|
||||
export default {
|
||||
components: { Variable },
|
||||
props: {
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return MESSAGE_VARIABLES.filter(variable => {
|
||||
return (
|
||||
variable.label.includes(this.searchKey) ||
|
||||
variable.key.includes(this.searchKey)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleVariableClick(item = {}) {
|
||||
this.$emit('click', item.key);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
class="vertical dropdown menu mention--box"
|
||||
:style="{ top: getTopPadding() + 'rem' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="item.key"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="onListItemSelection(index)"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<a class="text-truncate">
|
||||
<strong>{{ item.label }}</strong>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mentionSelectionKeyboardMixin from '../mentions/mentionSelectionKeyboardMixin';
|
||||
export default {
|
||||
mixins: [mentionSelectionKeyboardMixin],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
items(newItems) {
|
||||
if (newItems.length < this.selectedIndex + 1) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTopPadding() {
|
||||
if (this.items.length <= 4) {
|
||||
return -(this.items.length * 2.9 + 1.7);
|
||||
}
|
||||
return -14;
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 29 * this.selectedIndex;
|
||||
},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
onListItemSelection(index) {
|
||||
this.selectedIndex = index;
|
||||
this.onSelect();
|
||||
},
|
||||
onSelect() {
|
||||
this.$emit('mention-select', this.items[this.selectedIndex]);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mention--box {
|
||||
background: var(--white);
|
||||
border-bottom: var(--space-small) solid var(--white);
|
||||
border-top: 1px solid var(--color-border);
|
||||
left: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
padding-top: var(--space-small);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
.active a {
|
||||
background: var(--w-500);
|
||||
}
|
||||
}
|
||||
</style>
|
63
app/javascript/dashboard/helper/messageHelper.js
Normal file
63
app/javascript/dashboard/helper/messageHelper.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
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 = ({ name }) => {
|
||||
return name.split(' ')[0];
|
||||
};
|
||||
|
||||
export const getLastName = ({ name }) => {
|
||||
return name.split(' ').length > 1
|
||||
? name.split(' ')[name.split(' ').length - 1]
|
||||
: '';
|
||||
};
|
||||
|
||||
export const getMessageVariables = ({ conversation }) => {
|
||||
const {
|
||||
meta: { assignee = {}, sender = {} },
|
||||
id,
|
||||
} = conversation;
|
||||
|
||||
return {
|
||||
'contact.name': sender?.name,
|
||||
'contact.first_name': getFirstName({ name: sender?.name }),
|
||||
'contact.last_name': getLastName({ name: sender?.name }),
|
||||
'contact.email': sender?.email,
|
||||
'contact.phone': sender?.phone_number,
|
||||
'conversation.id': id,
|
||||
'agent.name': assignee?.name ? assignee?.name : '',
|
||||
'agent.first_name': assignee?.name
|
||||
? getFirstName({ name: assignee?.name })
|
||||
: '',
|
||||
'agent.last_name': assignee?.name
|
||||
? getLastName({ name: assignee?.name })
|
||||
: '',
|
||||
'agent.email': assignee?.email ? assignee?.email : '',
|
||||
};
|
||||
};
|
||||
|
||||
export const getUndefinedVariablesInMessage = ({ message, variables }) => {
|
||||
const messageWithOutCodeBlocks = skipCodeBlocks(message);
|
||||
const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
|
||||
const undefinedVariables = [];
|
||||
if (!matches) {
|
||||
return [];
|
||||
}
|
||||
matches.forEach(match => {
|
||||
const variable = match
|
||||
.replace('{{', '')
|
||||
.replace('}}', '')
|
||||
.trim();
|
||||
if (!variables[variable]) {
|
||||
undefinedVariables.push(match);
|
||||
}
|
||||
});
|
||||
return undefinedVariables;
|
||||
};
|
136
app/javascript/dashboard/helper/specs/messageHelper.spec.js
Normal file
136
app/javascript/dashboard/helper/specs/messageHelper.spec.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
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 name = 'John Doe';
|
||||
expect(getFirstName({ name })).toBe('John');
|
||||
});
|
||||
|
||||
it('returns the first name of the contact with multiple names', () => {
|
||||
const name = 'John Doe Smith';
|
||||
expect(getFirstName({ name })).toBe('John');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getLastName', () => {
|
||||
it('returns the last name of the contact', () => {
|
||||
const name = 'John Doe';
|
||||
expect(getLastName({ name })).toBe('Doe');
|
||||
});
|
||||
|
||||
it('returns the last name of the contact with multiple names', () => {
|
||||
const name = 'John Doe Smith';
|
||||
expect(getLastName({ name })).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}} ```';
|
||||
expect(
|
||||
getUndefinedVariablesInMessage({ message, variables }).length
|
||||
).toEqual(1);
|
||||
expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
|
||||
expect.arrayContaining(['{{contact_name}}'])
|
||||
);
|
||||
});
|
||||
});
|
|
@ -132,6 +132,13 @@
|
|||
"PLACEHOLDER": "Emails separated by commas",
|
||||
"ERROR": "Please enter valid email addresses"
|
||||
}
|
||||
},
|
||||
"UNDEFINED_VARIABLES": {
|
||||
"TITLE": "Undefined variables",
|
||||
"CONFIRM": {
|
||||
"YES": "Send",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -21,13 +21,17 @@
|
|||
<div class="medium-12 columns">
|
||||
<label :class="{ error: $v.content.$error }">
|
||||
{{ $t('CANNED_MGMT.ADD.FORM.CONTENT.LABEL') }}
|
||||
<textarea
|
||||
v-model.trim="content"
|
||||
rows="5"
|
||||
type="text"
|
||||
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
|
||||
@input="$v.content.$touch"
|
||||
/>
|
||||
<label class="editor-wrap">
|
||||
<woot-message-editor
|
||||
v-model="content"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.content.$error }"
|
||||
:enable-variables="true"
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
|
||||
@blur="$v.content.$touch"
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -56,12 +60,14 @@ import { required, minLength } from 'vuelidate/lib/validators';
|
|||
|
||||
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
|
||||
import Modal from '../../../../components/Modal';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootSubmitButton,
|
||||
Modal,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
|
@ -125,3 +131,19 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep .mention--box {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
left: 0;
|
||||
margin: auto;
|
||||
right: 0;
|
||||
top: 10rem !important;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
::v-deep .ProseMirror-menubar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -18,13 +18,17 @@
|
|||
<div class="medium-12 columns">
|
||||
<label :class="{ error: $v.content.$error }">
|
||||
{{ $t('CANNED_MGMT.EDIT.FORM.CONTENT.LABEL') }}
|
||||
<textarea
|
||||
v-model.trim="content"
|
||||
rows="5"
|
||||
type="text"
|
||||
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
|
||||
@input="$v.content.$touch"
|
||||
/>
|
||||
<label class="editor-wrap">
|
||||
<woot-message-editor
|
||||
v-model="content"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: $v.content.$error }"
|
||||
:enable-variables="true"
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
|
||||
@blur="$v.content.$touch"
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -51,7 +55,7 @@
|
|||
<script>
|
||||
/* eslint no-console: 0 */
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
|
||||
import Modal from '../../../../components/Modal';
|
||||
|
||||
|
@ -59,6 +63,7 @@ export default {
|
|||
components: {
|
||||
WootSubmitButton,
|
||||
Modal,
|
||||
WootMessageEditor,
|
||||
},
|
||||
props: {
|
||||
id: { type: Number, default: null },
|
||||
|
@ -139,3 +144,18 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
::v-deep .mention--box {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
left: 0;
|
||||
margin: auto;
|
||||
right: 0;
|
||||
top: 7rem !important;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
::v-deep .ProseMirror-menubar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -77,3 +77,50 @@ export const AUDIO_FORMATS = {
|
|||
WEBM: 'audio/webm',
|
||||
OGG: 'audio/ogg',
|
||||
};
|
||||
|
||||
export const MESSAGE_VARIABLES = [
|
||||
{
|
||||
label: 'Conversation Id',
|
||||
key: 'conversation.id',
|
||||
},
|
||||
{
|
||||
label: 'Contact Id',
|
||||
key: 'contact.id',
|
||||
},
|
||||
{
|
||||
label: 'Contact name',
|
||||
key: 'contact.name',
|
||||
},
|
||||
{
|
||||
label: 'Contact first name',
|
||||
key: 'contact.first_name',
|
||||
},
|
||||
{
|
||||
label: 'Contact last name',
|
||||
key: 'contact.last_name',
|
||||
},
|
||||
{
|
||||
label: 'Contact email',
|
||||
key: 'contact.email',
|
||||
},
|
||||
{
|
||||
label: 'Contact phone',
|
||||
key: 'contact.phone',
|
||||
},
|
||||
{
|
||||
label: 'Agent name',
|
||||
key: 'agent.name',
|
||||
},
|
||||
{
|
||||
label: 'Agent first name',
|
||||
key: 'agent.first_name',
|
||||
},
|
||||
{
|
||||
label: 'Agent last name',
|
||||
key: 'agent.last_name',
|
||||
},
|
||||
{
|
||||
label: 'Agent email',
|
||||
key: 'agent.email',
|
||||
},
|
||||
];
|
||||
|
|
36
app/models/concerns/liquidable.rb
Normal file
36
app/models/concerns/liquidable.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
module Liquidable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
acts_as_taggable_on :labels
|
||||
before_create :process_liquid_in_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message_drops
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'agent' => UserDrop.new(sender),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(inbox)
|
||||
}
|
||||
end
|
||||
|
||||
def liquid_processable_message?
|
||||
content.present? && message_type == 'outgoing'
|
||||
end
|
||||
|
||||
def process_liquid_in_content
|
||||
return unless liquid_processable_message?
|
||||
|
||||
template = Liquid::Template.parse(modified_liquid_content)
|
||||
self.content = template.render(message_drops)
|
||||
end
|
||||
|
||||
def modified_liquid_content
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
|
||||
end
|
||||
end
|
|
@ -32,6 +32,7 @@
|
|||
|
||||
class Message < ApplicationRecord
|
||||
include MessageFilterHelpers
|
||||
include Liquidable
|
||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||
|
||||
before_validation :ensure_content_type
|
||||
|
|
66
spec/models/concerns/liquidable_shared.rb
Normal file
66
spec/models/concerns/liquidable_shared.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
require 'rails_helper'
|
||||
|
||||
shared_examples_for 'liqudable' do
|
||||
context 'when liquid is present in content' do
|
||||
let(:contact) { create(:contact, name: 'john', phone_number: '+912883') }
|
||||
let(:conversation) { create(:conversation, id: 1, contact: contact) }
|
||||
|
||||
context 'when message is incoming' do
|
||||
let(:message) { build(:message, conversation: conversation, message_type: 'incoming') }
|
||||
|
||||
it 'will not process liquid in content' do
|
||||
message.content = 'hey {{contact.name}} how are you?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey {{contact.name}} how are you?'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is outgoing' do
|
||||
let(:message) { build(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
|
||||
it 'set replaces liquid variables in message' do
|
||||
message.content = 'hey {{contact.name}} how are you?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey john how are you?'
|
||||
end
|
||||
|
||||
it 'process liquid operators like default value' do
|
||||
message.content = 'Can we send you an email at {{ contact.email | default: "default" }} ?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'Can we send you an email at default ?'
|
||||
end
|
||||
|
||||
it 'return empty string when value is not available' do
|
||||
message.content = 'Can we send you an email at {{contact.email}}?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'Can we send you an email at ?'
|
||||
end
|
||||
|
||||
it 'will not process liquid tags in multiple code blocks' do
|
||||
message.content = 'hey {{contact.name}} how are you? ``` code: {{contact.name}} ``` ``` code: {{contact.name}} ``` test'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey john how are you? ``` code: {{contact.name}} ``` ``` code: {{contact.name}} ``` test'
|
||||
end
|
||||
|
||||
it 'will extract first name from contact name' do
|
||||
message.content = 'hey {{contact.first_name}} how are you?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey john how are you?'
|
||||
end
|
||||
|
||||
it 'return empty last name when value is not available' do
|
||||
message.content = 'hey {{contact.last_name}} how are you?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey how are you?'
|
||||
end
|
||||
|
||||
it 'will extract first name and last name from contact name' do
|
||||
contact.name = 'john doe'
|
||||
contact.save!
|
||||
message.content = 'hey {{contact.first_name}} {{contact.last_name}} how are you?'
|
||||
message.save!
|
||||
expect(message.content).to eq 'hey john doe how are you?'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
|
||||
|
||||
RSpec.describe Message, type: :model do
|
||||
context 'with validations' do
|
||||
|
@ -9,6 +10,10 @@ RSpec.describe Message, type: :model do
|
|||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
end
|
||||
|
||||
describe 'concerns' do
|
||||
it_behaves_like 'liqudable'
|
||||
end
|
||||
|
||||
describe '#reopen_conversation' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message) { build(:message, message_type: :incoming, conversation: conversation) }
|
||||
|
|
Loading…
Reference in a new issue