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");