diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index 37d335142d4..4ad4d20a89d 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -58,7 +58,7 @@
'web.assets_backend': [
'mail/static/src/js/core/translation.js',
'mail/static/src/js/many2many_tags_email.js',
- 'mail/static/src/js/many2one_avatar_user.js',
+ 'mail/static/src/js/m2x_avatar_user.js',
'mail/static/src/js/field_char.js',
'mail/static/src/js/document_viewer.js',
'mail/static/src/js/basic_view.js',
@@ -86,7 +86,7 @@
'mail/static/src/scss/thread.scss',
'mail/static/src/scss/systray.scss',
'mail/static/src/scss/mail_activity.scss',
- 'mail/static/src/scss/many2one_avatar_user.scss',
+ 'mail/static/src/scss/m2x_avatar_user.scss',
'mail/static/src/scss/activity_view.scss',
'mail/static/src/scss/kanban_view.scss',
'mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js',
@@ -280,7 +280,7 @@
'mail/static/tests/chatter_tests.js',
'mail/static/tests/mail_utils_tests.js',
'mail/static/tests/document_viewer_tests.js',
- 'mail/static/tests/many2one_avatar_user_tests.js',
+ 'mail/static/tests/m2x_avatar_user_tests.js',
'mail/static/tests/systray/systray_activity_menu_tests.js',
'mail/static/tests/tools/debug_manager_tests.js',
'mail/static/tests/activity_tests.js',
diff --git a/addons/mail/static/src/js/m2x_avatar_user.js b/addons/mail/static/src/js/m2x_avatar_user.js
new file mode 100644
index 00000000000..76ec83c440f
--- /dev/null
+++ b/addons/mail/static/src/js/m2x_avatar_user.js
@@ -0,0 +1,100 @@
+/** @odoo-module **/
+
+import fieldRegistry from 'web.field_registry';
+import { Many2OneAvatar } from 'web.relational_fields';
+import { FieldMany2ManyTagsAvatar } from 'web.relational_fields';
+
+const { Component } = owl;
+
+
+// This module defines extensions of the Many2OneAvatar and Many2ManyAvatar
+// widgets, which are integrated with the messaging system. They are designed
+// to display people, and when the avatar of those people is clicked, it
+// opens a DM chat window with the corresponding user.
+//
+// These widgets are supported on many2one and many2many fields pointing to
+// 'res.users'.
+//
+// Usage:
+//
+//
+// The widgets are designed to be extended, to support fields pointing to other
+// models than 'res.users'.
+
+const M2XAvatarMixin = {
+ supportedModels: ['res.users'],
+
+ init() {
+ this._super(...arguments);
+ if (!this.supportedModels.includes(this.field.relation)) {
+ throw new Error(`This widget is only supported on many2one and many2many fields pointing to ${JSON.stringify(this.supportedModels)}`);
+ }
+ this.className += ' o_clickable_m2x_avatar';
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Opens a chat window with the given user id.
+ *
+ * @private
+ * @param {Object} params
+ * @returns {Promise}
+ */
+ _openChat(params) {
+ const env = Component.env;
+ return env.messaging.openChat(params);
+ },
+};
+
+export const Many2OneAvatarUser = Many2OneAvatar.extend(M2XAvatarMixin, {
+ events: Object.assign({}, Many2OneAvatar.prototype.events, {
+ 'click .o_m2o_avatar > img': '_onAvatarClicked',
+ }),
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * When the avatar is clicked, open a DM chat window with the
+ * corresponding user.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAvatarClicked(ev) {
+ ev.stopPropagation(); // in list view, prevent from opening the record
+ this._openChat({ userId: this.value.res_id });
+ }
+});
+
+export const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({
+ _template: 'mail.KanbanMany2OneAvatarUser',
+});
+
+export const Many2ManyAvatarUser = FieldMany2ManyTagsAvatar.extend(M2XAvatarMixin, {
+ events: Object.assign({}, FieldMany2ManyTagsAvatar.prototype.events, {
+ 'click .o_m2m_avatar': '_onAvatarClicked',
+ }),
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAvatarClicked(ev) {
+ ev.stopPropagation(); // in list view, prevent from opening the record
+ const userId = parseInt(ev.target.getAttribute('data-id'), 10);
+ this._openChat({ userId: userId });
+ },
+});
+
+fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser);
+fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser);
+fieldRegistry.add('many2many_avatar_user', Many2ManyAvatarUser);
diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js
deleted file mode 100644
index 391b46dd062..00000000000
--- a/addons/mail/static/src/js/many2one_avatar_user.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/** @odoo-module **/
-
-// 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 user.
-//
-// This widget is supported on many2one fields pointing to 'res.users'.
-//
-// Usage:
-//
-//
-// The widget is designed to be extended, to support many2one fields pointing
-// to other models than 'res.users'.
-
-import fieldRegistry from 'web.field_registry';
-import { Many2OneAvatar } from 'web.relational_fields';
-
-const { Component } = owl;
-
-const Many2OneAvatarUser = Many2OneAvatar.extend({
- events: Object.assign({}, Many2OneAvatar.prototype.events, {
- 'click .o_m2o_avatar > img': '_onAvatarClicked',
- }),
- // This widget is only supported on many2ones pointing to 'res.users'
- supportedModels: ['res.users'],
-
- init() {
- this._super(...arguments);
- if (!this.supportedModels.includes(this.field.relation)) {
- throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`);
- }
- },
-
- //----------------------------------------------------------------------
- // Handlers
- //----------------------------------------------------------------------
-
- /**
- * When the avatar is clicked, open a DM chat window with the
- * corresponding user.
- *
- * @private
- * @param {MouseEvent} ev
- */
- async _onAvatarClicked(ev) {
- ev.stopPropagation(); // in list view, prevent from opening the record
- const env = Component.env;
- await env.messaging.openChat({ userId: this.value.res_id });
- }
-});
-
-const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({
- _template: 'mail.KanbanMany2OneAvatarUser',
-});
-
-fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser);
-fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser);
-
-export {
- Many2OneAvatarUser,
- KanbanMany2OneAvatarUser,
-};
diff --git a/addons/mail/static/src/scss/m2x_avatar_user.scss b/addons/mail/static/src/scss/m2x_avatar_user.scss
new file mode 100644
index 00000000000..5772dab986e
--- /dev/null
+++ b/addons/mail/static/src/scss/m2x_avatar_user.scss
@@ -0,0 +1,19 @@
+@mixin o-avatar-image-hover {
+ cursor: pointer;
+ filter: brightness(0.8);
+}
+
+.o_field_many2one_avatar.o_clickable_m2x_avatar {
+ .o_m2o_avatar:hover {
+ @include o-avatar-image-hover;
+ }
+}
+
+.o_field_many2manytags.avatar.o_clickable_m2x_avatar {
+ img.o_m2m_avatar {
+ margin-right: 2px;
+ &:hover {
+ @include o-avatar-image-hover;
+ }
+ }
+}
diff --git a/addons/mail/static/tests/many2one_avatar_user_tests.js b/addons/mail/static/tests/m2x_avatar_user_tests.js
similarity index 57%
rename from addons/mail/static/tests/many2one_avatar_user_tests.js
rename to addons/mail/static/tests/m2x_avatar_user_tests.js
index fb6a6d62788..0852641d5d3 100644
--- a/addons/mail/static/tests/many2one_avatar_user_tests.js
+++ b/addons/mail/static/tests/m2x_avatar_user_tests.js
@@ -2,14 +2,15 @@
import { afterEach, beforeEach, start } from '@mail/utils/test_utils';
+import FormView from 'web.FormView';
import KanbanView from 'web.KanbanView';
import ListView from 'web.ListView';
-import { Many2OneAvatarUser } from '@mail/js/many2one_avatar_user';
+import { Many2OneAvatarUser } from '@mail/js/m2x_avatar_user';
import { dom, mock } from 'web.test_utils';
QUnit.module('mail', {}, function () {
- QUnit.module('Many2OneAvatarUser', {
+ QUnit.module('M2XAvatarUser', {
beforeEach() {
beforeEach(this);
@@ -20,9 +21,10 @@ QUnit.module('mail', {}, function () {
'foo': {
fields: {
user_id: { string: "User", type: 'many2one', relation: 'res.users' },
+ user_ids: { string: "Users", type: "many2many", relation: 'res.users' },
},
records: [
- { id: 1, user_id: 11 },
+ { id: 1, user_id: 11, user_ids: [11, 23], },
{ id: 2, user_id: 7 },
{ id: 3, user_id: 11 },
{ id: 4, user_id: 23 },
@@ -118,4 +120,82 @@ QUnit.module('mail', {}, function () {
kanban.destroy();
});
+
+ QUnit.test('many2many_avatar_user widget in form view', async function (assert) {
+ assert.expect(7);
+
+ const { widget: form } = await start({
+ hasView: true,
+ 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);
+ },
+ res_id: 1,
+ });
+
+ assert.containsN(form, '.o_field_many2manytags.avatar.o_field_widget .badge', 2,
+ "should have 2 records");
+ assert.strictEqual(form.$('.o_field_many2manytags.avatar.o_field_widget .badge:first img').data('src'), '/web/image/res.users/11/image_128',
+ "should have correct avatar image");
+
+ await dom.click(form.$('.o_field_many2manytags.avatar .badge:first .o_m2m_avatar'));
+ await dom.click(form.$('.o_field_many2manytags.avatar .badge:nth(1) .o_m2m_avatar'));
+
+ assert.verifySteps([
+ "read foo 1",
+ 'read res.users 11,23',
+ "read res.users 11",
+ "read res.users 23",
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many_avatar_user widget in list view', async function (assert) {
+ assert.expect(7);
+
+ const { widget: list } = await start({
+ hasView: true,
+ 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, 'open_record', () => {
+ assert.step('open record');
+ });
+
+ assert.containsN(list.$(".o_data_cell:first"), '.o_field_many2manytags.avatar.o_field_widget .badge', 2,
+ "should have 2 records");
+ assert.strictEqual(list.$('.o_data_cell:first .o_tag_badge_text').text().trim(), 'MarioYoshi');
+
+ // 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 .o_field_many2manytags'));
+
+ await dom.click(list.$('.o_data_cell:nth(0) .o_m2m_avatar:nth(0)'));
+ await dom.click(list.$('.o_data_cell:nth(0) .o_m2m_avatar:nth(1)'));
+
+ assert.verifySteps([
+ 'read res.users 11,23',
+ "open record",
+ "read res.users 11",
+ "read res.users 23",
+ ]);
+
+ list.destroy();
+ });
+
});
diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss
index 21ea022c5d6..e6f725342cd 100644
--- a/addons/web/static/src/scss/fields.scss
+++ b/addons/web/static/src/scss/fields.scss
@@ -229,27 +229,28 @@
}
// Many2many tags avatar
&.avatar {
+ align-items: center;
.badge {
- padding-left: 4px;
+ padding: 0 0.6em 0 0;
img {
- height: 18px;
- width: 18px;
+ height: 17px;
+ width: 17px;
object-fit: cover;
- }
- &.o_tag_color_0 {
- background-color: #F1F1F1;
- box-shadow: none;
- border: 1px solid gray('300');
+ margin-top: 1px;
+ margin-left: 1px;
}
.o_badge_text {
display: flex;
align-items:center;
+ padding-top: 0.25em;
+ padding-bottom: 0.25em;
}
.o_tag_badge_text {
padding-left:2px;
}
.o_delete {
- margin-top: 3px;
+ padding-top: 0.25em;
+ padding-bottom: 0.25em;
}
}
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index f7a5bb592cd..14e59e0600c 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -1311,8 +1311,8 @@
-
-
+
+