From 01c460f37aafcdcd80fe1b61f16f0476b401d79f Mon Sep 17 00:00:00 2001 From: Mohammed Shekha Date: Tue, 15 Jun 2021 10:53:10 +0000 Subject: [PATCH] [IMP] mail: support many2many_avatar in kanban and list view Purpose The aim of the current task is to adapt the design of this new widget to the list and kanban views. SPECIFICATION Kanban when there is only one record set in the m2m field, display it like a many2one_avatar widget when there are two or three records set in the m2m field, display the avatars next to each other when there are more than three records set in the m2m field, display the first two avatars, then a grey circle with +X in it (where X is the number of records beyond the first two), when hovering the +X avatar, open a tooltip with the list of the remaining records the display order of the records is the same as the order in the m2m field the avatars behave like in the other avatar widgets (i.e. darkens on hover and clicking on it opens the chat) except the +X avatar that does not darken or has a cursor:pointer; on hover and is not clickable List same specs as for the kanban view, with the difference that the widget displays up to five records instead of three when the widget is editable display it like the current formview version of the many2many_avatar_user widget (i.e. tags with avatars) task-2563591 closes odoo/odoo#72166 Signed-off-by: Simon Genin (ges@odoo) --- .../hr/static/src/js/m2x_avatar_employee.js | 17 +- .../static/tests/m2x_avatar_employee_tests.js | 56 +++++- addons/mail/static/src/js/m2x_avatar_user.js | 12 +- .../static/tests/m2x_avatar_user_tests.js | 114 ++++++++++- .../src/legacy/js/fields/field_registry.js | 2 + .../src/legacy/js/fields/relational_fields.js | 68 +++++++ addons/web/static/src/legacy/scss/fields.scss | 21 ++ .../static/src/legacy/scss/kanban_view.scss | 8 + .../web/static/src/legacy/scss/list_view.scss | 7 + addons/web/static/src/legacy/xml/base.xml | 42 ++++ .../legacy/fields/relational_fields_tests.js | 184 ++++++++++++++++++ 11 files changed, 522 insertions(+), 9 deletions(-) diff --git a/addons/hr/static/src/js/m2x_avatar_employee.js b/addons/hr/static/src/js/m2x_avatar_employee.js index 5e6961fab50..3530b6de164 100644 --- a/addons/hr/static/src/js/m2x_avatar_employee.js +++ b/addons/hr/static/src/js/m2x_avatar_employee.js @@ -2,8 +2,9 @@ import fieldRegistry from 'web.field_registry'; -import { Many2OneAvatarUser, KanbanMany2OneAvatarUser } from '@mail/js/m2x_avatar_user'; +import { Many2OneAvatarUser, KanbanMany2OneAvatarUser, KanbanMany2ManyAvatarUser, ListMany2ManyAvatarUser } from '@mail/js/m2x_avatar_user'; import { Many2ManyAvatarUser } from '@mail/js/m2x_avatar_user'; +import { KanbanMany2ManyTagsAvatar, ListMany2ManyTagsAvatar } from 'web.relational_fields'; // This module defines variants of the Many2OneAvatarUser and Many2ManyAvatarUser @@ -44,13 +45,25 @@ export const KanbanMany2OneAvatarEmployee = KanbanMany2OneAvatarUser.extend(M2XA fieldRegistry.add('many2one_avatar_employee', Many2OneAvatarEmployee); fieldRegistry.add('kanban.many2one_avatar_employee', KanbanMany2OneAvatarEmployee); -export const Many2ManyAvatarEmployee = Many2ManyAvatarUser.extend(M2XAvatarEmployeeMixin, { +const M2MAvatarEmployeeMixin = Object.assign(M2XAvatarEmployeeMixin, { + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + _getEmployeeID(ev) { return parseInt(ev.target.getAttribute('data-id'), 10); }, }); +export const Many2ManyAvatarEmployee = Many2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {}); + +export const KanbanMany2ManyAvatarEmployee = KanbanMany2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {}); + +export const ListMany2ManyAvatarEmployee = ListMany2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {}); + fieldRegistry.add('many2many_avatar_employee', Many2ManyAvatarEmployee); +fieldRegistry.add('kanban.many2many_avatar_employee', KanbanMany2ManyAvatarEmployee); +fieldRegistry.add('list.many2many_avatar_employee', ListMany2ManyAvatarEmployee); export default { Many2OneAvatarEmployee, diff --git a/addons/hr/static/tests/m2x_avatar_employee_tests.js b/addons/hr/static/tests/m2x_avatar_employee_tests.js index 8e943ed5f08..db7baf0cf00 100644 --- a/addons/hr/static/tests/m2x_avatar_employee_tests.js +++ b/addons/hr/static/tests/m2x_avatar_employee_tests.js @@ -280,7 +280,8 @@ QUnit.module('hr', {}, function () { }, }); - assert.strictEqual(list.$('.o_data_cell:first .o_tag_badge_text').text().trim(), 'MarioYoshi'); + assert.containsN(list, '.o_data_cell:first .o_field_many2manytags > span', 2, + "should have two avatar"); // click on first employee badge await afterNextRender(() => @@ -324,6 +325,59 @@ QUnit.module('hr', {}, function () { list.destroy(); }); + QUnit.test('many2many_avatar_employee widget in kanban view', async function (assert) { + assert.expect(7); + + const { widget: kanban } = await start({ + hasView: true, + View: KanbanView, + 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); + }, + }); + + assert.containsN(kanban, '.o_kanban_record:first .o_field_many2manytags img.o_m2m_avatar', 2, + "should have 2 avatar images"); + assert.strictEqual(kanban.$('.o_kanban_record:first .o_field_many2manytags img.o_m2m_avatar:first').data('src'), + "/web/image/hr.employee.public/11/avatar_128", + "should have correct avatar image"); + assert.strictEqual(kanban.$('.o_kanban_record:first .o_field_many2manytags img.o_m2m_avatar:eq(1)').data('src'), + "/web/image/hr.employee.public/23/avatar_128", + "should have correct avatar image"); + + await dom.click(kanban.$('.o_kanban_record:first .o_m2m_avatar:nth(0)')); + await dom.click(kanban.$('.o_kanban_record:first .o_m2m_avatar:nth(1)')); + + assert.verifySteps([ + "read hr.employee.public 11,23", + "read hr.employee.public 11", + "read hr.employee.public 23" + ]); + + kanban.destroy(); + }); + QUnit.test('many2many_avatar_employee: click on an employee not associated with a user', async function (assert) { assert.expect(10); diff --git a/addons/mail/static/src/js/m2x_avatar_user.js b/addons/mail/static/src/js/m2x_avatar_user.js index c8f90b2feab..419a7b650f8 100644 --- a/addons/mail/static/src/js/m2x_avatar_user.js +++ b/addons/mail/static/src/js/m2x_avatar_user.js @@ -2,7 +2,7 @@ import fieldRegistry from 'web.field_registry'; import { Many2OneAvatar } from 'web.relational_fields'; -import { FieldMany2ManyTagsAvatar } from 'web.relational_fields'; +import { FieldMany2ManyTagsAvatar, KanbanMany2ManyTagsAvatar, ListMany2ManyTagsAvatar } from 'web.relational_fields'; const { Component } = owl; @@ -75,7 +75,7 @@ export const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({ _template: 'mail.KanbanMany2OneAvatarUser', }); -export const Many2ManyAvatarUser = FieldMany2ManyTagsAvatar.extend(M2XAvatarMixin, { +const M2MAvatarMixin = Object.assign(M2XAvatarMixin, { events: Object.assign({}, FieldMany2ManyTagsAvatar.prototype.events, { 'click .o_m2m_avatar': '_onAvatarClicked', }), @@ -95,7 +95,15 @@ export const Many2ManyAvatarUser = FieldMany2ManyTagsAvatar.extend(M2XAvatarMixi }, }); +export const Many2ManyAvatarUser = FieldMany2ManyTagsAvatar.extend(M2MAvatarMixin, {}); + +export const KanbanMany2ManyAvatarUser = KanbanMany2ManyTagsAvatar.extend(M2MAvatarMixin, {}); + +export const ListMany2ManyAvatarUser = ListMany2ManyTagsAvatar.extend(M2MAvatarMixin, {}); + fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser); fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser); fieldRegistry.add('activity.many2one_avatar_user', KanbanMany2OneAvatarUser); fieldRegistry.add('many2many_avatar_user', Many2ManyAvatarUser); +fieldRegistry.add('kanban.many2many_avatar_user', KanbanMany2ManyAvatarUser); +fieldRegistry.add('list.many2many_avatar_user', ListMany2ManyAvatarUser); diff --git a/addons/mail/static/tests/m2x_avatar_user_tests.js b/addons/mail/static/tests/m2x_avatar_user_tests.js index 2ac5870beef..7e4f4350046 100644 --- a/addons/mail/static/tests/m2x_avatar_user_tests.js +++ b/addons/mail/static/tests/m2x_avatar_user_tests.js @@ -6,7 +6,7 @@ import FormView from 'web.FormView'; import KanbanView from 'web.KanbanView'; import ListView from 'web.ListView'; import { Many2OneAvatarUser } from '@mail/js/m2x_avatar_user'; -import { dom, mock } from 'web.test_utils'; +import { dom, mock, nextTick } from 'web.test_utils'; QUnit.module('mail', {}, function () { @@ -157,8 +157,35 @@ QUnit.module('mail', {}, function () { form.destroy(); }); + QUnit.test('many2many_avatar_user widget with single record in list view', async function (assert) { + assert.expect(4); + + this.data.foo.records[1].user_ids = [11]; + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'foo', + data: this.data, + arch: '', + res_id: 1, + }); + + assert.containsN(list, '.o_data_row:eq(0) .o_field_many2manytags.avatar.o_field_widget .o_m2m_avatar', 2, + "should have 2 records"); + assert.containsN(list, '.o_data_row:eq(1) .o_field_many2manytags.avatar.o_field_widget > div > span', 1, + "should have 1 record in second row"); + assert.containsN(list, '.o_data_row:eq(1) .o_field_many2manytags.avatar.o_field_widget > div > span', 1, + "should have img and span in second record"); + + await dom.click(list.$('.o_data_row:eq(1) .o_field_many2manytags.avatar:first > div > span')); + assert.containsOnce(list, '.o_selected_row'); + + list.destroy(); + }); + QUnit.test('many2many_avatar_user widget in list view', async function (assert) { - assert.expect(7); + assert.expect(8); const { widget: list } = await start({ hasView: true, @@ -178,9 +205,14 @@ QUnit.module('mail', {}, function () { assert.step('open record'); }); - assert.containsN(list.$(".o_data_cell:first"), '.o_field_many2manytags.avatar.o_field_widget .badge', 2, + assert.containsN(list.$(".o_data_cell:first"), '.o_field_many2manytags.avatar.o_field_widget span', 2, "should have 2 records"); - assert.strictEqual(list.$('.o_data_cell:first .o_tag_badge_text').text().trim(), 'MarioYoshi'); + assert.strictEqual(list.$(".o_data_cell:first .o_field_many2manytags.avatar img.o_m2m_avatar:first").data('src'), + "/web/image/res.users/11/avatar_128", + "should have right image"); + assert.strictEqual(list.$(".o_data_cell:eq(0) .o_field_many2manytags.avatar img.o_m2m_avatar:eq(1)").data('src'), + "/web/image/res.users/23/avatar_128", + "should have right image"); // 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')); @@ -198,4 +230,78 @@ QUnit.module('mail', {}, function () { list.destroy(); }); + QUnit.test('many2many_avatar_user in kanban view', async function (assert) { + assert.expect(11); + + this.data['res.users'].records.push({ id: 15, name: "Tapu", partner_id: 11 },); + this.data.foo.records[2].user_ids = [11, 23, 7, 15]; + + const { widget: kanban } = await start({ + hasView: true, + View: KanbanView, + 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(kanban, 'open_record', () => { + assert.step('open record'); + }); + + assert.strictEqual(kanban.$('.o_kanban_record:first .o_field_many2manytags img.o_m2m_avatar:first').data('src'), + "/web/image/res.users/11/avatar_128", + "should have correct avatar image"); + assert.strictEqual(kanban.$('.o_kanban_record:first .o_field_many2manytags img.o_m2m_avatar:eq(1)').data('src'), + "/web/image/res.users/23/avatar_128", + "should have correct avatar image"); + + assert.containsN(kanban, '.o_kanban_record:eq(2) .o_field_many2manytags > span:not(.o_m2m_avatar_empty)', 2, + "should have 2 records"); + assert.strictEqual(kanban.$('.o_kanban_record:eq(2) .o_field_many2manytags img.o_m2m_avatar:first').data('src'), + "/web/image/res.users/11/avatar_128", + "should have correct avatar image"); + assert.strictEqual(kanban.$('.o_kanban_record:eq(2) .o_field_many2manytags img.o_m2m_avatar:eq(1)').data('src'), + "/web/image/res.users/23/avatar_128", + "should have correct avatar image"); + assert.containsOnce(kanban, '.o_kanban_record:eq(2) .o_field_many2manytags .o_m2m_avatar_empty', + "should have o_m2m_avatar_empty span"); + assert.strictEqual(kanban.$('.o_kanban_record:eq(2) .o_field_many2manytags .o_m2m_avatar_empty').text().trim(), "+2", + "should have +2 in o_m2m_avatar_empty"); + + kanban.$('.o_kanban_record:eq(2) .o_field_many2manytags .o_m2m_avatar_empty').trigger($.Event('mouseenter')); + await nextTick(); + assert.containsOnce(kanban, '.popover', + "should open a popover hover on o_m2m_avatar_empty"); + assert.strictEqual(kanban.$('.popover .popover-body > div').text().trim(), "LuigiTapu", + "should have a right text in popover"); + + assert.verifySteps([ + "read res.users 7,11,15,23", + ]); + + kanban.destroy(); + }); + }); diff --git a/addons/web/static/src/legacy/js/fields/field_registry.js b/addons/web/static/src/legacy/js/fields/field_registry.js index 5331f6feb00..d8b23ca60eb 100644 --- a/addons/web/static/src/legacy/js/fields/field_registry.js +++ b/addons/web/static/src/legacy/js/fields/field_registry.js @@ -87,6 +87,8 @@ registry .add('many2many_binary', relational_fields.FieldMany2ManyBinaryMultiFiles) .add('many2many_tags', relational_fields.FieldMany2ManyTags) .add('many2many_tags_avatar', relational_fields.FieldMany2ManyTagsAvatar) + .add('kanban.many2many_tags_avatar', relational_fields.KanbanMany2ManyTagsAvatar) + .add('list.many2many_tags_avatar', relational_fields.ListMany2ManyTagsAvatar) .add('form.many2many_tags', relational_fields.FormFieldMany2ManyTags) .add('kanban.many2many_tags', relational_fields.KanbanFieldMany2ManyTags) .add('many2many_checkboxes', relational_fields.FieldMany2ManyCheckBoxes) diff --git a/addons/web/static/src/legacy/js/fields/relational_fields.js b/addons/web/static/src/legacy/js/fields/relational_fields.js index 0bdda5eedbe..ccce78a9c9a 100644 --- a/addons/web/static/src/legacy/js/fields/relational_fields.js +++ b/addons/web/static/src/legacy/js/fields/relational_fields.js @@ -2772,6 +2772,72 @@ var FieldMany2ManyTagsAvatar = FieldMany2ManyTags.extend({ }, }); + +// Remove event handlers on this widget to ensure that the kanban 'global +// click' opens the clicked record +const { click, ...M2MAvatarMixinEvents } = AbstractField.prototype.events; +const M2MAvatarMixin = { + visibleAvatarCount: 3, // number of visible avatar + events: M2MAvatarMixinEvents, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Open tooltip on empty avatar clicked + * + * @private + */ + _bindPopover(ev) { + this.$('.o_m2m_avatar_empty').popover({ + container: this.$el, + trigger: 'hover', + html: true, + placement: 'auto', + content: () => { + const elements = this.value ? _.pluck(this.value.data, 'data') : []; + return qweb.render('Many2ManyTagAvatarPopover', { + elements: elements.slice(this.visibleAvatarCount - 1), + }); + }, + }); + }, + /** + * @override + */ + _getRenderTagsContext() { + const result = this._super(...arguments); + result['widget'] = this; + return result; + }, + /** + * @override + */ + _renderReadonly() { + this.$el.addClass('o_field_many2manytags_multi'); + return this._super(...arguments); + }, + /** + * Override to bind popover + * + * @override + */ + _renderTags() { + this._super(...arguments); + this._bindPopover(); + }, +} + +const KanbanMany2ManyTagsAvatar = FieldMany2ManyTagsAvatar.extend(M2MAvatarMixin, { + tag_template: 'KanbanMany2ManyTagAvatar', +}); + +const ListMany2ManyTagsAvatar = FieldMany2ManyTagsAvatar.extend(M2MAvatarMixin, { + tag_template: 'ListMany2ManyTagAvatar', + visibleAvatarCount: 5, +}); + var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ events: _.extend({}, FieldMany2ManyTags.prototype.events, { 'click .dropdown-toggle': '_onOpenColorPicker', @@ -3697,6 +3763,8 @@ return { FieldMany2ManyCheckBoxes: FieldMany2ManyCheckBoxes, FieldMany2ManyTags: FieldMany2ManyTags, FieldMany2ManyTagsAvatar: FieldMany2ManyTagsAvatar, + KanbanMany2ManyTagsAvatar: KanbanMany2ManyTagsAvatar, + ListMany2ManyTagsAvatar: ListMany2ManyTagsAvatar, FormFieldMany2ManyTags: FormFieldMany2ManyTags, KanbanFieldMany2ManyTags: KanbanFieldMany2ManyTags, diff --git a/addons/web/static/src/legacy/scss/fields.scss b/addons/web/static/src/legacy/scss/fields.scss index 022c35e8513..bc39b966d57 100644 --- a/addons/web/static/src/legacy/scss/fields.scss +++ b/addons/web/static/src/legacy/scss/fields.scss @@ -296,6 +296,27 @@ } } } + &.o_field_many2manytags_multi { + margin-bottom: 0px; + > span { + display: inline-block; + margin-right: 2px; + } + .o_m2m_avatar, .o_m2m_avatar_empty { + width: 20px; + height: 20px; + margin-left: 0px; + } + .o_m2m_avatar_empty { + background-color: #dee2e6; + vertical-align: bottom; + } + &.avatar.o_clickable_m2x_avatar { + img.o_m2m_avatar { + margin-right: 0px; + } + } + } } // Stars diff --git a/addons/web/static/src/legacy/scss/kanban_view.scss b/addons/web/static/src/legacy/scss/kanban_view.scss index b49c3f6ad18..1f0acbd27a9 100644 --- a/addons/web/static/src/legacy/scss/kanban_view.scss +++ b/addons/web/static/src/legacy/scss/kanban_view.scss @@ -267,6 +267,14 @@ line-height: 1.2; word-break: break-all; + &.avatar { + margin: 0 6px; + .o_m2m_avatar_empty > span { + display: block; + margin-top: 3px; + } + } + .o_tag { display: inline-block; margin-right: 4px; diff --git a/addons/web/static/src/legacy/scss/list_view.scss b/addons/web/static/src/legacy/scss/list_view.scss index 857e0cbd80b..3616a32da68 100644 --- a/addons/web/static/src/legacy/scss/list_view.scss +++ b/addons/web/static/src/legacy/scss/list_view.scss @@ -244,6 +244,13 @@ pointer-events: auto; } } + &.o_many2many_avatar_user_cell { + .o_field_many2manytags { + > span { + margin-right: 2px; + } + } + } } .o_data_row:not(.o_selected_row) { diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index f9b91f8f3cd..329fdfa66e7 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -1336,6 +1336,48 @@ + + + + + + + + 9+ + + + + + + + + + + + +
+ + +
+
+ + + + + + + + 9+ + + + + + + +
+ + +
+
+