[IMP] web, mail, hr: Avatar card preview
This commit adds the possibility to see additional information about a user with a card that appears when hovering avatar user fields for a set amount of time. This card contains the following information: - Avatar - Chat status - Name - Email - Button to send message and with HR installed: - View profile Button - Job position - Department - Phone number - Advanced chat status (when hr_holidays is installed) Task-3167110 closes odoo/odoo#121943 Related: odoo/enterprise#44563 Signed-off-by: Mathieu Duckerts-Antoine (dam) <dam@odoo.com>
This commit is contained in:
@@ -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);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.AvatarCardPopover" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//div[hasclass('o_avatar_card_buttons')]" position="inside">
|
||||
<button class="btn btn-secondary btn-sm" t-on-click.stop="onClickViewEmployee"> View profile </button>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="hr.avatarCardUserInfos" owl="1">
|
||||
<span class="fw-bold" t-esc="user.name"/>
|
||||
<small class="text-muted" t-if="user.job_title" t-esc="user.job_title"/>
|
||||
<span class="text-muted" t-if="user.department_id" data-tooltip="Department" t-esc="user.department_id[1]"/>
|
||||
<a t-att-href="'mailto:'+user.email">
|
||||
<i class="fa fa-fw fa-envelope"/> <t t-esc="user.email"/>
|
||||
</a>
|
||||
<a t-att-href="'tel:'+user.work_phone">
|
||||
<i class="fa fa-fw fa-phone"/> <t t-esc="user.work_phone"/>
|
||||
</a>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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");
|
||||
@@ -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' },
|
||||
|
||||
@@ -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": `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.AvatarCardPopover" t-inherit-mode="extension">
|
||||
<xpath expr="//span[@name='icon']" position="inside">
|
||||
<i t-elif="user.im_status === 'leave_online'" class="fa fa-fw fa-plane text-success" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-elif="user.im_status === 'leave_away'" class="fa fa-fw fa-plane text-warning" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-elif="user.im_status === 'leave_offline'" class="fa fa-fw fa-plane text-700" title="Out of office" role="img" aria-label="User is out of office"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="mail.AvatarCardPopover" owl="1">
|
||||
<div class="o_avatar_card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<span class="o_avatar pt-1 position-relative o_card_avatar">
|
||||
<img t-if="props.id"
|
||||
t-attf-src="/web/image/{{props.relation}}/{{props.id}}/avatar_128"
|
||||
class="rounded"
|
||||
/>
|
||||
<span t-if="user.im_status" name="icon" class="o_card_avatar_im_status position-absolute d-flex align-items-center justify-content-center">
|
||||
<i t-if="user.im_status === 'online'" class="fa fa-fw fa-circle text-success" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-elif="user.im_status === 'away'" class="fa fa-fw fa-circle text-warning" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-elif="user.im_status === 'offline'" class="fa fa-fw fa-circle-o text-700" title="Offline" role="img" aria-label="User is offline"/>
|
||||
<i t-elif="user.im_status === 'bot'" class="fa fa-fw fa-heart text-success" title="Bot" role="img" aria-label="User is a bot"/>
|
||||
<i t-elif="!user.im_status" class="fa fa-fw fa-question-circle" title="No IM status available"/>
|
||||
</span>
|
||||
</span>
|
||||
<div t-if="userInfoTemplate" t-call="{{userInfoTemplate}}" class="d-flex flex-column o_card_user_infos"/>
|
||||
<div t-else="" class="d-flex flex-column o_card_user_infos">
|
||||
<span class="fw-bold" t-esc="user.name"/>
|
||||
<a t-att-href="'mailto:'+user.email">
|
||||
<i class="fa fa-envelope me-1"> </i>
|
||||
<t t-esc="user.email"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-wrap gap-2 mt-2">
|
||||
<div class="justify-content-end d-flex align-items-baseline">
|
||||
<div class="d-flex gap-2 o_avatar_card_buttons">
|
||||
<button class="btn btn-secondary btn-sm" t-on-click.stop="onSendClick"> Send message </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
+30
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+10
@@ -1,14 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="mail.Many2ManyAvatarUserTagsList" t-inherit="web.TagsList" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//span[hasclass('o_tag')]" position="attributes">
|
||||
<attribute name="t-att-title"></attribute>
|
||||
</xpath>
|
||||
<img position="attributes">
|
||||
<attribute name="t-on-click.stop.prevent">tag.onImageClicked</attribute>
|
||||
<attribute name="t-on-mouseleave">tag.clearTimeout</attribute>
|
||||
<attribute name="t-on-mouseover.stop.prevent">tag.openCard</attribute>
|
||||
</img>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.KanbanMany2ManyAvatarUserTagsList" t-inherit="web.KanbanMany2ManyTagsAvatarFieldTagsList" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//span[hasclass('o_tag')]" position="attributes">
|
||||
<attribute name="t-att-title"></attribute>
|
||||
</xpath>
|
||||
<img position="attributes">
|
||||
<attribute name="t-on-click.stop.prevent">tag.onImageClicked</attribute>
|
||||
<attribute name="t-on-mouseleave">tag.clearTimeout</attribute>
|
||||
<attribute name="t-on-mouseover.stop.prevent">tag.openCard</attribute>
|
||||
</img>
|
||||
</t>
|
||||
|
||||
|
||||
+27
@@ -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 {
|
||||
|
||||
+10
@@ -1,14 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="mail.Many2OneAvatarUserField" t-inherit="web.Many2OneAvatarField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//div" position="attributes">
|
||||
<attribute name="t-att-data-tooltip"></attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[hasclass('o_m2o_avatar')]" position="attributes">
|
||||
<attribute name="t-on-click.stop.prevent">onClickAvatar</attribute>
|
||||
<attribute name="t-on-mouseleave">clearTimeout</attribute>
|
||||
<attribute name="t-on-mouseover.stop.prevent">openCard</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.KanbanMany2OneAvatarUserField" t-inherit="web.KanbanMany2OneAvatarField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//div" position="attributes">
|
||||
<attribute name="t-att-data-tooltip"></attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[hasclass('o_m2o_avatar')]" position="attributes">
|
||||
<attribute name="t-on-click.stop.prevent">onClickAvatar</attribute>
|
||||
<attribute name="t-on-mouseleave">clearTimeout</attribute>
|
||||
<attribute name="t-on-mouseover.stop.prevent">openCard</attribute>
|
||||
</xpath>
|
||||
<Many2OneField position="attributes">
|
||||
<attribute name="t-if">props.readonly and props.displayAvatarName</attribute>
|
||||
|
||||
@@ -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": `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="user_id" widget="many2one_avatar_user"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user