From f5acea7f9ce232773ec3adf1828a3d18bbedee1e Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Tue, 3 Nov 2015 06:56:16 +0100 Subject: [PATCH 01/56] [FIX] web_editor: escape for xml in translation as done in ir.ui.view Currently server side there is two differents source for html content displayed on a qweb rendered page: 1) ir.ui.view arch value are stored as xml and are unescaped before being displayed, so we have to escape the text nodes before saving. e.g: *   should be stored as &nbsp; since it is an HTML entity but not a defined XML entity, * & should be stored as &amp; since when unescaping it, it would become & which is invalid in HTML, * < should be stored as &lt; since when unescaping it, it would become `<` so HTML text node could become HTML element node. The tag themselves can also be escaped but it often has the same result: <em&;gt;blah</em> is the same as blah when unescaping is applied to both. But it is required is some instance, e.g unescaped would be invalid xml. 2) openerp.fields.Html value are stored directly in HTML formatting and should, conversely, not be escaped before being stored. Thus when saving an ir.ui.view part modified thanks to the website editor, an escaping of text node content is carried out to take this into account, but the same was currently not done for the v9.0 new translation system. This commit apply the same logic when saving ir.ui.view as when saving ir.translation values. --- addons/web_editor/static/src/js/rte.js | 24 ++++++++++++------- addons/web_editor/static/src/js/translator.js | 3 ++- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/addons/web_editor/static/src/js/rte.js b/addons/web_editor/static/src/js/rte.js index 6f210e005ee..a87f9fcf1e8 100644 --- a/addons/web_editor/static/src/js/rte.js +++ b/addons/web_editor/static/src/js/rte.js @@ -420,14 +420,10 @@ var RTE = Widget.extend({ }); }, - saveElement: function ($el, context) { - // remove multi edition - if ($el.data('oe-model')) { - var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression'); - if (this.__saved[key]) return true; - this.__saved[key] = true; - } - // escape text nodes for xml saving + /** + * Get HTML cloned element with text nodes escaped for XML storage + */ + getEscapedElement: function($el) { var escaped_el = $el.clone(); var to_escape = escaped_el.find('*').addBack(); to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack()); @@ -436,7 +432,17 @@ var RTE = Widget.extend({ this.nodeValue = $('
').text(this.nodeValue).html(); } }); - var markup = escaped_el.prop('outerHTML'); + return escaped_el; + }, + + saveElement: function ($el, context) { + // remove multi edition + if ($el.data('oe-model')) { + var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression'); + if (this.__saved[key]) return true; + this.__saved[key] = true; + } + var markup = this.getEscapedElement($el).prop('outerHTML'); return ajax.jsonRpc('/web/dataset/call', 'call', { model: 'ir.ui.view', diff --git a/addons/web_editor/static/src/js/translator.js b/addons/web_editor/static/src/js/translator.js index b15e8278004..0d41699c3f3 100644 --- a/addons/web_editor/static/src/js/translator.js +++ b/addons/web_editor/static/src/js/translator.js @@ -41,13 +41,14 @@ var RTE_Translate = rte.Class.extend({ var key = 'translation:'+$el.data('oe-translation-id'); if (this.__saved[key]) return true; this.__saved[key] = true; + var translation_content = this.getEscapedElement($el).html(); return ajax.jsonRpc('/web/dataset/call', 'call', { model: 'ir.translation', method: 'write', args: [ [+$el.data('oe-translation-id')], - {value: $el.html(), state: 'translated'}, + {value: translation_content, state: 'translated'}, context || base.get_context() ], }); From ee46560004f2301d9c47dfb25b37ce2ee06759cb Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Fri, 30 Oct 2015 14:09:22 +0100 Subject: [PATCH 02/56] [IMP] translate: test `xml_translate` with sample translations The received value of a translation has to be escaped client side. note: testing the use case solved with f5acea7 --- openerp/addons/base/tests/test_translate.py | 24 +++++++++++++++++++++ openerp/tools/translate.py | 3 +-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/openerp/addons/base/tests/test_translate.py b/openerp/addons/base/tests/test_translate.py index 40cf960873c..95b306e1837 100644 --- a/openerp/addons/base/tests/test_translate.py +++ b/openerp/addons/base/tests/test_translate.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from lxml.etree import XMLSyntaxError import unittest from openerp.tools.translate import quote, unquote, xml_translate, html_translate @@ -41,6 +42,29 @@ class TranslationToolsTestCase(unittest.TestCase): self.assertItemsEqual(terms, ['Form stuff', 'Blah blah blah', 'Put some more text here']) + def test_translate_xml_text(self): + """ Test xml_translate() on plain text. """ + terms = [] + source = "Blah blah blah" + result = xml_translate(terms.append, source) + self.assertEquals(result, source) + self.assertItemsEqual(terms, [source]) + + def test_translate_xml_text_entity_unescaped(self): + """ Test xml_translate() on plain text with HTML entities. """ + terms = [] + source = "Blah blah blah" + with self.assertRaises(XMLSyntaxError): + xml_translate(terms.append, source) + + def test_translate_xml_text_entity(self): + """ Test xml_translate() on plain text with HTML escaped entities. """ + terms = [] + source = "Blah&nbsp;blah&nbsp;blah" + result = xml_translate(terms.append, source) + self.assertEquals(result, source) + self.assertItemsEqual(terms, [source]) + def test_translate_xml_inline1(self): """ Test xml_translate() with formatting elements. """ terms = [] diff --git a/openerp/tools/translate.py b/openerp/tools/translate.py index 3287547c790..07b587d1467 100644 --- a/openerp/tools/translate.py +++ b/openerp/tools/translate.py @@ -286,8 +286,7 @@ def html_translate(callback, value): trans = XMLTranslator(callback, 'html') wrapped = "
%s
" % encode(value) root = etree.fromstring(wrapped, etree.HTMLParser(encoding='utf-8')) - # html > body > div - trans.process(root[0][0]) + trans.process(root[0][0]) # html > body > div return trans.get_done()[5:-6] # remove tags
and
From 0a0df28fd9529605ad3c78599abfa2468c8fc19e Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Tue, 3 Nov 2015 14:57:17 +0100 Subject: [PATCH 03/56] [IMP] website: add special char to rte_translator tour To test the the use case solved with f5acea7. --- addons/website/static/src/js/website.tour.rte.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/addons/website/static/src/js/website.tour.rte.js b/addons/website/static/src/js/website.tour.rte.js index b8d94ecf643..53bc4b1bcfe 100644 --- a/addons/website/static/src/js/website.tour.rte.js +++ b/addons/website/static/src/js/website.tour.rte.js @@ -58,7 +58,8 @@ Tour.register({ onload: function () { $("#wrap p:first").replaceWith('

Write one or two paragraphs describing your product or\ services. To be successful your content needs to be\ - useful to your readers.

'); + useful to your readers.

\ +

<b></b> is an HTML tag & is empty

'); $("#wrap img").attr("title", "test translate image title"); } }, @@ -99,6 +100,13 @@ Tour.register({ waitFor: '#wrap .o_dirty font:first:contains(translated french text)', element: 'input:first', }, + { + title: "translate text with special char", + onload: function () { + $('#wrap input + p').find(':last').prepend('<{translated}>') + .closest('[data-oe-translation-id]').addClass('o_dirty').trigger('keyup'); + }, + }, { title: "translate placeholder", element: '.modal.web_editor-dialog input:first', @@ -119,6 +127,10 @@ Tour.register({ waitNot: 'button[data-action=save]', waitFor: '#wrap p font:first:contains(translated french text)', }, + { + title: "check: content with special char is translated", + waitFor: "#wrap input + p:contains(<{translated}> is an HTML\xa0tag & )", + }, { title: "check: placeholder translation", waitFor: 'input[placeholder="test french placeholder"]', From f99231a8f4c84f79b40d3c4cc8a03d5bb536555c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 30 Oct 2015 15:43:27 +0100 Subject: [PATCH 04/56] [IMP] mail: various ui improvements - display hash before text in create channel input - increase limit for message fetching from 20 to 100 - 'Show more messages' renamed ino 'Show older messages' - remove border top and bottom on selected messages --- addons/mail/static/src/js/chat_manager.js | 2 +- addons/mail/static/src/js/client_action.js | 2 +- addons/mail/static/src/less/client_action.less | 11 +++++++++++ addons/mail/static/src/less/thread.less | 5 ----- addons/mail/static/src/xml/client_action.xml | 3 +++ addons/mail/static/src/xml/thread.xml | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js index 783ee6b0c14..9764bdd1018 100644 --- a/addons/mail/static/src/js/chat_manager.js +++ b/addons/mail/static/src/js/chat_manager.js @@ -9,7 +9,7 @@ var session = require('web.session'); var web_client = require('web.web_client'); var _t = core._t; -var LIMIT = 20; +var LIMIT = 100; var preview_msg_max_size = 50; var MessageModel = new Model('mail.message', session.context); diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 457dbe31786..1b28b53a02a 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -219,7 +219,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { self.last_search_val = _.escape(request.term); self.do_search_channel(self.last_search_val).done(function(result){ result.push({ - 'label': _.str.sprintf(''+_t("Create %s")+'', '"'+self.last_search_val+'"'), + 'label': _.str.sprintf(''+_t("Create %s")+'', '"#'+self.last_search_val+'"'), 'value': '_create', }); response(result); diff --git a/addons/mail/static/src/less/client_action.less b/addons/mail/static/src/less/client_action.less index 7c4bdfab206..2204a2eb49c 100644 --- a/addons/mail/static/src/less/client_action.less +++ b/addons/mail/static/src/less/client_action.less @@ -51,8 +51,16 @@ width: 100%; .o-webclient-padding(@top: 5px, @bottom: 5px); padding-left: 1.5*@odoo-horizontal-padding; // larger padding than titles + position: relative; + > input { padding: 2px 5px; + padding-left: 12px; + } + > span { + position: absolute; + top: 7px; + left: 27px; } } } @@ -150,6 +158,9 @@ > input { color: black; } + > span { + color: gray; + } } .o_mail_sidebar_title { diff --git a/addons/mail/static/src/less/thread.less b/addons/mail/static/src/less/thread.less index 70eedfe8aa0..a02e730d595 100644 --- a/addons/mail/static/src/less/thread.less +++ b/addons/mail/static/src/less/thread.less @@ -97,12 +97,7 @@ } } - border-bottom: 1px solid transparent; - border-top: 1px solid transparent; &.o_thread_selected_message { - border-bottom: 1px solid @gray-lighter; - border-top: 1px solid @gray-lighter; - .o_thread_message_side_date { opacity: @mail-thread-side-date-opacity; } diff --git a/addons/mail/static/src/xml/client_action.xml b/addons/mail/static/src/xml/client_action.xml index 7c8564c8146..db2a3483913 100644 --- a/addons/mail/static/src/xml/client_action.xml +++ b/addons/mail/static/src/xml/client_action.xml @@ -23,6 +23,7 @@ Channels + true Add a channel @@ -41,6 +42,7 @@ Add a channel + true
@@ -76,6 +78,7 @@
+ #
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 3f840881d86..c2d09d1db57 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -144,7 +144,7 @@
- +
From 76c71e63e77cd0ec85e4668cad6af9659775266c Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 2 Nov 2015 16:10:13 +0100 Subject: [PATCH 05/56] [IMP] mail: client action: control panel buttons Extract all buttons from 'Action' dropdown --- addons/mail/static/src/js/client_action.js | 30 +++++++------------- addons/mail/static/src/xml/client_action.xml | 6 ++-- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 1b28b53a02a..4f161f3d93c 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -16,7 +16,6 @@ var Model = require('web.Model'); var pyeval = require('web.pyeval'); var SearchView = require('web.SearchView'); var session = require('web.session'); -var Sidebar = require('web.Sidebar'); var Widget = require('web.Widget'); var QWeb = core.qweb; @@ -144,18 +143,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.searchview = new SearchView(this, dataset, view_id, {}, options); this.searchview.on('search_data', this, this.on_search); - this.sidebar = new Sidebar(this, { - sections: [ - {name: 'action', label: _t('Action')}, - ], - items: { - action: [ - {label: _t('Unsubscribe'), classname: 'o_mail_chat_button_unsubscribe', callback: this.on_click_button_unsubscribe}, - {label: _t('Settings'), classname: 'o_mail_chat_button_settings', callback: this.on_click_button_settings}, - ], - }, - }); - this.composer = new ChatComposer(this, { get_channel_info: function () { return { channel_id: self.channel.id }; @@ -169,6 +156,8 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.$buttons.find('button').css({display:"inline-block"}); this.$buttons.on('click', '.o_mail_chat_button_invite', this.on_click_button_invite); this.$buttons.on('click', '.o_mail_chat_button_detach', this.on_click_button_detach); + this.$buttons.on('click', '.o_mail_chat_button_unsubscribe', this.on_click_button_unsubscribe); + this.$buttons.on('click', '.o_mail_chat_button_settings', this.on_click_button_settings); this.$buttons.on('click', '.o_mail_toggle_channels', function () { self.$('.o_mail_chat_sidebar').slideToggle(200); }); @@ -189,11 +178,10 @@ var ChatAction = Widget.extend(ControlPanelMixin, { var def1 = this.thread.prependTo(this.$('.o_mail_chat_content')); var def2 = this.composer.appendTo(this.$('.o_mail_chat_content')); var def3 = this.searchview.appendTo($("
")); - var def4 = this.sidebar.appendTo($("
")); this.render_sidebar(); - return $.when(def1, def2, def3, def4) + return $.when(def1, def2, def3) .then(this.set_channel.bind(this, chat_manager.get_channel(default_channel_id))) .then(function () { chat_manager.bus.on('new_message', self, self.on_new_message); @@ -301,15 +289,19 @@ var ChatAction = Widget.extend(ControlPanelMixin, { } var new_channel_scrolltop = this.channels_scrolltop[channel.id]; this.channel = channel; + + // Update control panel this.set("title", channel.name); + // Hide 'detach' button in static channels this.$buttons .find('.o_mail_chat_button_detach') .toggle(channel.type !== "static"); + // Hide 'invite', 'unsubscribe' and 'settings' buttons in static channels and DM this.$buttons - .find('.o_mail_chat_button_invite') - .toggle(channel.type !== "dm" && channel.type !== 'static'); - + .find('.o_mail_chat_button_invite, .o_mail_chat_button_unsubscribe, .o_mail_chat_button_settings') + .toggle(channel.type !== "static" && channel.type !== "dm"); this.update_cp(); + this.action.context.active_id = channel.id; this.action.context.active_ids = [channel.id]; @@ -320,7 +312,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, { .addClass('o_active'); this.$('.o_chat_composer').toggle(channel.type !== 'static'); - this.sidebar.$el.toggle(channel.type !== 'static'); return this.fetch_and_render_thread().then(function () { self.thread.scroll_to({offset: new_channel_scrolltop}); @@ -388,7 +379,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, { $buttons: this.$buttons, $searchview: this.searchview.$el, $searchview_buttons: this.searchview.$buttons.contents(), - $sidebar: this.sidebar.$el, }, searchview: this.searchview, }); diff --git a/addons/mail/static/src/xml/client_action.xml b/addons/mail/static/src/xml/client_action.xml index db2a3483913..a487cd741be 100644 --- a/addons/mail/static/src/xml/client_action.xml +++ b/addons/mail/static/src/xml/client_action.xml @@ -87,8 +87,10 @@
- - + + + +
From b61c0d24993761b936766665823869f831fbb737 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 3 Nov 2015 10:49:29 +0100 Subject: [PATCH 06/56] [FIX] web: action manager: options of client actions The options dictionnary given when performing a do_action is now transmitted to the client action initialization. This was required for the mail client action, to correctly redirect to a given channel. --- addons/web/static/src/js/action_manager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/web/static/src/js/action_manager.js b/addons/web/static/src/js/action_manager.js index 54c7513295a..b5353a8e7d2 100644 --- a/addons/web/static/src/js/action_manager.js +++ b/addons/web/static/src/js/action_manager.js @@ -723,7 +723,7 @@ var ActionManager = Widget.extend({ return this.ir_actions_common({ widget: function () { - return new ClientWidget(self, action); + return new ClientWidget(self, action, options); }, action: action, klass: 'oe_act_client', From 388f7a7db9cd42142d86a768d1438af5af2f3b73 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 26 Oct 2015 16:46:09 +0100 Subject: [PATCH 07/56] [IMP] mail: composer: mention channels feature Generalizes the mention mechanism to allow mentionning partners (@) and channels (#). When a user clicks on a mentionned channel, it subscribes to it and joins it. If the mention is in a chatter, the user is redirected to the Discuss client action. Moves the RPCs to get the menu and action id of the Discuss client action from systray.js to chat_managaer.js as they are now needed in the chatter as well. Also fixes the on_channel_clicked redirection for notification message (created/joined/left #channel). Those three messages are now of type notification, and correctly redirect on click. --- addons/mail/models/mail_channel.py | 24 +- addons/mail/static/src/js/chat_manager.js | 15 +- addons/mail/static/src/js/chatter.js | 49 +- addons/mail/static/src/js/client_action.js | 13 +- addons/mail/static/src/js/composer.js | 568 ++++++++++++--------- addons/mail/static/src/js/systray.js | 17 +- addons/mail/static/src/js/thread.js | 2 +- addons/mail/static/src/xml/composer.xml | 20 +- addons/mail/static/src/xml/thread.xml | 2 +- 9 files changed, 429 insertions(+), 281 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 22f5249c973..e569bd8e56f 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -7,6 +7,7 @@ import uuid from openerp import _, api, fields, models, modules, tools from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT from openerp.exceptions import UserError +from openerp.osv import expression from openerp.addons.bus.models.bus_presence import AWAY_TIMER @@ -147,8 +148,8 @@ class Channel(models.Model): def action_unfollow(self): result = self.write({'channel_partner_ids': [(3, self.env.user.partner_id.id)]}) self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), self.channel_info()[0]) - notification = _('
left #%s
') % (self.uuid, self.name,) - self.message_post(body=notification, message_type="comment", subtype="mail.mt_comment") + notification = _('
left #%s
') % (self.id, self.name,) + self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") return result @@ -485,7 +486,7 @@ class Channel(models.Model): @api.multi def channel_join_and_get_info(self): self.ensure_one() - notification = _('
joined #%s
') % (self.uuid, self.name,) + notification = _('
joined #%s
') % (self.id, self.name,) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") self.action_follow() @@ -509,7 +510,22 @@ class Channel(models.Model): 'channel_partner_ids': [(4, self.env.user.partner_id.id)] }) channel_info = new_channel.channel_info()[0] - notification = _('
created #%s
') % (new_channel.uuid, new_channel.name,) + notification = _('
created #%s
') % (new_channel.id, new_channel.name,) new_channel.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info + + @api.model + def get_mention_suggestions(self, search, limit=8): + """ Return 'limit'-first channels' id, name and public fields such that the name matches a + 'search' string. Exclude channels of type chat (DM), and private channels the current + user isn't registered to. """ + domain = expression.AND([ + [('name', 'ilike', search)], + [('channel_type', '!=', 'chat')], + expression.OR([ + [('public', '!=', 'private')], + [('channel_partner_ids', 'in', [self.env.user.partner_id.id])] + ]) + ]) + return self.search_read(domain, ['id', 'name', 'public'], limit=limit) diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js index 9764bdd1018..42f2056e011 100644 --- a/addons/mail/static/src/js/chat_manager.js +++ b/addons/mail/static/src/js/chat_manager.js @@ -22,6 +22,7 @@ var channels = []; var emojis = []; var emoji_substitutions = {}; var needaction_counter = 0; +var discuss_ids = {}; // Message and channel manipulation helpers //---------------------------------------------------------------------------------- @@ -376,6 +377,10 @@ var chat_manager = { return needaction_counter; }, + get_discuss_ids: function () { + return discuss_ids; + }, + detach_channel: function (channel) { return ChannelModel.call("channel_minimize", [channel.uuid, true]); }, @@ -454,6 +459,10 @@ function init () { }); }); + var ir_model = new Model("ir.model.data"); + var load_menu_id = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_menu_root_chat"]); + var load_action_id = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_action_client_chat"]); + bus.on('notification', null, function (notification) { var model = notification[0][1]; if (model === 'ir.needaction') { @@ -498,7 +507,11 @@ function init () { } }); - return $.when(load_channels, load_emojis).then(function () { + return $.when(load_menu_id, load_action_id, load_channels, load_emojis).then(function (menu_id, action_id) { + discuss_ids = { + menu_id: menu_id, + action_id: action_id, + }; bus.start_polling(); return chat_manager; }); diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 2aaf856cd46..0d261ea9053 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -105,7 +105,7 @@ var Followers = form_common.AbstractField.extend({ // event: click on 'edit_subtype(pencil)' button to edit subscription this.$el.on('click', '.o_edit_subtype', self.on_edit_subtype); this.$el.on('click', '.o_remove_follower', self.on_remove_follower); - this.$el.on('click', 'a[data-partner]', self.on_follower_clicked); + this.$el.on('click', '.o_mail_redirect', self.on_click_redirect); }, on_edit_subtype: function (event) { @@ -167,24 +167,11 @@ var Followers = form_common.AbstractField.extend({ } }, - on_follower_clicked: function (event) { + on_click_redirect: function (event) { event.preventDefault(); - var partner_id = $(event.target).data('partner'); - var state = { - 'model': 'res.partner', - 'id': partner_id, - 'title': this.record_name - }; - web_client.action_manager.do_push_state(state); - var action = { - type:'ir.actions.act_window', - view_type: 'form', - view_mode: 'form', - res_model: 'res.partner', - views: [[false, 'form']], - res_id: partner_id, - }; - this.do_action(action); + var res_id = $(event.target).data('oe-id'); + var res_model = $(event.target).data('oe-model'); + this.trigger('redirect', res_model, res_id); }, read_value: function () { @@ -697,7 +684,6 @@ var Chatter = form_common.AbstractField.extend({ template: 'mail.Chatter', events: { - "click .o_mail_redirect": "on_click_redirect", "click .o_chatter_button_new_message": "on_open_composer_new_message", "click .o_chatter_button_log_note": "on_open_composer_log_note", }, @@ -717,6 +703,7 @@ var Chatter = form_common.AbstractField.extend({ this.followers = this.field_manager.fields.message_follower_ids; if (this.followers) { this.$('.o_chatter_topbar').append(this.followers.$el); + this.followers.on('redirect', this, this.on_redirect); } this.thread = new ChatThread(this, { @@ -728,6 +715,8 @@ var Chatter = form_common.AbstractField.extend({ this.thread.on('toggle_star_status', this, function (message_id) { chat_manager.toggle_star_status(message_id); }); + this.thread.on('redirect', this, this.on_redirect); + this.thread.on('redirect_to_channel', this, this.on_channel_redirect); var def1 = this._super.apply(this, arguments); var def2 = this.thread.appendTo(this.$el); @@ -778,15 +767,31 @@ var Chatter = form_common.AbstractField.extend({ } }, - on_click_redirect: function (event) { + on_channel_redirect: function (channel_id) { + event.preventDefault(); + var self = this; + var def; + var channel = chat_manager.get_channel(channel_id); + // If not registered to 'channel' yet, do it + if (channel.id !== channel_id) { + def = chat_manager.join_channel(channel_id); + } + $.when(def).then(function () { + // Execute Discuss client action with 'channel' as default channel + var discuss_ids = chat_manager.get_discuss_ids(); + self.do_action(discuss_ids.action_id, {active_id: channel_id}); + }); + }, + + on_redirect: function (res_model, res_id) { event.preventDefault(); this.do_action({ type:'ir.actions.act_window', view_type: 'form', view_mode: 'form', - res_model: $(event.target).data('oe-model'), + res_model: res_model, views: [[false, 'form']], - res_id: $(event.target).data('oe-id'), + res_id: res_id, }); }, diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index 4f161f3d93c..5113ebff5d6 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -118,11 +118,12 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.channels_scrolltop[this.channel.id] = this.thread.get_scrolltop(); }, - init: function(parent, action) { + init: function(parent, action, options) { this._super.apply(this, arguments); this.action_manager = parent; this.domain = []; this.action = action; + this.options = options || {}; this.channels_scrolltop = {}; }, @@ -137,7 +138,8 @@ var ChatAction = Widget.extend(ControlPanelMixin, { }; var dataset = new data.DataSetSearch(this, 'mail.message'); var view_id = (this.action && this.action.search_view_id && this.action.search_view_id[0]) || false; - var default_channel_id = this.action.context.active_id || + var default_channel_id = this.options.active_id || + this.action.context.active_id || this.action.params.default_active_id || 'channel_inbox'; this.searchview = new SearchView(this, dataset, view_id, {}, options); @@ -164,7 +166,12 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.thread.on('redirect', this, this.on_redirect); this.thread.on('redirect_to_channel', this, function (channel_id) { - this.set_channel(chat_manager.get_channel(channel_id)); + var channel = chat_manager.get_channel(channel_id); + if (channel.id === channel_id) { + this.set_channel(channel); + } else { + chat_manager.join_channel(channel_id); + } }); this.thread.on('load_more_messages', this, this.load_more_messages); this.thread.on('mark_as_read', this, function (message_id) { diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js index 7ed8318594a..b53221dac35 100644 --- a/addons/mail/static/src/js/composer.js +++ b/addons/mail/static/src/js/composer.js @@ -12,6 +12,296 @@ var Widget = require('web.Widget'); var QWeb = core.qweb; var _t = core._t; +// The MentionManager allows the Composer to register listeners. For each +// listener, it detects if the user is currently typing a mention (starting by a +// given delimiter). If so, if fetches mention suggestions and renders them. On +// suggestion clicked, it updates the selection for the corresponding listener. +var MentionManager = Widget.extend({ + className: 'dropup o_composer_mention_dropdown', + + events: { + "mouseover .o_mention_proposition": "on_hover_mention_proposition", + "click .o_mention_proposition": "on_click_mention_item", + }, + + init: function (parent, options) { + this._super.apply(this, arguments); + + this.composer = parent; + this.options = _.extend({}, options, { + min_length: 0, + typing_speed: 200, + }); + + this.open = false; + this.listeners = []; + this.set('mention_suggestions', []); + this.on('change:mention_suggestions', this, this._render_suggestions); + }, + + // Events + on_hover_mention_proposition: function (event) { + var $elem = $(event.currentTarget); + this.$('.o_mention_proposition').removeClass('active'); + $elem.addClass('active'); + }, + on_click_mention_item: function (event) { + event.preventDefault(); + + var text_input = this.composer.$input.val(); + var id = $(event.currentTarget).data('id'); + var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) { + return s.id === id; + }); + var get_mention_index = function (matches, cursor_position) { + for (var i=0; i wrapping the mention + * @param {boolean} [sort_selection] true if the selection must be sorted + * @param {string} [suggestions_template] the QWeb template to render suggestions + */ + register: function (listener) { + this.listeners.push(_.extend(listener, { + selection: [], + })); + }, + + /** + * Returns true if the mention suggestions dropdown is open, false otherwise + */ + is_open: function () { + return this.open; + }, + + proposition_navigation: function (keycode) { + var $active = this.$('.o_mention_proposition.active'); + if (keycode === $.ui.keyCode.ENTER) { // selecting proposition + $active.click(); + } else { // navigation in propositions + var $to; + if (keycode === $.ui.keyCode.DOWN) { + $to = $active.nextAll('.o_mention_proposition').first(); + } else { + $to = $active.prevAll('.o_mention_proposition').first(); + } + if ($to.length) { + $active.removeClass('active'); + $to.addClass('active'); + } + } + }, + + /** + * Detects if the user is currently typing a mention word + * @return the search string if it is, false otherwise + */ + detect_delimiter: function () { + var self = this; + var options = this.options; + var text_val = this.composer.$input.val(); + var cursor_position = this._get_selection_positions().start; + var left_string = text_val.substring(0, cursor_position); + function validate_keyword (delimiter) { + var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, cursor_position); + var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))"; + var regex_start = new RegExp(pattern, "g"); + search_str = search_str.replace(/^\s\s*|^[\n\r]/g, ''); + if (regex_start.test(search_str) && search_str.length > options.min_length) { + search_str = search_str.replace(pattern, ''); + return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false; + } + return false; + } + + this.active_listener = undefined; + for (var i=0; i'); return $.when({ - content: this.mention_preprocess_message(value), + content: this.mention_manager.generate_links(value), attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), partner_ids: _.pluck(this.get('mention_selected_partners'), 'id'), }); @@ -114,7 +414,7 @@ var Composer = Widget.extend({ // Empty input, selected partners and attachments self.$input.val(''); self.resize_input(); - self.set('mention_selected_partners', []); + self.mention_manager.reset_selections(); self.set('attachment_ids', []); self.$input.focus(); @@ -157,18 +457,18 @@ var Composer = Widget.extend({ // UP, DOWN: prevent moving cursor if navigation in mention propositions case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: - if (this.mention_dropdown_open) { + if (this.mention_manager.is_open()) { event.preventDefault(); } break; // BACKSPACE, DELETE: check if need to remove a mention case $.ui.keyCode.BACKSPACE: case $.ui.keyCode.DELETE: - this.mention_check_remove(); + this.mention_manager.check_remove(); break; // ENTER: submit the message only if the dropdown mention proposition is not displayed case $.ui.keyCode.ENTER: - if (this.mention_dropdown_open) { + if (this.mention_manager.is_open()) { event.preventDefault(); } else if (!this.prevent_send(event)) { event.preventDefault(); @@ -195,16 +495,11 @@ var Composer = Widget.extend({ case $.ui.keyCode.ENTER: case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: - this.mention_proposition_navigation(event.which); + this.mention_manager.proposition_navigation(event.which); break; // Otherwise, check if a mention is typed default: - this.mention_word = this.mention_detect_delimiter(); - if (this.mention_word !== false) { - this.mention_word_changed(); - } else { - this.set('mention_partners', []); // close the dropdown - } + this.mention_manager.detect_delimiter(); this.resize_input(); } }, @@ -294,226 +589,35 @@ var Composer = Widget.extend({ }, // Mention - on_click_mention_item: function (event) { - event.preventDefault(); - - var text_input = this.$input.val(); - var partner_id = $(event.currentTarget).data('partner-id'); - var selected_partner = _.filter(_.flatten(this.get('mention_partners')), function (p) { - return p.id === partner_id; - })[0]; - - // add the mention partner to the list - var mention_selected_partners = this.get('mention_selected_partners'); - if (mention_selected_partners.length) { // there are already mention partners - // get mention matches (ordered by index in the text) - var matches = this.mention_get_match(text_input); - var index = this.mention_get_index(matches, this.get_selection_positions().start); - mention_selected_partners.splice(index, 0, selected_partner); - mention_selected_partners = _.clone(mention_selected_partners); - } else { // this is the first mentionned partner - mention_selected_partners = mention_selected_partners.concat([selected_partner]); - } - this.set('mention_selected_partners', mention_selected_partners); - - // update input text, and reset dropdown - 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.options.mention_delimiter + selected_partner.name + ' ' + text_right; - this.$input.val(text_input_new); - this.set_cursor_position(text_left.length+selected_partner.name.length+2); - this.set('mention_partners', []); - }, - - on_hover_mention_proposition: function (event) { - var $elem = $(event.currentTarget); - this.$('.o_mention_proposition').removeClass('active'); - $elem.addClass('active'); - }, - - mention_proposition_navigation: function (keycode) { - var $active = this.$('.o_mention_proposition.active'); - if (keycode === $.ui.keyCode.ENTER) { // selecting proposition - $active.click(); - } else { // navigation in propositions - var $to; - if (keycode === $.ui.keyCode.DOWN) { - $to = $active.nextAll('.o_mention_proposition').first(); - } else { - $to = $active.prevAll('.o_mention_proposition').first(); - } - if ($to.length) { - $active.removeClass('active'); - $to.addClass('active'); - } - } - }, - - /** - * Return the text attached to the mention delimiter - * @returns {String|false}: the text right after the delimiter or false - */ - mention_detect_delimiter: function () { - var options = this.options; - var delimiter = options.mention_delimiter; - var text_val = this.$input.val(); - var cursor_position = this.get_selection_positions().start; - var left_string = text_val.substring(0, cursor_position); - var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, cursor_position); - - return validate_keyword(search_str); - - function validate_keyword (search_str) { - var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))"; - var regex_start = new RegExp(pattern, "g"); - search_str = search_str.replace(/^\s\s*|^[\n\r]/g, ''); - if (regex_start.test(search_str) && search_str.length > options.mention_min_length) { - search_str = search_str.replace(pattern, ''); - return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false; - } - return false; - } - }, - - mention_word_changed: function () { - var self = this; - // start a timeout to fetch partner with the current 'mention word'. The timer avoid to start - // an RPC for each pushed key when the user is typing the partner name. - // The 'mention_typing_speed' option should approach the time for a human to type a letter. - clearTimeout(this.mention_fetch_timer); - this.mention_fetch_timer = setTimeout(function () { - self.mention_fetch_partner(self.mention_word); - }, this.options.mention_typing_speed); - }, - - mention_fetch_partner: function (search) { - var self = this; + mention_fetch_channels: function (search) { var kwargs = { - channel: this.get_channel_info(), - exclude: _.pluck(this.get('mention_selected_partners'), 'id'), limit: this.options.mention_fetch_limit, search: search, }; - this.PartnerModel.call('get_mention_suggestions', kwargs).then(function (suggestions) { - self.set('mention_partners', suggestions); - }); + return this.ChannelModel + .call('get_mention_suggestions', kwargs) + .then(function (suggestions) { + return _.partition(suggestions, function (suggestion) { + return _.contains(['public', 'groups'], suggestion.public); + }); + }); }, - - mention_check_remove: function () { - var mention_selected_partners = this.get('mention_selected_partners'); - var partners_to_remove = []; - var selection = this.get_selection_positions(); - var deleted_binf = selection.start; - var deleted_bsup = selection.end; - - var matches = this.mention_get_match(this.$input.val()); - for (var i=0; i@%s", partners[i].id, partner_name); - var subtext = message.substring(start_index, end_index).replace(match[0], processed_text); - substrings.push(subtext); - start_index = end_index; - } - substrings.push(message.substring(start_index, message.length)); - return substrings.join(''); - } - return message; - }, - - render_mention_partners: function () { - if (_.flatten(this.get('mention_partners')).length) { - this.$mention_dropdown.html(QWeb.render('mail.ChatComposer.MentionMenu', { - suggestions: this.get('mention_partners'), - })); - this.$mention_dropdown - .addClass('open') - .find('.o_mention_proposition').first().addClass('active'); - this.mention_dropdown_open = true; - } else { - this.$mention_dropdown.removeClass('open'); - this.$mention_dropdown.empty(); - this.mention_dropdown_open = false; - } - }, - - render_mention_selected_partners: function () { + mention_render_selection: function (listener) { this.$mention_partner_tags.html(QWeb.render('mail.ChatComposer.MentionTags', { - partners: this.get('mention_selected_partners'), + partners: listener.selection, })); }, - /** - * Return the matches (as RexExp.exec does) for the partner mention in the input text - * @param {String} input_text: the text to search matches - * @returns {Object[]} matches in the same format as RexExp.exec() - */ - mention_get_match: function (input_text) { - var self = this; - // create the regex of all mention partner name - var partner_names = _.pluck(this.get('mention_selected_partners'), 'name'); - var escaped_partner_names = _.map(partner_names, function (str) { - return "("+_.str.escapeRegExp(self.options.mention_delimiter+str)+")"; - }); - var regex_str = escaped_partner_names.join('|'); - // extract matches - var result = []; - if(regex_str.length){ - var myRegexp = new RegExp(regex_str, 'g'); - var match = myRegexp.exec(input_text); - while (match !== null) { - result.push(match); - match = myRegexp.exec(input_text); - } - } - return result; - }, - - mention_get_index: function (matches, cursor_position) { - for (var i=0; i
-