diff --git a/addons/hr/static/tests/m2x_avatar_employee_tests.js b/addons/hr/static/tests/m2x_avatar_employee_tests.js index 001936f1eb6..acb1e2b9a2f 100644 --- a/addons/hr/static/tests/m2x_avatar_employee_tests.js +++ b/addons/hr/static/tests/m2x_avatar_employee_tests.js @@ -366,8 +366,8 @@ QUnit.module('hr', {}, function () { res_model: 'm2x.avatar.employee', views: [[false, 'kanban']], }); - assert.containsN(document.body, '.o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar', 2, - "should have 2 avatar images"); + assert.containsN(document.body, '.o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar', 3, + "should have 2 avatar images and the default empty avatar"); assert.strictEqual(document.querySelector('.o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar').getAttribute('data-src'), `/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`, "should have correct avatar image"); diff --git a/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.js b/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.js index a2940336605..69fa66c7f8d 100644 --- a/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.js +++ b/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.js @@ -1,12 +1,16 @@ /** @odoo-module **/ +import { patch } from "@web/core/utils/patch"; import { registry } from "@web/core/registry"; import { TagsList } from "@web/views/fields/many2many_tags/tags_list"; import { Many2ManyTagsAvatarField, - ListKanbanMany2ManyTagsAvatarField, many2ManyTagsAvatarField, - listKanbanMany2ManyTagsAvatarField, + ListMany2ManyTagsAvatarField, + listMany2ManyTagsAvatarField, + KanbanMany2ManyTagsAvatarField, + kanbanMany2ManyTagsAvatarField, + KanbanMany2ManyTagsAvatarFieldTagsList, } from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field"; import { useOpenChat } from "@mail/views/open_chat_hook"; import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hook"; @@ -14,28 +18,29 @@ import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hoo export class Many2ManyAvatarUserTagsList extends TagsList {} Many2ManyAvatarUserTagsList.template = "mail.Many2ManyAvatarUserTagsList"; -export class Many2ManyTagsAvatarUserField extends Many2ManyTagsAvatarField { +const userChatter = { setup() { - super.setup(); + this._super(...arguments); this.openChat = useOpenChat(this.relation); if (this.props.withCommand) { useAssignUserCommand(); } - } + }, - get tags() { - return super.tags.map((tag) => ({ - ...tag, - onImageClicked: () => { - this.openChat(tag.resId); - }, - })); - } -} -Many2ManyTagsAvatarUserField.components = { - ...Many2ManyTagsAvatarField.components, - TagsList: Many2ManyAvatarUserTagsList, + getTagProps(record) { + return { + ...this._super(...arguments), + onImageClicked: () => this.openChat(record.resId), + }; + }, }; +export class Many2ManyTagsAvatarUserField extends Many2ManyTagsAvatarField { + static components = { + ...Many2ManyTagsAvatarField.components, + TagsList: Many2ManyAvatarUserTagsList, + }; +} +patch(Many2ManyTagsAvatarUserField.prototype, "mail", userChatter); export const many2ManyTagsAvatarUserField = { ...many2ManyTagsAvatarField, @@ -45,56 +50,48 @@ export const many2ManyTagsAvatarUserField = { registry.category("fields").add("many2many_avatar_user", many2ManyTagsAvatarUserField); -export class KanbanMany2ManyTagsAvatarUserField extends ListKanbanMany2ManyTagsAvatarField { - static props = { - ...ListKanbanMany2ManyTagsAvatarField.props, - displayText: { type: Boolean, optional: true }, +export class KanbanMany2ManyAvatarUserTagsList extends KanbanMany2ManyTagsAvatarFieldTagsList { + static template = "mail.KanbanMany2ManyAvatarUserTagsList"; +} + +export class KanbanMany2ManyTagsAvatarUserField extends KanbanMany2ManyTagsAvatarField { + static template = "mail.KanbanMany2ManyTagsAvatarUserField"; + static components = { + ...KanbanMany2ManyTagsAvatarField.components, + TagsList: KanbanMany2ManyAvatarUserTagsList, }; - - setup() { - super.setup(); - this.openChat = useOpenChat(this.relation); - if (this.props.withCommand) { - useAssignUserCommand(); - } - } - get displayText() { - return ( - (this.props.displayText && this.props.record.data[this.props.name].records.length === 1) || - !this.props.readonly - ); - } - - get tags() { - const recordFromId = (id) => - this.props.record.data[this.props.name].records.find((rec) => rec.id === id); - return super.tags.map((tag) => ({ - ...tag, - onImageClicked: () => { - this.openChat(recordFromId(tag.id).resId); - }, - })); + return !this.props.readonly; } } -KanbanMany2ManyTagsAvatarUserField.template = "mail.KanbanMany2ManyTagsAvatarUserField"; -KanbanMany2ManyTagsAvatarUserField.components = { - ...ListKanbanMany2ManyTagsAvatarField.components, - TagsList: Many2ManyAvatarUserTagsList, -}; - +patch(KanbanMany2ManyTagsAvatarUserField.prototype, "mail", userChatter); export const kanbanMany2ManyTagsAvatarUserField = { - ...listKanbanMany2ManyTagsAvatarField, + ...kanbanMany2ManyTagsAvatarField, component: KanbanMany2ManyTagsAvatarUserField, - additionalClasses: ["o_field_many2many_tags_avatar"], - extractProps: (fieldInfo) => ({ - ...listKanbanMany2ManyTagsAvatarField.extractProps(fieldInfo), - displayText: fieldInfo.viewType === "list", - }), + additionalClasses: ["o_field_many2many_tags_avatar", "o_field_many2many_tags_avatar_kanban"], +}; +registry.category("fields").add("kanban.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField); + +export class ListMany2ManyTagsAvatarUserField extends ListMany2ManyTagsAvatarField { + static template = "mail.ListMany2ManyTagsAvatarUserField"; + static components = { + ...ListMany2ManyTagsAvatarField.components, + TagsList: Many2ManyAvatarUserTagsList, + }; + + get displayText() { + return this.props.record.data[this.props.name].records.length === 1 || !this.props.readonly; + } +} +patch(ListMany2ManyTagsAvatarUserField.prototype, "mail", userChatter); + +export const listMany2ManyTagsAvatarUserField = { + ...listMany2ManyTagsAvatarField, + component: ListMany2ManyTagsAvatarUserField, + additionalClasses: ["o_field_many2many_tags_avatar", "o_field_many2many_tags_avatar_list"], }; -registry.category("fields").add("kanban.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField); -registry.category("fields").add("list.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField); +registry.category("fields").add("list.many2many_avatar_user", listMany2ManyTagsAvatarUserField); registry .category("fields") .add("activity.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField); diff --git a/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml b/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml index abfc51ac305..58193422aba 100644 --- a/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml +++ b/addons/mail/static/src/views/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml @@ -6,7 +6,20 @@ - + + + tag.onImageClicked + + + + + + !props.readonly + displayText + + + + !props.readonly displayText diff --git a/addons/mail/static/tests/qunit_suite_tests/m2x_avatar_user_tests.js b/addons/mail/static/tests/qunit_suite_tests/m2x_avatar_user_tests.js index 7b70a22b6e7..b3a019e6277 100644 --- a/addons/mail/static/tests/qunit_suite_tests/m2x_avatar_user_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/m2x_avatar_user_tests.js @@ -78,11 +78,11 @@ QUnit.module("mail", {}, function () { }); QUnit.test("many2many_avatar_user in kanban view", async function (assert) { - assert.expect(5); + assert.expect(8); patchWithCleanup(browser, { setTimeout: async (fn) => { - await new Promise((r) => setTimeout(r)) + await new Promise((r) => setTimeout(r)); fn(); }, }); @@ -136,26 +136,34 @@ QUnit.module("mail", {}, function () { "+2", "should have +2 in o_m2m_avatar_empty" ); - - document - .querySelector(".o_kanban_record .o_field_many2many_avatar_user .o_m2m_avatar_empty") - .dispatchEvent(new Event("mouseenter")); - await nextTick(); + await click( + document.querySelector( + ".o_kanban_record .o_field_many2many_avatar_user .o_m2m_avatar_empty" + ) + ); assert.containsOnce( document.body, ".popover", "should open a popover hover on o_m2m_avatar_empty" ); + const tags = document.querySelectorAll(".o_popover > .o_field_tags > .o_tag"); + assert.strictEqual(tags.length, 4, "should have 4 tags"); assert.strictEqual( - document.querySelector(".popover .o-tooltip > div").innerText.trim(), + tags[0].innerText.trim(), + "Mario", + "should have a right text in popover" + ); + assert.strictEqual( + tags[1].innerText.trim(), + "Yoshi", + "should have a right text in popover" + ); + assert.strictEqual( + tags[2].innerText.trim(), "Luigi", "should have a right text in popover" ); - assert.strictEqual( - document.querySelectorAll(".popover .o-tooltip > div")[1].innerText.trim(), - "Tapu", - "should have a right text in popover" - ); + assert.strictEqual(tags[3].innerText.trim(), "Tapu", "should have a right text in popover"); }); QUnit.test( diff --git a/addons/web/static/src/views/fields/many2many_tags/tags_list.js b/addons/web/static/src/views/fields/many2many_tags/tags_list.js index 0ba482aa122..77690758d22 100644 --- a/addons/web/static/src/views/fields/many2many_tags/tags_list.js +++ b/addons/web/static/src/views/fields/many2many_tags/tags_list.js @@ -17,10 +17,12 @@ export class TagsList extends Component { itemsVisible: { type: Number, optional: true }, tags: { type: Object, optional: true }, }; - + get visibleTagsCount() { + return this.props.itemsVisible - 1; + } get visibleTags() { if (this.props.itemsVisible && this.props.tags.length > this.props.itemsVisible) { - return this.props.tags.slice(0, this.props.itemsVisible - 1); + return this.props.tags.slice(0, this.visibleTagsCount); } return this.props.tags; } @@ -28,7 +30,7 @@ export class TagsList extends Component { if (!this.props.itemsVisible || this.props.tags.length <= this.props.itemsVisible) { return []; } - return this.props.tags.slice(this.props.itemsVisible - 1); + return this.props.tags.slice(this.visibleTagsCount); } get tooltipInfo() { return JSON.stringify({ diff --git a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.js b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.js index 54697cb3ef4..baf3ef89b96 100644 --- a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.js +++ b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.js @@ -1,5 +1,7 @@ /** @odoo-module **/ +import { isMobileOS } from "@web/core/browser/feature_detection"; +import { usePopover } from "@web/core/popover/popover_hook"; import { registry } from "@web/core/registry"; import { Many2XAutocomplete } from "@web/views/fields/relational_utils"; import { @@ -7,6 +9,7 @@ import { Many2ManyTagsField, } from "@web/views/fields/many2many_tags/many2many_tags_field"; import { TagsList } from "../many2many_tags/tags_list"; +import { onMounted, useRef, useState } from "@odoo/owl"; export class Many2ManyTagsAvatarField extends Many2ManyTagsField { static template = "web.Many2ManyTagsAvatarField"; @@ -18,13 +21,11 @@ export class Many2ManyTagsAvatarField extends Many2ManyTagsField { ...Many2ManyTagsField.props, withCommand: { type: Boolean, optional: true }, }; - - get tags() { - return super.tags.map((tag) => ({ - ...tag, - img: `/web/image/${this.relation}/${tag.resId}/avatar_128`, - onDelete: !this.props.readonly ? () => this.deleteTag(tag.id) : undefined, - })); + getTagProps(record) { + return { + ...super.getTagProps(record), + img: `/web/image/${this.relation}/${record.resId}/avatar_128`, + }; } } @@ -39,32 +40,153 @@ export const many2ManyTagsAvatarField = { registry.category("fields").add("many2many_tags_avatar", many2ManyTagsAvatarField); -export class ListKanbanMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField { +export class ListMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField { + itemsVisible = 5; +} + +export const listMany2ManyTagsAvatarField = { + ...many2ManyTagsAvatarField, + component: ListMany2ManyTagsAvatarField, +}; + +registry.category("fields").add("list.many2many_tags_avatar", listMany2ManyTagsAvatarField); + +export class Many2ManyTagsAvatarFieldPopover extends Many2ManyTagsAvatarField { + static template = "web.Many2ManyTagsAvatarFieldPopover"; static props = { ...Many2ManyTagsAvatarField.props, - itemsVisible: { type: Number, optional: true }, + close: { type: Function }, + deleteTag: { type: Function }, + updateTag: { type: Function }, }; - static defaultProps = { - ...Many2ManyTagsAvatarField.defaultProps, - itemsVisible: 3, + + setup() { + super.setup(); + this.state = useState({ tags: this.tags }); + this.update = async (recordList) => { + const updatedVal = await this.props.updateTag(recordList); + this.state.tags = updatedVal.map((tag) => ({ + ...tag, + onDelete: () => this.deleteTag(tag.id), + })); + }; + onMounted(() => { + this.autoCompleteRef.el.querySelector("input").focus(); + }); + } + async deleteTag(id) { + const updatedVal = await this.props.deleteTag(id); + this.state.tags = updatedVal.map((tag) => ({ + ...tag, + onDelete: () => this.deleteTag(tag.id), + })); + } +} + +export const many2ManyTagsAvatarFieldPopover = { + ...many2ManyTagsAvatarField, + component: Many2ManyTagsAvatarFieldPopover, +}; +registry.category("fields").add("many2many_tags_avatar_popover", many2ManyTagsAvatarFieldPopover); + +export class KanbanMany2ManyTagsAvatarFieldTagsList extends TagsList { + static template = "web.KanbanMany2ManyTagsAvatarFieldTagsList"; + + static props = { + ...TagsList.props, + popoverProps: { type: Object }, + readonly: { type: Boolean, optional: true }, }; + setup() { + super.setup(); + this.popover = usePopover(); + this.emptyAvatar = useRef("emptyAvatar"); + } + get visibleTagsCount() { + return this.props.itemsVisible; + } + closePopover() { + this.closePopoverFn(); + this.closePopoverFn = null; + } + openPopover(ev) { + if (this.props.readonly) { + return; + } + if (this.closePopoverFn) { + this.closePopover(); + } + this.emptyAvatar.el?.dispatchEvent(new MouseEvent("mouseleave")); + this.emptyAvatar.el?.removeAttribute("data-tooltip-template"); + this.closePopoverFn = this.popover.add( + ev.currentTarget.parentElement, + Many2ManyTagsAvatarFieldPopover, + { + ...this.props.popoverProps, + readonly: false, + canCreate: false, + canCreateEdit: false, + canQuickCreate: false, + }, + { + position: "bottom", + onClose: () => { + this.emptyAvatar.el?.setAttribute( + "data-tooltip-template", + "web.TagsList.Tooltip" + ); + }, + } + ); + } + + get canDisplayDelete() { + return !this.props.readonly && !isMobileOS(); + } +} + +export class KanbanMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField { + static template = "web.KanbanMany2ManyTagsAvatarField"; + static components = { + ...Many2ManyTagsAvatarField.component, + TagsList: KanbanMany2ManyTagsAvatarFieldTagsList, + }; + itemsVisible = 2; + + get isFieldReadonly() { + return this.props.record.isReadonly(this.props.name); + } + + get popoverProps() { + return { + ...this.props, + readonly: this.isFieldReadonly, + deleteTag: this.deleteTag.bind(this), + updateTag: this.updateTag.bind(this), + }; + } + async deleteTag(id) { + super.deleteTag(id); + await this.props.record.save({ noReload: true }); + return this.tags; + } + async updateTag(recordList) { + await this.update(recordList); + await this.props.record.save({ noReload: true }); + return this.tags; + } getTagProps(record) { return { ...super.getTagProps(record), - img: `/web/image/${this.relation}/${record.resId}/avatar_128`, + onDelete: () => this.deleteTag(record.id), }; } } -export const listKanbanMany2ManyTagsAvatarField = { +export const kanbanMany2ManyTagsAvatarField = { ...many2ManyTagsAvatarField, - component: ListKanbanMany2ManyTagsAvatarField, - extractProps: (fieldInfo) => ({ - ...many2ManyTagsAvatarField.extractProps(fieldInfo), - itemsVisible: fieldInfo.viewType === "list" ? 5 : 3, - }), + component: KanbanMany2ManyTagsAvatarField, }; -registry.category("fields").add("list.many2many_tags_avatar", listKanbanMany2ManyTagsAvatarField); -registry.category("fields").add("kanban.many2many_tags_avatar", listKanbanMany2ManyTagsAvatarField); +registry.category("fields").add("kanban.many2many_tags_avatar", kanbanMany2ManyTagsAvatarField); diff --git a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss index 490a1cd8307..443dae38a7f 100644 --- a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss +++ b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.scss @@ -80,3 +80,21 @@ flex-basis: 40px; } } +.o_m2m_tags_avatar_field_popover { + max-width: 280px; + .dropdown-menu { + width: 275px; + } +} +.o_field_many2many_tags_avatar_kanban { + .o_field_tags .o_tag .o_delete { + margin-left: -12px; + margin-top: -4px; + width: 12px; + height: 12px; + visibility: hidden; + } + .o_allow_delete_hover:hover .o_delete { + visibility: visible; + } +} diff --git a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.xml b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.xml index 03aa3388858..9ee33498cb3 100644 --- a/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.xml +++ b/addons/web/static/src/views/fields/many2many_tags_avatar/many2many_tags_avatar_field.xml @@ -6,7 +6,7 @@ class="o_field_tags d-inline-flex flex-wrap mw-100" t-att-class="{'o_tags_input o_input': !props.readonly}" > - +
+ + + Quick assign + + + emptyAvatar + openPopover + + + {'o_allow_delete_hover': canDisplayDelete} + + + + Remove + Remove + + + + + + popoverProps + isFieldReadonly + + + + + + + + + + + + + + diff --git a/addons/web/static/tests/views/fields/many2many_tags_avatar_field_tests.js b/addons/web/static/tests/views/fields/many2many_tags_avatar_field_tests.js index baac904eebb..e76e05bc731 100644 --- a/addons/web/static/tests/views/fields/many2many_tags_avatar_field_tests.js +++ b/addons/web/static/tests/views/fields/many2many_tags_avatar_field_tests.js @@ -1,6 +1,12 @@ /** @odoo-module **/ -import { click, clickSave, getFixture, selectDropdownItem } from "@web/../tests/helpers/utils"; +import { + click, + clickSave, + getFixture, + triggerEvent, + selectDropdownItem, +} from "@web/../tests/helpers/utils"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { triggerHotkey } from "../../helpers/utils"; @@ -273,7 +279,7 @@ QUnit.module("Fields", (hooks) => { ); QUnit.test("widget many2many_tags_avatar in kanban view", async function (assert) { - assert.expect(13); + assert.expect(16); const records = []; for (let id = 5; id <= 15; id++) { @@ -336,8 +342,8 @@ QUnit.module("Fields", (hooks) => { assert.containsN( target, ".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag", - 3, - "should have 3 records" + 2, + "should have 2 records" ); assert.containsN( target, @@ -395,25 +401,42 @@ QUnit.module("Fields", (hooks) => { "should have 9+ in o_m2m_avatar_empty" ); - // check data-tooltip attribute (used by the tooltip service) - const tag = target.querySelector( - ".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty" + // check delete + const firstTag = target.querySelector( + ".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag" ); - assert.strictEqual( - tag.dataset["tooltipTemplate"], - "web.TagsList.Tooltip", - "uses the proper tooltip template" - ); - const tooltipInfo = JSON.parse(tag.dataset["tooltipInfo"]); - assert.strictEqual( - tooltipInfo.tags.map((tag) => tag.text).join(" "), - "aaa record 5", - "shows a tooltip on hover" + await triggerEvent(firstTag, null, "mouseover"); + await click(firstTag, ".o_delete"); + assert.containsN( + target, + ".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag", + 2, + "should have 2 record" ); - await click( - target.querySelector(".o_kanban_record .o_field_many2many_tags_avatar img.o_m2m_avatar") + const o_kanban_record = target.querySelector(".o_kanban_record:nth-child(2)"); + await click(o_kanban_record, ".o_field_tags > img.o_m2m_avatar", true); + const popover = document.querySelector(".o_popover_container"); + assert.strictEqual(popover.querySelectorAll(".o_tag").length, 2, "Should have 2 tags"); + // delete inside the popover + await click(popover.querySelector(".o_tag .o_delete")); + assert.strictEqual(popover.querySelectorAll(".o_tag").length, 1, "Should have 1 tag"); + assert.strictEqual( + o_kanban_record.querySelectorAll(".o_tag").length, + 1, + "Should have 1 tags" ); + // select input + await click(popover.querySelector(".o_field_many2many_selection input")); + // select first input + await click(popover.querySelector(".o-autocomplete--dropdown-item")); + assert.strictEqual(popover.querySelectorAll(".o_tag").length, 2, "Should have 2 tags"); + assert.strictEqual( + o_kanban_record.querySelectorAll(".o_tag").length, + 2, + "Should have 2 tags" + ); + await click(target); }); QUnit.test("widget many2many_tags_avatar delete tag", async function (assert) {