From e3ecccd6416cfb18187dfe803d627af43ec821d8 Mon Sep 17 00:00:00 2001 From: Xavier Dubuc Date: Thu, 18 Mar 2021 10:32:27 +0000 Subject: [PATCH] [FIX] mail; hr: restore faster and more accurate mention suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code from v13 used to look for partners in JS before making the RPC, which was much faster, and also gave better results by returning partners that were already known and therefore more likely to be selected. The same logic is reintroduced here, and further improved to take into account all known partners and sort them according to the likeliness they will be selected based on various criteria. task-2413776 closes odoo/odoo#68045 X-original-commit: 930e090ced8134c3819a5a87b9052467ba23e8e4 Signed-off-by: Alexandre Kühn (aku) Co-authored-by: Sébastien Theys --- addons/hr/models/res_partner.py | 15 +- addons/mail/models/mail_channel.py | 11 +- addons/mail/models/res_partner.py | 61 +- .../src/components/composer/composer_tests.js | 79 +- .../composer_suggestion.js | 4 +- .../composer_suggestion_list.js | 16 +- .../composer_suggestion_list.xml | 6 +- .../composer_text_input.js | 9 +- .../composer_text_input.xml | 2 +- .../discuss_sidebar_item.xml | 2 +- .../thread_view/thread_view_tests.js | 1 + .../models/canned_response/canned_response.js | 78 +- .../models/channel_command/channel_command.js | 96 ++- .../static/src/models/composer/composer.js | 794 ++++++++---------- .../messaging_initializer.js | 7 +- .../mail/static/src/models/partner/partner.js | 179 +++- .../mail/static/src/models/thread/thread.js | 121 +++ addons/mail/static/src/models/user/user.js | 6 + addons/mail/static/src/utils/utils.js | 14 + .../mail/static/tests/helpers/mock_server.js | 19 +- 20 files changed, 929 insertions(+), 591 deletions(-) diff --git a/addons/hr/models/res_partner.py b/addons/hr/models/res_partner.py index 22d315aa68f..e51f0069dd7 100644 --- a/addons/hr/models/res_partner.py +++ b/addons/hr/models/res_partner.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models +from odoo import models from odoo.exceptions import AccessError @@ -9,19 +9,6 @@ class Partner(models.Model): _inherit = ['res.partner'] - @api.model - def get_static_mention_suggestions(self): - """ Extend the mail's static mention suggestions by adding the employees. """ - suggestions = super(Partner, self).get_static_mention_suggestions() - - try: - employee_group = self.env.ref('base.group_user') - hr_suggestions = [{'id': user.partner_id.id, 'name': user.name, 'email': user.email} for user in employee_group.users] - suggestions.append(hr_suggestions) - return suggestions - except AccessError: - return suggestions - def name_get(self): """ Override to allow an employee to see its private address in his profile. This avoids to relax access rules on `res.parter` and to add an `ir.rule`. diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index fbc56cde19b..d3f6100afe3 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -604,7 +604,14 @@ class Channel(models.Model): self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({'is_pinned': True}) - message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs) + # mail_post_autofollow=False is necessary to prevent adding followers + # when using mentions in channels. Followers should not be added to + # channels, and especially not automatically (because channel membership + # should be managed with channel.partner instead). + # The current client code might be setting the key to True on sending + # message but it is only useful when targeting customers in chatter. + # This value should simply be set to False in channels no matter what. + message = super(Channel, self.with_context(mail_create_nosubscribe=True, mail_post_autofollow=False)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs) # Notifies the message author when his message is pending moderation if required on channel. # The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message. @@ -1165,7 +1172,7 @@ class Channel(models.Model): [('channel_partner_ids', 'in', [self.env.user.partner_id.id])] ]) ]) - return self.search_read(domain, ['id', 'name', 'public'], limit=limit) + return self.search_read(domain, ['id', 'name', 'public', 'channel_type'], limit=limit) @api.model def channel_fetch_listeners(self, uuid): diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 2167c2d5354..0fbad5d9a5e 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -6,6 +6,7 @@ import logging from odoo import _, api, fields, models, tools from odoo.addons.bus.models.bus_presence import AWAY_TIMER from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER +from odoo.exceptions import AccessError from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -71,13 +72,20 @@ class Partner(models.Model): def mail_partner_format(self): self.ensure_one() - return { - "id": self.name_get()[0][0], - "display_name": self.name_get()[0][1], + internal_users = self.user_ids - self.user_ids.filtered('share') + main_user = internal_users[0] if len(internal_users) > 0 else self.user_ids[0] if len(self.user_ids) > 0 else self.env['res.users'] + res = { + "id": self.id, + "display_name": self.display_name, "name": self.name, + "email": self.email, "active": self.active, "im_status": self.im_status, + "user_id": main_user.id, } + if main_user: + res["is_internal_user"] = not main_user.share + return res @api.model def get_needaction_count(self): @@ -106,36 +114,39 @@ class Partner(models.Model): @api.model def get_static_mention_suggestions(self): - """ To be overwritten to return the id, name and email of partners used as static mention - suggestions loaded once at webclient initialization and stored client side. """ - return [] + """ Returns static mention suggestions of partners, loaded once at webclient initialization + and stored client side. All the internal users are returned. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ + partners = self.env['res.partner'] + try: + partners = self.env.ref('base.group_user').users.partner_id + except AccessError: + pass + return [partner.mail_partner_format() for partner in partners] @api.model - def get_mention_suggestions(self, search, limit=8): - """ Return 'limit'-first partners' id, name and email such that the name or email matches a - 'search' string. Prioritize users, and then extend the research to all partners. """ + def get_mention_suggestions(self, search, limit=8, channel_id=None): + """ Return 'limit'-first partners' such that the name or email matches a 'search' string. + Prioritize partners that are also users, and then extend the research to all partners. + If channel_id is given, only members of this channel are returned. + The return format is a list of partner data (as per returned by `mail_partner_format()`). + """ search_dom = expression.OR([[('name', 'ilike', search)], [('email', 'ilike', search)]]) search_dom = expression.AND([[('active', '=', True)], search_dom]) - fields = ['id', 'name', 'email'] + if channel_id: + search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom]) - # Search users + # Search partners that are also users domain = expression.AND([[('user_ids.id', '!=', False), ('user_ids.active', '=', True)], search_dom]) - users = self.search_read(domain, fields, limit=limit) + partners = self.search(domain, limit=limit) - # Search partners if less than 'limit' users found - partners = [] - if len(users) < limit: - partners = self.search_read(search_dom, fields, limit=limit) - # Remove duplicates - partners = [p for p in partners if not len([u for u in users if u['id'] == p['id']])] + # Search partners that are not users if less than 'limit' partner that are users found + remaining_limit = limit - len(partners) + if remaining_limit > 0: + partners |= self.search(expression.AND([[('id', 'not in', partners.ids)], search_dom]), limit=remaining_limit) - # add OdooBot even if its partner is archived - if len(partners) + len(users) < limit and "odoobot".startswith(search.lower()): - odoobot = self.env.ref("base.partner_root") - if not any(elem['id'] == odoobot.id for elem in partners): - partners.append(odoobot.read(fields)[0]) - - return [users, partners] + return [partner.mail_partner_format() for partner in partners] @api.model def im_search(self, name, limit=20): diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js index ff1c9333acf..d30d2ea3f09 100644 --- a/addons/mail/static/src/components/composer/composer_tests.js +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -402,8 +402,6 @@ QUnit.test('use a canned response', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, ":"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -462,10 +460,8 @@ QUnit.test('use a canned response some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, ":") - ); await afterNextRender(() => { + document.execCommand('insertText', false, ":"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -518,8 +514,6 @@ QUnit.test('add an emoji after a canned response', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, ":"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -619,8 +613,6 @@ QUnit.test('mention a channel', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "#"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -675,10 +667,8 @@ QUnit.test('mention a channel after some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, "#") - ); await afterNextRender(() => { + document.execCommand('insertText', false, "#"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -727,8 +717,6 @@ QUnit.test('add an emoji after a channel mention', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "#"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -833,8 +821,6 @@ QUnit.test('use a command for a specific channel type', async function (assert) await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "/"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -922,10 +908,8 @@ QUnit.test('use a command after some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, "/") - ); await afterNextRender(() => { + document.execCommand('insertText', false, "/"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -977,8 +961,6 @@ QUnit.test('add an emoji after a command', async function (assert) { await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "/"); - }); - await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -2220,61 +2202,6 @@ QUnit.test('send message only once when enter is pressed twice quickly', async f ); }); -QUnit.test("mentioned partners should not be notified if they are not member of current channel", async function (assert) { - assert.expect(4); - - this.data['res.partner'].records.push({ - email: "testpartner@example.com", - name: "TestPartner", - }); - this.data['mail.channel'].records.push({ - id: 10, - members: [this.data.currentPartnerId], - }); - await this.start({ - async mockRPC(route, args) { - if (args.model === 'mail.channel' && args.method === 'message_post') { - assert.step('message_post'); - assert.strictEqual( - args.kwargs.partner_ids.length, - 0, - "message_post should not contain mentioned partners that are not members of channel" - ); - } - return this._super(...arguments); - }, - }); - const thread = this.env.models['mail.thread'].findFromIdentifyingData({ - id: 10, - model: 'mail.channel', - }); - await this.createComposerComponent(thread.composer); - await afterNextRender(() => { - document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@"); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keydown')); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keyup')); - }); - await afterNextRender(() => { - document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "Test"); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keydown')); - document.querySelector(`.o_ComposerTextInput_textarea`) - .dispatchEvent(new window.KeyboardEvent('keyup')); - }); - await afterNextRender(() => document.querySelector('.o_ComposerSuggestion').click()); - assert.strictEqual( - document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), - "@TestPartner ", - "text content of composer should have mentioned partner + additional whitespace afterwards" - ); - await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); - assert.verifySteps(['message_post'], "the message should be posted"); -}); - QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) { // Uploading multiple files uploads attachments one at a time, this test // ensures that there is no crash when an attachment is destroyed before its diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js index 4ae48db623b..da54ab54311 100644 --- a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js @@ -116,9 +116,7 @@ class ComposerSuggestion extends Component { */ _onClick(ev) { ev.preventDefault(); - this.composer.update({ - [this.composer.activeSuggestedRecordName]: [['link', this.record]], - }); + this.composer.update({ activeSuggestedRecord: [['link', this.record]] }); this.composer.insertSuggestion(); this.composer.closeSuggestions(); this.trigger('o-composer-suggestion-clicked'); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js index ea2153dbf6f..23f08399594 100644 --- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js @@ -22,23 +22,23 @@ class ComposerSuggestionList extends Component { const activeSuggestedRecord = composer ? composer.activeSuggestedRecord : undefined; - const extraSuggestedRecordsList = composer - ? composer.extraSuggestedRecordsList + const extraSuggestedRecords = composer + ? composer.extraSuggestedRecords : []; - const mainSuggestedRecordsList = composer - ? composer.mainSuggestedRecordsList + const mainSuggestedRecords = composer + ? composer.mainSuggestedRecords : []; return { activeSuggestedRecord, composer, composerSuggestionModelName: composer && composer.suggestionModelName, - extraSuggestedRecordsList: extraSuggestedRecordsList, - mainSuggestedRecordsList: mainSuggestedRecordsList, + extraSuggestedRecords, + mainSuggestedRecords, }; }, { compareDepth: { - extraSuggestedRecordsList: 1, - mainSuggestedRecordsList: 1, + extraSuggestedRecords: 1, + mainSuggestedRecords: 1, }, }); } diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml index 8bcd87c2d07..9747109a994 100644 --- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml @@ -5,7 +5,7 @@