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 @@
+
@@ -97,6 +98,7 @@
+
@@ -123,6 +125,7 @@
+
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index d24a928536e..373cee51516 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -127,9 +127,11 @@
.oe_kanban_avatar {
border-radius: 50%;
+ object-fit: cover;
+ }
+ .oe_kanban_avatar, .o_field_many2one_avatar > .o_m2o_avatar {
width: 20px;
height: 20px;
- object-fit: cover;
margin-left: 6px;
}
}
diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst
index 433409acd5e..e7f350ee01c 100644
--- a/doc/reference/javascript_reference.rst
+++ b/doc/reference/javascript_reference.rst
@@ -2148,6 +2148,13 @@ Relational fields
- Supported field types: *many2one*
+- many2one_avatar_user (Many2OneAvatarUser)
+ This widget is a specialization of the Many2OneAvatar. When the avatar is
+ clicked, we open a chat window with the corresponding user. This widget can
+ only be set on many2one fields pointing to the 'res.users' model.
+
+ - Supported field types: *many2one* (pointing to 'res.users')
+
- kanban.many2one (KanbanFieldMany2One)
Default widget for many2one fields (in kanban view). We need to disable all
editing in kanban views.