diff --git a/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.js b/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.js new file mode 100644 index 00000000000..9b61ec8c6ea --- /dev/null +++ b/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.js @@ -0,0 +1,30 @@ +/* @odoo-module */ + +import { patch } from "@web/core/utils/patch"; +import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover"; +import { useService } from "@web/core/utils/hooks"; + +export const patchAvatarCardPopover = { + setup() { + this._super(); + this.userInfoTemplate = "hr.avatarCardUserInfos", + this.actionService = useService("action"); + }, + get fieldNames(){ + let fields = this._super(); + return fields.concat([ + "work_phone", + "job_title", + "department_id", + "employee_parent_id", + "employee_id", + ]) + }, + async onClickViewEmployee(){ + const employeeId = this.user.employee_id[0]; + const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]); + this.actionService.doAction(action); + } +}; + +patch(AvatarCardPopover.prototype, "hr", patchAvatarCardPopover); diff --git a/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml b/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml new file mode 100644 index 00000000000..2e49ad22c50 --- /dev/null +++ b/addons/hr/static/src/components/avatar_card/avatar_card_popover_patch.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr/static/tests/disable_patch.js b/addons/hr/static/tests/disable_patch.js new file mode 100644 index 00000000000..c409ae06566 --- /dev/null +++ b/addons/hr/static/tests/disable_patch.js @@ -0,0 +1,6 @@ +/** @odoo-module */ + +import { unpatch } from "@web/core/utils/patch"; +import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover"; + +unpatch(AvatarCardPopover.prototype, "hr"); diff --git a/addons/hr/static/tests/helpers/model_definitions_setup.js b/addons/hr/static/tests/helpers/model_definitions_setup.js index b112c137fae..5ccc9594abd 100644 --- a/addons/hr/static/tests/helpers/model_definitions_setup.js +++ b/addons/hr/static/tests/helpers/model_definitions_setup.js @@ -2,7 +2,7 @@ import { addFakeModel, addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; -addModelNamesToFetch(['hr.employee', 'hr.employee.public']); +addModelNamesToFetch(['hr.employee', 'hr.employee.public', 'hr.department']); addFakeModel('m2x.avatar.employee', { employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee.public' }, diff --git a/addons/hr/static/tests/web/m2x_avatar_user_tests.js b/addons/hr/static/tests/web/m2x_avatar_user_tests.js new file mode 100644 index 00000000000..0fe6a857d57 --- /dev/null +++ b/addons/hr/static/tests/web/m2x_avatar_user_tests.js @@ -0,0 +1,110 @@ +/** @odoo-module **/ + +import { start, startServer } from "@mail/../tests/helpers/test_utils"; +import { patchWithCleanup, triggerEvent, getFixture, getNodesTextContent } from "@web/../tests/helpers/utils"; +import { registry } from "@web/core/registry"; +import { browser } from "@web/core/browser/browser"; +import { EventBus } from "@odoo/owl"; +import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover"; +import { patchAvatarCardPopover } from "@hr/components/avatar_card/avatar_card_popover_patch"; + +const fakeMultiTab = { + start() { + const bus = new EventBus(); + return { + bus, + get currentTabId() { + return null; + }, + isOnMainTab() { + return true; + }, + getSharedValue(key, defaultValue) { + return ""; + }, + setSharedValue(key, value) {}, + removeSharedValue(key) {}, + }; + }, +}; + +const fakeImStatusService = { + start() { + return { + registerToImStatus() {}, + unregisterFromImStatus() {}, + }; + }, +}; + +let target; + +QUnit.module("M2XAvatarUser", ({ beforeEach }) => { + beforeEach(() => { + target = getFixture(); + patchWithCleanup(AvatarCardPopover.prototype, patchAvatarCardPopover); + registry.category("services").add("multi_tab", fakeMultiTab, { force: true }); + registry.category("services").add("im_status", fakeImStatusService, { force: true }); + }); + + QUnit.test("avatar card preview with hr", async (assert) => { + const pyEnv = await startServer(); + const departmentId = pyEnv["hr.department"].create({ + name: "Managemment" + }); + const userId = pyEnv["res.users"].create({ + name: "Mario", + email: "Mario@odoo.test", + im_status: "online", + work_phone: "+585555555", + job_title: "sub manager", + department_id: departmentId, + }); + const mockRPC = (route, args) => { + if(route === "/web/dataset/call_kw/res.users/read"){ + assert.deepEqual(args.args[1], ['name', 'email', 'im_status', + "work_phone", + "job_title", + "department_id", + "employee_parent_id", + "employee_id"]); + assert.step("user read"); + } + }; + const avatarUserId = pyEnv["m2x.avatar.user"].create({ user_id: userId }); + const views = { + "m2x.avatar.user,false,kanban": ` + + + +
+ +
+
+
+
`, + }; + const { openView } = await start({ serverData: { views }, mockRPC }); + await openView({ + res_model: "m2x.avatar.user", + res_id: avatarUserId, + views: [[false, "kanban"]], + }); + + patchWithCleanup(browser, { + setTimeout: (callback, delay) => { + assert.step(`setTimeout of ${delay}ms`); + callback(); + }, + }); + // Open card + await triggerEvent(target, ".o_m2o_avatar > img", "mouseover"); + assert.verifySteps(["setTimeout of 350ms", "user read"]); + assert.containsOnce(target, ".o_avatar_card"); + assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_card_user_infos > *")), ['Mario', 'sub manager', 'Managemment', ' Mario@odoo.test', ' +585555555']); + // Close card + await triggerEvent(target, ".o_control_panel", "mouseover"); + assert.verifySteps(["setTimeout of 400ms"]); + assert.containsNone(target, ".o_avatar_card"); + }); +}); diff --git a/addons/hr_holidays/static/src/avatar_card_popover_patch.xml b/addons/hr_holidays/static/src/avatar_card_popover_patch.xml new file mode 100644 index 00000000000..c0785da8455 --- /dev/null +++ b/addons/hr_holidays/static/src/avatar_card_popover_patch.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.js b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.js new file mode 100644 index 00000000000..69a0effa2b0 --- /dev/null +++ b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import { useService } from "@web/core/utils/hooks"; +import { Component, onWillStart } from "@odoo/owl"; +import { useOpenChat } from "@mail/core/web/open_chat_hook"; + +export class AvatarCardPopover extends Component { + static template = "mail.AvatarCardPopover"; + + static props = { + id: { type: Number, required: true }, + relation: { type: String, required: true }, + close: { type: Function, required: true }, + }; + + setup() { + this.orm = useService("orm"); + this.openChat = useOpenChat(this.props.relation); + onWillStart(async () => { + [this.user] = await this.orm.read( + this.props.relation, + [this.props.id], + this.fieldNames + ); + }); + } + + get fieldNames() { + return ["name", "email", "im_status"]; + } + + onSendClick() { + this.openChat(this.user.id); + } +} diff --git a/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.scss b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.scss new file mode 100644 index 00000000000..19ddf4fda31 --- /dev/null +++ b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.scss @@ -0,0 +1,19 @@ +.o_card_avatar { + img { + height: 60px; + aspect-ratio: 1; + } + + .o_card_avatar_im_status { + bottom: -4px; + right: -4px; + border-radius: 2rem; + background-color: white; + height: 18px; + width: 18px; + } +} + +.o_avatar_card { + width: 380px; +} diff --git a/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.xml b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.xml new file mode 100644 index 00000000000..f9e90ec401d --- /dev/null +++ b/addons/mail/static/src/discuss/web/avatar_card/avatar_card_popover.xml @@ -0,0 +1,39 @@ + + + +
+
+
+ + + + + + + + + + +
+
+ + + + + +
+
+
+
+
+ +
+
+
+
+
+ + diff --git a/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.js b/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.js index 7e0b0ed6ef6..2ea24ba3102 100644 --- a/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.js +++ b/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.js @@ -6,6 +6,9 @@ import { useAssignUserCommand } from "@mail/views/web/fields/assign_user_command import { registry } from "@web/core/registry"; import { TagsList } from "@web/core/tags_list/tags_list"; import { patch } from "@web/core/utils/patch"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { browser } from "@web/core/browser/browser"; +import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover"; import { Many2ManyTagsAvatarField, many2ManyTagsAvatarField, @@ -26,12 +29,39 @@ const userChatter = { if (this.props.withCommand) { useAssignUserCommand(); } + this.avatarCard = usePopover(AvatarCardPopover, { + closeOnHoverAway: true, + }); + this.openTimeout = false; + this.lastOpenedId = 0; }, getTagProps(record) { return { ...this._super(...arguments), onImageClicked: () => this.openChat(record.resId), + openCard: (ev) => { + if (this.env.isSmall) { + return; + } + const target = ev.currentTarget; + this.openTimeout = browser.setTimeout(() => { + if ( + !this.avatarCard.isOpen || + (this.lastOpenedId && record.resId !== this.lastOpenedId) + ) { + this.avatarCard.open(target, { + id: record.resId, + relation: this.relation, + }); + this.lastOpenedId = record.resId; + } + }, 350); + }, + clearTimeout: () => { + browser.clearTimeout(this.openTimeout); + delete this.openTimeout; + }, }; }, }; diff --git a/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml b/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml index b7b1116d3f5..b16c8c0510f 100644 --- a/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml +++ b/addons/mail/static/src/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field.xml @@ -1,14 +1,24 @@ + + + tag.onImageClicked + tag.clearTimeout + tag.openCard + + + tag.onImageClicked + tag.clearTimeout + tag.openCard diff --git a/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.js b/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.js index 0a66779e86c..55e57a92531 100644 --- a/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.js +++ b/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.js @@ -12,6 +12,9 @@ import { KanbanMany2OneAvatarField, kanbanMany2OneAvatarField, } from "@web/views/fields/many2one_avatar/many2one_avatar_field"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { browser } from "@web/core/browser/browser"; +import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover"; const userChatter = { setup() { @@ -20,6 +23,10 @@ const userChatter = { if (this.props.withCommand) { useAssignUserCommand(); } + this.avatarCard = usePopover(AvatarCardPopover, { + closeOnHoverAway: true, + }); + this.openTimeout = false; }, onClickAvatar() { @@ -28,6 +35,26 @@ const userChatter = { this.openChat(id); } }, + + openCard(ev) { + if (this.env.isSmall) { + return; + } + const target = ev.currentTarget; + this.openTimeout = browser.setTimeout(() => { + if (!this.avatarCard.isOpen) { + this.avatarCard.open(target, { + id: this.props.record.data[this.props.name][0], + relation: this.relation, + }); + } + }, 350); + }, + + clearTimeout() { + browser.clearTimeout(this.openTimeout); + delete this.openTimeout; + }, }; export class Many2OneAvatarUserField extends Many2OneAvatarField { diff --git a/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.xml b/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.xml index c1d0c787a26..995b86b6ab3 100644 --- a/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.xml +++ b/addons/mail/static/src/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field.xml @@ -1,14 +1,24 @@ + + + onClickAvatar + clearTimeout + openCard + + + onClickAvatar + clearTimeout + openCard props.readonly and props.displayAvatarName diff --git a/addons/mail/static/tests/web/fields/m2x_avatar_user_tests.js b/addons/mail/static/tests/web/fields/m2x_avatar_user_tests.js index eb28bb100b1..900236ab37b 100644 --- a/addons/mail/static/tests/web/fields/m2x_avatar_user_tests.js +++ b/addons/mail/static/tests/web/fields/m2x_avatar_user_tests.js @@ -1,13 +1,43 @@ /* @odoo-module */ import { start, startServer } from "@mail/../tests/helpers/test_utils"; - -import { popoverService } from "@web/core/popover/popover_service"; +import { click, patchWithCleanup, triggerHotkey, triggerEvent } from "@web/../tests/helpers/utils"; import { registry } from "@web/core/registry"; -import { tooltipService } from "@web/core/tooltip/tooltip_service"; import { session } from "@web/session"; -import { click, patchWithCleanup, triggerHotkey } from "@web/../tests/helpers/utils"; import { nextTick } from "web.test_utils"; +import { popoverService } from "@web/core/popover/popover_service"; +import { tooltipService } from "@web/core/tooltip/tooltip_service"; +import { browser } from "@web/core/browser/browser"; +import { EventBus } from "@odoo/owl"; + +const fakeMultiTab = { + start() { + const bus = new EventBus(); + return { + bus, + get currentTabId() { + return null; + }, + isOnMainTab() { + return true; + }, + getSharedValue(key, defaultValue) { + return ""; + }, + setSharedValue(key, value) {}, + removeSharedValue(key) {}, + }; + }, +}; + +const fakeImStatusService = { + start() { + return { + registerToImStatus() {}, + unregisterFromImStatus() {}, + }; + }, +}; QUnit.module("M2XAvatarUser"); @@ -312,6 +342,61 @@ QUnit.test( } ); +QUnit.test("avatar card preview", async (assert) => { + registry.category("services").add("multi_tab", fakeMultiTab, { force: true }); + registry.category("services").add("im_status", fakeImStatusService, { force: true }); + const pyEnv = await startServer(); + const userId = pyEnv["res.users"].create({ + name: "Mario", + email: "Mario@odoo.test", + im_status: "online", + }); + const mockRPC = (route, args) => { + if (route === "/web/dataset/call_kw/res.users/read") { + assert.deepEqual(args.args[1], ["name", "email", "im_status"]); + assert.step("user read"); + } + }; + const avatarUserId = pyEnv["m2x.avatar.user"].create({ user_id: userId }); + const views = { + "m2x.avatar.user,false,kanban": ` + + + +
+ +
+
+
+
`, + }; + const { openView } = await start({ serverData: { views }, mockRPC }); + await openView({ + res_model: "m2x.avatar.user", + res_id: avatarUserId, + views: [[false, "kanban"]], + }); + + patchWithCleanup(browser, { + setTimeout: (callback, delay) => { + assert.step(`setTimeout of ${delay}ms`); + callback(); + }, + }); + // Open card + await triggerEvent(document, ".o_m2o_avatar > img", "mouseover"); + assert.verifySteps(["setTimeout of 350ms", "user read"]); + assert.containsOnce(document.body, ".o_avatar_card"); + assert.strictEqual( + document.querySelector(".o_card_user_infos").textContent, + "Mario Mario@odoo.test" + ); + // Close card + await triggerEvent(document, ".o_control_panel", "mouseover"); + assert.verifySteps(["setTimeout of 400ms"]); + assert.containsNone(document.body, ".o_avatar_card"); +}); + QUnit.test( "avatar_user widget displays the appropriate user image in form view", async (assert) => { diff --git a/addons/web/static/src/core/popover/popover_controller.js b/addons/web/static/src/core/popover/popover_controller.js index 2c835e7d098..bf126f6dbb7 100644 --- a/addons/web/static/src/core/popover/popover_controller.js +++ b/addons/web/static/src/core/popover/popover_controller.js @@ -4,6 +4,7 @@ import { Component, onWillDestroy, useExternalListener, xml } from "@odoo/owl"; import { useHotkey } from "../hotkeys/hotkey_hook"; import { useChildRef } from "../utils/hooks"; import { Popover } from "./popover"; +import { browser } from "../browser/browser"; export class PopoverController extends Component { static template = xml` @@ -16,6 +17,7 @@ export class PopoverController extends Component { "target", "close", "closeOnClickAway", + "closeOnHoverAway", "component", "componentProps", "popoverProps", @@ -25,6 +27,10 @@ export class PopoverController extends Component { if (this.props.target.isConnected) { this.popoverRef = useChildRef(); useExternalListener(window, "mousedown", this.onClickAway, { capture: true }); + if (this.props.closeOnHoverAway) { + this.closePopoverTimeout = false; + useExternalListener(window, "mouseover", this.onHoverAway, { capture: true }); + } useHotkey("escape", () => this.props.close()); const targetObserver = new MutationObserver(this.onTargetMutate.bind(this)); targetObserver.observe(this.props.target.parentElement, { childList: true }); @@ -45,6 +51,17 @@ export class PopoverController extends Component { } } + onHoverAway(ev) { + const target = ev.composedPath()[0]; + if (!this.props.target.contains(target) && !this.popoverRef.el.contains(target)) { + this.closePopoverTimeout = browser.setTimeout(() => { + this.props.close(); + }, 400); + } else { + browser.clearTimeout(this.closePopoverTimeout); + } + } + onTargetMutate() { if (!this.props.target.isConnected) { this.props.close(); diff --git a/addons/web/static/src/core/popover/popover_service.js b/addons/web/static/src/core/popover/popover_service.js index b53ce3f214e..46534325a96 100644 --- a/addons/web/static/src/core/popover/popover_service.js +++ b/addons/web/static/src/core/popover/popover_service.js @@ -7,6 +7,7 @@ import { PopoverController } from "./popover_controller"; /** * @typedef {{ * closeOnClickAway?: boolean | (target: HTMLElement) => boolean; + * closeOnHoverAway?: boolean; * onClose?: () => void; * popoverClass?: string; * position?: import("@web/core/position_hook").Options["position"]; @@ -31,12 +32,14 @@ export const popoverService = { typeof options.closeOnClickAway === "function" ? options.closeOnClickAway : () => options.closeOnClickAway ?? true; + const closeOnHoverAway = options.closeOnHoverAway || false; const remove = overlay.add( PopoverController, { target, close: () => remove(), closeOnClickAway, + closeOnHoverAway, component, componentProps: markRaw(props), popoverProps: { diff --git a/addons/web/static/tests/views/fields/many2one_avatar_field_tests.js b/addons/web/static/tests/views/fields/many2one_avatar_field_tests.js index f40ef367157..fe45eae8702 100644 --- a/addons/web/static/tests/views/fields/many2one_avatar_field_tests.js +++ b/addons/web/static/tests/views/fields/many2one_avatar_field_tests.js @@ -87,7 +87,7 @@ QUnit.module("Fields", (hooks) => { target, '.o_m2o_avatar > img[data-src="/web/image/user/17/avatar_128"]' ); - assert.containsOnce(target, '.o_field_many2one_avatar > div[data-tooltip="Aline"]'); + assert.containsOnce(target, ".o_field_many2one_avatar > div"); assert.containsOnce(target, ".o_input_dropdown"); assert.strictEqual(target.querySelector(".o_input_dropdown input").value, "Aline");