From 59641325c7eb1d41f898785d7b1bdd6db7c10ee8 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Wed, 29 Apr 2020 07:21:42 +0000 Subject: [PATCH] [IMP] mail: add Many2OneAvatarUser field widget This widget is an extension of Many2OneAvatar, designed for many2one fields pointing to the 'res.users' model, or a model having itself a many2one field pointing to 'res.partner'. With this widget, when the avatar is clicked, we open a DM chat window with the corresponding partner. If the user clicked on itself, we open a blank chat window for the sake of consistency. In kanban views, this widget only displays the avatar. Part of task 2195254 --- addons/mail/__manifest__.py | 1 + .../static/src/js/many2one_avatar_user.js | 139 +++++++++++++ .../static/src/scss/many2one_avatar_user.scss | 6 + .../static/src/xml/many2one_avatar_user.xml | 12 ++ .../tests/many2one_avatar_user_tests.js | 184 ++++++++++++++++++ addons/mail/views/mail_templates.xml | 3 + addons/web/static/src/scss/kanban_view.scss | 4 +- doc/reference/javascript_reference.rst | 7 + 8 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 addons/mail/static/src/js/many2one_avatar_user.js create mode 100644 addons/mail/static/src/scss/many2one_avatar_user.scss create mode 100644 addons/mail/static/src/xml/many2one_avatar_user.xml create mode 100644 addons/mail/static/tests/many2one_avatar_user_tests.js diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index c45b4f7598e..7178b1baa9b 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -54,6 +54,7 @@ 'static/src/xml/chatter.xml', 'static/src/xml/discuss.xml', 'static/src/xml/followers.xml', + 'static/src/xml/many2one_avatar_user.xml', 'static/src/xml/systray.xml', 'static/src/xml/out_of_office.xml', 'static/src/xml/thread.xml', diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js new file mode 100644 index 00000000000..fe768b089c1 --- /dev/null +++ b/addons/mail/static/src/js/many2one_avatar_user.js @@ -0,0 +1,139 @@ +odoo.define('mail.Many2OneAvatarUser', function (require) { + "use strict"; + + // This module defines an extension of the Many2OneAvatar widget, which is + // integrated with the messaging system. The Many2OneAvatarUser is designed + // to display people, and when the avatar of those people is clicked, it + // opens a DM chat window with the corresponding partner (the messaging + // system is based on model 'res.partner'). + // + // This widget is supported on many2one fields pointing to 'res.users'. When + // the user is clicked, we fetch the partner id of the given user (field + // 'partner_id'), and we open a chat window with this partner. + // + // Usage: + // + // + // The widget is designed to be extended, to support many2one fields pointing + // to other models than 'res.users'. Those models must have a many2one field + // pointing to 'res.partner'. Ideally, the many2one should be false if the + // corresponding partner isn't associated with a user, otherwise it will open + // chat windows with partners that won't be able to read your messages and + // reply. + + const { _t } = require('web.core'); + const fieldRegistry = require('web.field_registry'); + const { Many2OneAvatar } = require('web.relational_fields'); + const session = require('web.session'); + + + const Many2OneAvatarUser = Many2OneAvatar.extend({ + events: Object.assign({}, Many2OneAvatar.prototype.events, { + 'click .o_m2o_avatar': '_onAvatarClicked', + }), + // Maps record ids to promises that resolve with the corresponding partner ids. + // Used as a cache shared between all instances of this widget. + partnerIds: {}, + // This widget is only supported on many2ones pointing to 'res.users' + supportedModel: 'res.users', + + init() { + this._super(...arguments); + if (this.supportedModel !== this.field.relation) { + throw new Error(`This widget is only supported on many2one fields pointing to ${this.supportedModel}`); + } + if (this.mode === 'readonly') { + this.className += ' o_clickable_m2o_avatar'; + } + this.partnerField = 'partner_id'; // field to read on 'res.users' to get the partner id + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Displays a warning when we can't open a DM chat window with the partner + * corresponding to the clicked record. On model 'res.users', it only + * happens when the user clicked on himself. This can be overridden by + * extensions of this widget, for instance to handle the case where the + * user clicked on a record whose partner isn't associated with any user. + * + * @private + */ + _displayWarning() { + this.displayNotification({ + title: _t('Cannot chat with yourself'), + message: _t('Click on the avatar of other users to chat with them.'), + type: 'info', + }); + }, + /** + * @private + * @param {number} resId + * @returns {string} the key to use in the partnerIds cache + */ + _getCacheKey(resId) { + return `${this.field.relation}_${resId}`; + }, + /** + * For a given record id of the comodel, returns the corresponding + * partner id. + * + * @param {number} resId + * @returns {Promise} + */ + _resIdToPartnerId(resId) { + const key = this._getCacheKey(resId); + if (!this.partnerIds[key]) { + const params = { + method: 'read', + model: this.field.relation, + args: [resId, [this.partnerField]], + }; + this.partnerIds[key] = this._rpc(params).then(recs => { + const partner = recs[0][this.partnerField]; + return partner && partner[0]; + }); + } + return this.partnerIds[key]; + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * When the avatar is clicked, open a DM chat window with the + * corresponding partner. If the user clicked on himself, open a blank + * thread window, for the sake of consistency. + * + * @private + * @param {MouseEvent} ev + */ + async _onAvatarClicked(ev) { + ev.stopPropagation(); // in list view, prevent from opening the record + let partnerId; + if (this.field.relation !== 'res.users' || this.value.res_id !== session.uid) { + partnerId = await this._resIdToPartnerId(this.value.res_id); + } + if (partnerId && partnerId !== session.partner_id) { + this.call('mail_service', 'openDMChatWindow', partnerId); + } else { + this._displayWarning(partnerId); + } + } + }); + + const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({ + _template: 'mail.KanbanMany2OneAvatarUser', + }); + + fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser); + fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser); + + return { + Many2OneAvatarUser, + KanbanMany2OneAvatarUser, + }; +}); diff --git a/addons/mail/static/src/scss/many2one_avatar_user.scss b/addons/mail/static/src/scss/many2one_avatar_user.scss new file mode 100644 index 00000000000..cd3fdd38ad8 --- /dev/null +++ b/addons/mail/static/src/scss/many2one_avatar_user.scss @@ -0,0 +1,6 @@ +.o_field_many2one_avatar.o_clickable_m2o_avatar { + .o_m2o_avatar:hover { + cursor: pointer; + filter: brightness(0.8); + } +} diff --git a/addons/mail/static/src/xml/many2one_avatar_user.xml b/addons/mail/static/src/xml/many2one_avatar_user.xml new file mode 100644 index 00000000000..3ef845fdde6 --- /dev/null +++ b/addons/mail/static/src/xml/many2one_avatar_user.xml @@ -0,0 +1,12 @@ + + + + + + + value + + + + + diff --git a/addons/mail/static/tests/many2one_avatar_user_tests.js b/addons/mail/static/tests/many2one_avatar_user_tests.js new file mode 100644 index 00000000000..2864fcc1868 --- /dev/null +++ b/addons/mail/static/tests/many2one_avatar_user_tests.js @@ -0,0 +1,184 @@ +odoo.define('mail.Many2OneAvatarUserTests', function (require) { +"use strict"; + +const FormView = require('web.FormView'); +const KanbanView = require('web.KanbanView'); +const ListView = require('web.ListView'); +const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser'); +const { createView, dom, mock } = require('web.test_utils'); + + +QUnit.module('mail', {}, function () { + QUnit.module('Many2OneAvatarUser', { + beforeEach: function () { + // reset the cache before each test + Many2OneAvatarUser.prototype.partnerIds = {}; + + this.data = { + 'foo': { + fields: { + user_id: { string: "User", type: 'many2one', relation: 'res.users' }, + }, + records: [ + { id: 1, user_id: 11 }, + { id: 2, user_id: 7 }, + { id: 3, user_id: 11 }, + { id: 4, user_id: 23 }, + ], + }, + 'res.users': { + fields: { + display_name: { string: "Name", type: "char" }, + partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' }, + }, + records: [{ + id: 11, + name: "Mario", + partner_id: 1, + }, { + id: 7, + name: "Luigi", + partner_id: 2, + }, { + id: 23, + name: "Yoshi", + partner_id: 3, + }], + }, + 'res.partner': { + fields: { + display_name: { string: "Name", type: "char" }, + }, + records: [{ + id: 1, + display_name: "Partner 1", + }, { + id: 2, + display_name: "Partner 2", + }, { + id: 3, + display_name: "Partner 3", + }], + }, + }; + }, + }); + + QUnit.test('many2one_avatar_user widget in list view', async function (assert) { + assert.expect(8); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + mockRPC(route, args) { + if (args.method === 'read') { + assert.step(`read ${args.model} ${args.args[0]}`); + } + return this._super(...arguments); + }, + }); + + mock.intercept(list, 'call_service', ev => { + if (ev.data.service === 'mail_service') { + assert.step(`call service ${ev.data.method} ${ev.data.args[0]}`); + } + }, true); + mock.intercept(list, 'open_record', () => { + assert.step('open record'); + }); + + assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi'); + + // sanity check: later on, we'll check that clicking on the avatar doesn't open the record + await dom.click(list.$('.o_data_row:first span')); + + await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar')); + + + assert.verifySteps([ + 'open record', + 'read res.users 11', + 'call service openDMChatWindow 1', + 'read res.users 7', + 'call service openDMChatWindow 2', + 'call service openDMChatWindow 1', + ]); + + list.destroy(); + }); + + QUnit.test('many2one_avatar_user widget: click on self', async function (assert) { + assert.expect(4); + + const form = await createView({ + View: FormView, + model: 'foo', + data: this.data, + arch: '
', + mockRPC(route, args) { + if (args.method === 'read') { + assert.step(`read ${args.model} ${args.args[0]}`); + } + return this._super(...arguments); + }, + session: { + uid: 23, + }, + res_id: 4, + }); + + mock.intercept(form, 'call_service', (ev) => { + if (ev.data.service === 'mail_service') { + throw new Error('should not call mail_service'); + } + if (ev.data.service === 'notification') { + assert.step(`display notification "${ev.data.args[0].title}"`); + } + }, true); + + assert.strictEqual(form.$('.o_field_widget[name=user_id]').text().trim(), 'Yoshi'); + + await dom.click(form.$('.o_m2o_avatar')); + + assert.verifySteps([ + 'read foo 4', + 'display notification "Cannot chat with yourself"', + ]); + + form.destroy(); + }); + + QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) { + assert.expect(6); + + const kanban = await createView({ + View: KanbanView, + model: 'foo', + data: this.data, + arch: ` + + + +
+ +
+
+
+
`, + }); + + assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), ''); + assert.containsN(kanban, '.o_m2o_avatar', 4); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/res.users/7/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/res.users/23/image_128'); + + kanban.destroy(); + }); +}); +}); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 9dfdf06f608..0430dda0a50 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -10,6 +10,7 @@