[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:
Julien Carion (juca)
2023-07-26 19:18:49 +02:00
parent 284a0ef4dc
commit 3af4bffeee
17 changed files with 457 additions and 6 deletions
@@ -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>
+6
View File
@@ -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>
@@ -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;
},
};
},
};
@@ -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>
@@ -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 {
@@ -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");