[ADD] im_livechat,mail: canned responses support

Use the mention mechanism of the composer to autocomplete on canned responses
(keyword :) with fuzzy search.

Directly perform the string replacement in the composer, instead of
server-side as before.

Add a menu item to access an editable list view to create and edit canned
responses.

Add two canned responses as demo data.
This commit is contained in:
Aaron Bohy
2016-05-03 10:34:41 +02:00
parent 29483ffe2e
commit 2264378739
10 changed files with 172 additions and 47 deletions
+1
View File
@@ -30,6 +30,7 @@ Help your customers with this chat, and analyse their feedback.
],
'demo': [
"data/im_livechat_channel_demo.xml",
'data/mail_shortcode_demo.xml',
],
'depends': ["mail", "rating"],
'qweb': [
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data noupdate="1">
<record id="mail_canned_response_hello" model="mail.shortcode">
<field name="source">hello</field>
<field name="substitution">Hello. How may I help you?</field>
<field name="shortcode_type">text</field>
</record>
<record id="mail_canned_response_bye" model="mail.shortcode">
<field name="source">bye</field>
<field name="substitution">Thanks for your feedback. Good bye!</field>
<field name="shortcode_type">text</field>
</record>
</data>
</openerp>
@@ -174,6 +174,35 @@
</field>
</record>
<!-- Canned responses -->
<record id="im_livechat_canned_response_view_tree" model="ir.ui.view">
<field name="name">im_livechat.canned_response.tree</field>
<field name="model">mail.shortcode</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="source"/>
<field name="substitution"/>
</tree>
</field>
</record>
<record id="im_livechat_canned_response_action" model="ir.actions.act_window">
<field name="name">Canned Responses</field>
<field name="res_model">mail.shortcode</field>
<field name="view_mode">tree</field>
<field name="view_id" ref="im_livechat_canned_response_view_tree"/>
<field name="domain">[('shortcode_type','=','text')]</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to create a new canned response.
</p><p>
Canned responses allow you to insert prewritten responses in
your messages by typing <i>:shortcut</i>. The shortcut is
replaced directly in your message, so that you can still edit
it before sending.
</p>
</field>
</record>
<!-- Menu items -->
<menuitem
@@ -199,5 +228,13 @@
groups="im_livechat_group_user"
sequence="10"/>
<menuitem
id="canned_responses"
name="Canned Responses"
parent="menu_livechat_root"
action="im_livechat_canned_response_action"
groups="im_livechat_group_user"
sequence="15"/>
</data>
</openerp>
+1 -1
View File
@@ -243,7 +243,7 @@ class MailController(http.Controller):
'starred_counter': request.env['res.partner'].get_starred_count(),
'channel_slots': request.env['mail.channel'].channel_fetch_slot(),
'mention_partner_suggestions': request.env['res.partner'].get_static_mention_suggestions(),
'emoji': request.env['mail.shortcode'].sudo().search_read([('shortcode_type', '=', 'image')], ['source', 'substitution', 'description']),
'shortcodes': request.env['mail.shortcode'].sudo().search_read([], ['shortcode_type', 'source', 'substitution', 'description']),
'menu_id': request.env['ir.model.data'].xmlid_to_res_id('mail.mail_channel_menu_root_chat'),
}
return values
-2
View File
@@ -213,8 +213,6 @@ class Channel(models.Model):
def message_post(self, body='', subject=None, message_type='notification', subtype=None, parent_id=False, attachments=None, content_subtype='html', **kwargs):
# auto pin 'direct_message' channel partner
self.filtered(lambda channel: channel.channel_type == 'chat').mapped('channel_last_seen_partner_ids').write({'is_pinned': True})
# apply shortcode (text only) subsitution
body = self.env['mail.shortcode'].apply_shortcode(body, shortcode_type='text')
message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(body=body, subject=subject, message_type=message_type, subtype=subtype, parent_id=parent_id, attachments=attachments, content_subtype=content_subtype, **kwargs)
return message
+3 -17
View File
@@ -15,12 +15,12 @@ class MailShortcode(models.Model):
_name = 'mail.shortcode'
_description = 'Canned Response / Shortcode'
source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replace in the Chat Messages")
substitution = fields.Char('Substitution', required=True, index=True, help="The escaped html code replacing the shortcut")
source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replaced in the Chat Messages")
substitution = fields.Text('Substitution', required=True, index=True, help="The escaped html code replacing the shortcut")
description = fields.Char('Description')
shortcode_type = fields.Selection([('image', 'Smiley'), ('text', 'Canned Response')], required=True, default='text',
help="* Smiley are only used for HTML code to display an image "\
"* Text (default value) is use to substitute text with another text")
"* Text (default value) is used to substitute text with another text")
@api.model
def create(self, values):
@@ -43,17 +43,3 @@ class MailShortcode(models.Model):
if is_img_tag:
return substitution
return cgi.escape(substitution)
@api.model
def apply_shortcode(self, message, shortcode_type=None):
""" Apply the substitution on the given text
:param message : the text where the shortcode will be applied
:param shortcode_type : shortcode type that should be applied during the substitution
"""
domain = []
if shortcode_type:
domain.append(('shortcode_type', '=', shortcode_type))
for shortcode in self.search(domain):
regex = '(?:^|\s)(%s)(?:\s|$)' % re.escape(shortcode.source)
message = re.sub(regex, " " + shortcode.substitution + " ", message)
return message
+15 -4
View File
@@ -34,6 +34,7 @@ var emoji_substitutions = {};
var needaction_counter = 0;
var starred_counter = 0;
var mention_partner_suggestions = [];
var canned_responses = [];
var discuss_menu_id;
var global_unread_counter = 0;
var pinned_dm_partners = []; // partner_ids we have a pinned DM with
@@ -757,6 +758,10 @@ var chat_manager = {
return channel.members_deferred;
},
get_canned_responses: function () {
return canned_responses;
},
get_emojis: function() {
return emojis;
},
@@ -988,12 +993,18 @@ function init () {
needaction_counter = result.needaction_inbox_counter;
starred_counter = result.starred_counter;
mention_partner_suggestions = result.mention_partner_suggestions;
emojis = result.emoji;
_.each(emojis, function(emoji) {
emoji_substitutions[_.escape(emoji.source)] = emoji.substitution;
});
discuss_menu_id = result.menu_id;
// Shortcodes: canned responses and emojis
_.each(result.shortcodes, function (s) {
if (s.shortcode_type === 'text') {
canned_responses.push(_.pick(s, ['id', 'source', 'substitution']));
} else {
emojis.push(_.pick(s, ['id', 'source', 'substitution', 'description']));
emoji_substitutions[_.escape(s.source)] = s.substitution;
}
});
bus.start_polling();
});
}
+60 -11
View File
@@ -18,6 +18,7 @@ var NON_BREAKING_SPACE = '\u00a0';
var MENTION_PARTNER_DELIMITER = '@';
var MENTION_CHANNEL_DELIMITER = '#';
var MENTION_CANNED_RESPONSE_DELIMITER = ':';
// The MentionManager allows the Composer to register listeners. For each
// listener, it detects if the user is currently typing a mention (starting by a
@@ -59,8 +60,12 @@ var MentionManager = Widget.extend({
var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) {
return s.id === id;
});
// replace white spaces with non-breaking spaces to facilitate mentions detection in text
selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
var substitution = selected_suggestion.substitution;
if (!substitution) { // no substitution string given, so use the mention name instead
// replace white spaces with non-breaking spaces to facilitate mentions detection in text
selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
substitution = this.active_listener.delimiter + selected_suggestion.name;
}
var get_mention_index = function (matches, cursor_position) {
for (var i=0; i<matches.length; i++) {
if (cursor_position <= matches[i].index) {
@@ -84,10 +89,11 @@ var MentionManager = Widget.extend({
var cursor_position = this._get_selection_positions().start;
var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
var text_right = text_input.substring(cursor_position, text_input.length);
var text_input_new = text_left + this.active_listener.delimiter + selected_suggestion.name + ' ' + text_right;
var text_input_new = text_left + substitution + ' ' + text_right;
this.composer.$input.val(text_input_new);
this._set_cursor_position(text_left.length+selected_suggestion.name.length+2);
this._set_cursor_position(text_left.length+substitution.length+2);
this.set('mention_suggestions', []);
this.composer.focus(); // to trigger autoresize
},
// Public API
@@ -95,11 +101,16 @@ var MentionManager = Widget.extend({
* Registers a new listener, described by an object containing the following keys
* @param {char} [delimiter] the mention delimiter
* @param {function} [fetch_callback] the callback to fetch mention suggestions
* @param {string} [model] the model used for redirection
* @param {string} [redirect_classname] the classname of the <a> wrapping the mention
* @param {boolean} [generate_links] true to wrap mentions in <a> links
* @param {string} [model] (optional) the model used for redirection
* @param {string} [redirect_classname] (optional) the classname of the <a> wrapping the mention
* @param {array} [selection] (optional) initial mentions for each listener
* @param {string} [suggestion_template] the template of suggestions' dropdown
*/
register: function (listener) {
this.listeners.push(_.defaults(listener, {
model: '',
redirect_classname: '',
selection: [],
}));
},
@@ -202,6 +213,9 @@ var MentionManager = Widget.extend({
var base_href = session.url("/web");
var mention_link = "<a href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'>%s%s</a>";
_.each(this.listeners, function (listener) {
if (!listener.generate_links) {
return;
}
var selection = listener.selection;
if (selection.length) {
var matches = self._get_match(s, listener);
@@ -266,12 +280,29 @@ var MentionManager = Widget.extend({
return result;
},
_render_suggestions: function () {
if (_.flatten(this.get('mention_suggestions')).length) {
this.$el.html(QWeb.render('mail.ChatComposer.MentionSuggestions', {
suggestions: this.get('mention_suggestions'),
var suggestions = [];
if (_.find(this.get('mention_suggestions'), _.isArray)) {
// Array of arrays -> Flatten and insert dividers between groups
var insert_divider = false;
_.each(this.get('mention_suggestions'), function (suggestion_group) {
if (suggestion_group.length > 0) {
if (insert_divider) {
suggestions.push({ divider: true });
}
suggestions = suggestions.concat(suggestion_group);
insert_divider = true;
}
});
} else {
suggestions = this.get('mention_suggestions');
}
if (suggestions.length) {
this.$el.html(QWeb.render(this.active_listener.suggestion_template, {
suggestions: suggestions,
}));
this.$el
.addClass('open')
.find('ul').css("max-width", this.composer.$input.width())
.find('.o_mention_proposition').first().addClass('active');
this.open = true;
} else {
@@ -346,16 +377,26 @@ var BasicComposer = Widget.extend({
this.mention_manager.register({
delimiter: MENTION_PARTNER_DELIMITER,
fetch_callback: this.mention_fetch_partners.bind(this),
generate_links: true,
model: 'res.partner',
redirect_classname: 'o_mail_redirect',
selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER],
suggestion_template: 'mail.MentionPartnerSuggestions',
});
this.mention_manager.register({
delimiter: MENTION_CHANNEL_DELIMITER,
fetch_callback: this.mention_fetch_channels.bind(this),
generate_links: true,
model: 'mail.channel',
redirect_classname: 'o_channel_redirect',
selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER],
suggestion_template: 'mail.MentionChannelSuggestions',
});
this.mention_manager.register({
delimiter: MENTION_CANNED_RESPONSE_DELIMITER,
fetch_callback: this.mention_get_canned_responses.bind(this),
selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER],
suggestion_template: 'mail.MentionCannedResponseSuggestions',
});
// Emojis
@@ -621,8 +662,8 @@ var BasicComposer = Widget.extend({
_.each(prefetched_partners, function (partners) {
if (limit > 0) {
var filtered_partners = _.filter(partners, function (partner) {
return partner.email && partner.email.search(search_regexp) !== -1 ||
partner.name && utils.unaccent(partner.name).search(search_regexp) !== -1;
return partner.email && search_regexp.test(partner.email) ||
partner.name && search_regexp.test(utils.unaccent(partner.name));
});
if (filtered_partners.length) {
suggestions.push(filtered_partners.slice(0, limit));
@@ -640,6 +681,14 @@ var BasicComposer = Widget.extend({
return suggestions;
});
},
mention_get_canned_responses: function (search) {
var canned_responses = chat_manager.get_canned_responses();
var matches = fuzzy.filter(utils.unaccent(search), _.pluck(canned_responses, 'source'));
var indexes = _.pluck(matches.slice(0, this.options.mention_fetch_limit), 'index');
return _.map(indexes, function (i) {
return canned_responses[i];
});
},
mention_set_prefetched_partners: function (prefetched_partners) {
this.mention_prefetched_partners = prefetched_partners;
},
+12
View File
@@ -14,6 +14,18 @@
width: 100%;
resize: none;
}
.o_composer_mention_dropdown .o_mention_proposition > a {
.o-text-overflow();
width: 100%;
padding: 3px 15px;
.o_mention_name {
font-weight: 500;
}
.o_mention_info {
font-style: italic;
color: gray;
}
}
}
.o_composer_attachments_list {
+25 -12
View File
@@ -46,21 +46,34 @@
</t>
</t>
<t t-name="mail.ChatComposer.MentionSuggestions">
<t t-name="mail.AbstractMentionSuggestions">
<ul class="dropdown-menu" aria-labelledby="dropdownMailMentionMenu">
<t t-set="show_divider" t-value="false"/>
<t t-foreach="suggestions" t-as="group">
<t t-foreach="group" t-as="suggestion">
<li t-if="show_divider &amp;&amp; (suggestion_index === 0)" class="divider"/>
<t t-set="show_divider" t-value="true"/>
<li class="o_mention_proposition" t-att-data-id="suggestion.id">
<a href="#">
<t t-esc="suggestion.name"/> <t t-if="suggestion.email">(<t t-esc="suggestion.email"/>)</t>
</a>
</li>
</t>
<t t-foreach="suggestions" t-as="suggestion">
<li t-if="suggestion.divider" class="divider"/>
<li t-if="!suggestion.divider" class="o_mention_proposition" t-att-data-id="suggestion.id">
<a href="#"/>
</li>
</t>
</ul>
</t>
<t t-name="mail.MentionPartnerSuggestions" t-extend="mail.AbstractMentionSuggestions">
<t t-jquery=".o_mention_proposition a" t-operation="append">
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
<t t-if="suggestion.email">
<span class="o_mention_info">(<t t-esc="suggestion.email"/>)</span>
</t>
</t>
</t>
<t t-name="mail.MentionChannelSuggestions" t-extend="mail.AbstractMentionSuggestions">
<t t-jquery=".o_mention_proposition a" t-operation="append">
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
</t>
</t>
<t t-name="mail.MentionCannedResponseSuggestions" t-extend="mail.AbstractMentionSuggestions">
<t t-jquery=".o_mention_proposition a" t-operation="append">
<span class="o_mention_name"><t t-esc="suggestion.source"/></span>
<span class="o_mention_info"><t t-esc="suggestion.substitution"/></span>
</t>
</t>
</templates>