Skip to content
Snippets Groups Projects
Commit 59641325 authored by Aaron Bohy's avatar Aaron Bohy
Browse files

[IMP] mail: add Many2OneAvatarUser field widget

This widget is an extension of Many2OneAvatar, designed for
many2one fields pointing to the 'res.users' model, or a model having
itself a many2one field pointing to 'res.partner'.

With this widget, when the avatar is clicked, we open a DM chat
window with the corresponding partner. If the user clicked on itself,
we open a blank chat window for the sake of consistency.

In kanban views, this widget only displays the avatar.

Part of task 2195254
parent e63ffeab
No related branches found
No related tags found
No related merge requests found
......@@ -54,6 +54,7 @@
'static/src/xml/chatter.xml',
'static/src/xml/discuss.xml',
'static/src/xml/followers.xml',
'static/src/xml/many2one_avatar_user.xml',
'static/src/xml/systray.xml',
'static/src/xml/out_of_office.xml',
'static/src/xml/thread.xml',
......
odoo.define('mail.Many2OneAvatarUser', function (require) {
"use strict";
// This module defines an extension of the Many2OneAvatar widget, which is
// integrated with the messaging system. The Many2OneAvatarUser is designed
// to display people, and when the avatar of those people is clicked, it
// opens a DM chat window with the corresponding partner (the messaging
// system is based on model 'res.partner').
//
// This widget is supported on many2one fields pointing to 'res.users'. When
// the user is clicked, we fetch the partner id of the given user (field
// 'partner_id'), and we open a chat window with this partner.
//
// Usage:
// <field name="user_id" widget="many2one_avatar_user"/>
//
// The widget is designed to be extended, to support many2one fields pointing
// to other models than 'res.users'. Those models must have a many2one field
// pointing to 'res.partner'. Ideally, the many2one should be false if the
// corresponding partner isn't associated with a user, otherwise it will open
// chat windows with partners that won't be able to read your messages and
// reply.
const { _t } = require('web.core');
const fieldRegistry = require('web.field_registry');
const { Many2OneAvatar } = require('web.relational_fields');
const session = require('web.session');
const Many2OneAvatarUser = Many2OneAvatar.extend({
events: Object.assign({}, Many2OneAvatar.prototype.events, {
'click .o_m2o_avatar': '_onAvatarClicked',
}),
// Maps record ids to promises that resolve with the corresponding partner ids.
// Used as a cache shared between all instances of this widget.
partnerIds: {},
// This widget is only supported on many2ones pointing to 'res.users'
supportedModel: 'res.users',
init() {
this._super(...arguments);
if (this.supportedModel !== this.field.relation) {
throw new Error(`This widget is only supported on many2one fields pointing to ${this.supportedModel}`);
}
if (this.mode === 'readonly') {
this.className += ' o_clickable_m2o_avatar';
}
this.partnerField = 'partner_id'; // field to read on 'res.users' to get the partner id
},
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* Displays a warning when we can't open a DM chat window with the partner
* corresponding to the clicked record. On model 'res.users', it only
* happens when the user clicked on himself. This can be overridden by
* extensions of this widget, for instance to handle the case where the
* user clicked on a record whose partner isn't associated with any user.
*
* @private
*/
_displayWarning() {
this.displayNotification({
title: _t('Cannot chat with yourself'),
message: _t('Click on the avatar of other users to chat with them.'),
type: 'info',
});
},
/**
* @private
* @param {number} resId
* @returns {string} the key to use in the partnerIds cache
*/
_getCacheKey(resId) {
return `${this.field.relation}_${resId}`;
},
/**
* For a given record id of the comodel, returns the corresponding
* partner id.
*
* @param {number} resId
* @returns {Promise<integer|false>}
*/
_resIdToPartnerId(resId) {
const key = this._getCacheKey(resId);
if (!this.partnerIds[key]) {
const params = {
method: 'read',
model: this.field.relation,
args: [resId, [this.partnerField]],
};
this.partnerIds[key] = this._rpc(params).then(recs => {
const partner = recs[0][this.partnerField];
return partner && partner[0];
});
}
return this.partnerIds[key];
},
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* When the avatar is clicked, open a DM chat window with the
* corresponding partner. If the user clicked on himself, open a blank
* thread window, for the sake of consistency.
*
* @private
* @param {MouseEvent} ev
*/
async _onAvatarClicked(ev) {
ev.stopPropagation(); // in list view, prevent from opening the record
let partnerId;
if (this.field.relation !== 'res.users' || this.value.res_id !== session.uid) {
partnerId = await this._resIdToPartnerId(this.value.res_id);
}
if (partnerId && partnerId !== session.partner_id) {
this.call('mail_service', 'openDMChatWindow', partnerId);
} else {
this._displayWarning(partnerId);
}
}
});
const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({
_template: 'mail.KanbanMany2OneAvatarUser',
});
fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser);
fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser);
return {
Many2OneAvatarUser,
KanbanMany2OneAvatarUser,
};
});
.o_field_many2one_avatar.o_clickable_m2o_avatar {
.o_m2o_avatar:hover {
cursor: pointer;
filter: brightness(0.8);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- MailMany2OneAvatar: do not display the display_name in kanban views -->
<t t-name="mail.KanbanMany2OneAvatarUser" t-extend="web.Many2OneAvatar">
<t t-jquery="img" t-operation="attributes">
<attribute name="t-att-title">value</attribute>
</t>
<t t-jquery="span" t-operation="replace"/>
</t>
</templates>
odoo.define('mail.Many2OneAvatarUserTests', function (require) {
"use strict";
const FormView = require('web.FormView');
const KanbanView = require('web.KanbanView');
const ListView = require('web.ListView');
const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser');
const { createView, dom, mock } = require('web.test_utils');
QUnit.module('mail', {}, function () {
QUnit.module('Many2OneAvatarUser', {
beforeEach: function () {
// reset the cache before each test
Many2OneAvatarUser.prototype.partnerIds = {};
this.data = {
'foo': {
fields: {
user_id: { string: "User", type: 'many2one', relation: 'res.users' },
},
records: [
{ id: 1, user_id: 11 },
{ id: 2, user_id: 7 },
{ id: 3, user_id: 11 },
{ id: 4, user_id: 23 },
],
},
'res.users': {
fields: {
display_name: { string: "Name", type: "char" },
partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' },
},
records: [{
id: 11,
name: "Mario",
partner_id: 1,
}, {
id: 7,
name: "Luigi",
partner_id: 2,
}, {
id: 23,
name: "Yoshi",
partner_id: 3,
}],
},
'res.partner': {
fields: {
display_name: { string: "Name", type: "char" },
},
records: [{
id: 1,
display_name: "Partner 1",
}, {
id: 2,
display_name: "Partner 2",
}, {
id: 3,
display_name: "Partner 3",
}],
},
};
},
});
QUnit.test('many2one_avatar_user widget in list view', async function (assert) {
assert.expect(8);
const list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree><field name="user_id" widget="many2one_avatar_user"/></tree>',
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
return this._super(...arguments);
},
});
mock.intercept(list, 'call_service', ev => {
if (ev.data.service === 'mail_service') {
assert.step(`call service ${ev.data.method} ${ev.data.args[0]}`);
}
}, true);
mock.intercept(list, 'open_record', () => {
assert.step('open record');
});
assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi');
// sanity check: later on, we'll check that clicking on the avatar doesn't open the record
await dom.click(list.$('.o_data_row:first span'));
await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar'));
await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar'));
await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar'));
assert.verifySteps([
'open record',
'read res.users 11',
'call service openDMChatWindow 1',
'read res.users 7',
'call service openDMChatWindow 2',
'call service openDMChatWindow 1',
]);
list.destroy();
});
QUnit.test('many2one_avatar_user widget: click on self', async function (assert) {
assert.expect(4);
const form = await createView({
View: FormView,
model: 'foo',
data: this.data,
arch: '<form><field name="user_id" widget="many2one_avatar_user"/></form>',
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
return this._super(...arguments);
},
session: {
uid: 23,
},
res_id: 4,
});
mock.intercept(form, 'call_service', (ev) => {
if (ev.data.service === 'mail_service') {
throw new Error('should not call mail_service');
}
if (ev.data.service === 'notification') {
assert.step(`display notification "${ev.data.args[0].title}"`);
}
}, true);
assert.strictEqual(form.$('.o_field_widget[name=user_id]').text().trim(), 'Yoshi');
await dom.click(form.$('.o_m2o_avatar'));
assert.verifySteps([
'read foo 4',
'display notification "Cannot chat with yourself"',
]);
form.destroy();
});
QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) {
assert.expect(6);
const kanban = await createView({
View: KanbanView,
model: 'foo',
data: this.data,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="user_id" widget="many2one_avatar_user"/>
</div>
</t>
</templates>
</kanban>`,
});
assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), '');
assert.containsN(kanban, '.o_m2o_avatar', 4);
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/res.users/11/image_128');
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/res.users/7/image_128');
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/res.users/11/image_128');
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/res.users/23/image_128');
kanban.destroy();
});
});
});
......@@ -10,6 +10,7 @@
<template id="assets_backend" name="mail assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/mail/static/src/js/many2many_tags_email.js"></script>
<script type="text/javascript" src="/mail/static/src/js/many2one_avatar_user.js"></script>
<!-- Services -->
<script type="text/javascript" src="/mail/static/src/js/services/mail_manager.js"></script>
......@@ -97,6 +98,7 @@
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/systray.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/mail_activity.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/many2one_avatar_user.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/activity_view.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/kanban_view.scss"/>
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/attachment_box.scss"/>
......@@ -123,6 +125,7 @@
<script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/discuss_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/document_viewer_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/many2one_avatar_user_tests.js"></script>
<!-- systray -->
<script type="text/javascript" src="/mail/static/tests/systray/systray_activity_menu_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray/systray_messaging_menu_tests.js"></script>
......
......@@ -127,9 +127,11 @@
.oe_kanban_avatar {
border-radius: 50%;
object-fit: cover;
}
.oe_kanban_avatar, .o_field_many2one_avatar > .o_m2o_avatar {
width: 20px;
height: 20px;
object-fit: cover;
margin-left: 6px;
}
}
......
......@@ -2148,6 +2148,13 @@ Relational fields
- Supported field types: *many2one*
- many2one_avatar_user (Many2OneAvatarUser)
This widget is a specialization of the Many2OneAvatar. When the avatar is
clicked, we open a chat window with the corresponding user. This widget can
only be set on many2one fields pointing to the 'res.users' model.
- Supported field types: *many2one* (pointing to 'res.users')
- kanban.many2one (KanbanFieldMany2One)
Default widget for many2one fields (in kanban view). We need to disable all
editing in kanban views.
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment