[FIX] mail, *: fix opens and redirects, adapt messaging menu close
task-2244279 task-2262862 task-2274993 task-2277537 task-2282238 closes odoo/odoo#56755 X-original-commit: 17611f4adc5e950b0a75a2a6615b1da3c956387f Related: odoo/enterprise#12769 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com> Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
@@ -46,5 +46,8 @@
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'qweb': ['static/src/xml/hr_templates.xml'],
|
||||
'qweb': [
|
||||
'static/src/bugfix/bugfix.xml',
|
||||
'static/src/xml/hr_templates.xml',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file allows introducing new JS modules without contaminating other files.
|
||||
* This is useful when bug fixing requires adding such JS modules in stable
|
||||
* versions of Odoo. Any module that is defined in this file should be isolated
|
||||
* in its own file in master.
|
||||
*/
|
||||
odoo.define('hr/static/src/bugfix/bugfix.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* This file allows introducing new styles without contaminating other files.
|
||||
* This is useful when bug fixing requires adding new components for instance in
|
||||
* stable versions of Odoo. Any style that is defined in this file should be isolated
|
||||
* in its own file in master.
|
||||
*/
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!--
|
||||
This file allows introducing new static templates without contaminating other files.
|
||||
This is useful when bug fixing requires adding new components for instance in stable
|
||||
versions of Odoo. Any template that is defined in this file should be isolated
|
||||
in its own file in master.
|
||||
-->
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define('hr/static/src/bugfix/bugfix_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* This file allows introducing new QUnit test modules without contaminating
|
||||
* other test files. This is useful when bug fixing requires adding new
|
||||
* components for instance in stable versions of Odoo. Any test that is defined
|
||||
* in this file should be isolated in its own file in master.
|
||||
*/
|
||||
QUnit.module('hr', {}, function () {
|
||||
QUnit.module('bugfix', {}, function () {
|
||||
QUnit.module('bugfix_tests.js', {
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -22,11 +22,7 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var $chat_button = self.$el.find('.o_employee_chat_btn');
|
||||
if (self.state.context.uid === self.state.data.user_id.res_id) { // Hide the button for yourself
|
||||
$chat_button.hide();
|
||||
} else {
|
||||
$chat_button.off('click').on('click', self._onOpenChat.bind(self));
|
||||
}
|
||||
$chat_button.off('click').on('click', self._onOpenChat.bind(self));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -38,9 +34,8 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
_onOpenChat: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
this.trigger_up('open_chat', {
|
||||
partner_id: this.state.data.user_partner_id.res_id
|
||||
});
|
||||
const env = Component.env;
|
||||
env.messaging.openChat({ employeeId: this.state.data.id });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -48,23 +43,9 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
// USAGE OF CHAT MIXIN IN FORM VIEWS
|
||||
var EmployeeFormRenderer = FormRenderer.extend(ChatMixin);
|
||||
|
||||
var EmployeeFormController = FormController.extend({
|
||||
custom_events: _.extend({}, FormController.prototype.custom_events, {
|
||||
open_chat: '_onOpenChat'
|
||||
}),
|
||||
|
||||
_onOpenChat: function (ev) {
|
||||
const env = Component.env;
|
||||
const partner = env.models['mail.partner'].insert({
|
||||
id: ev.data.partner_id,
|
||||
});
|
||||
partner.openChat();
|
||||
},
|
||||
});
|
||||
|
||||
var EmployeeFormView = FormView.extend({
|
||||
config: _.extend({}, FormView.prototype.config, {
|
||||
Controller: EmployeeFormController,
|
||||
Controller: FormController,
|
||||
Renderer: EmployeeFormRenderer
|
||||
}),
|
||||
});
|
||||
@@ -80,23 +61,9 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
}),
|
||||
});
|
||||
|
||||
var EmployeeKanbanController = KanbanController.extend({
|
||||
custom_events: _.extend({}, KanbanController.prototype.custom_events, {
|
||||
open_chat: '_onOpenChat'
|
||||
}),
|
||||
|
||||
_onOpenChat: function (ev) {
|
||||
const env = Component.env;
|
||||
const partner = env.models['mail.partner'].insert({
|
||||
id: ev.data.partner_id,
|
||||
});
|
||||
partner.openChat();
|
||||
},
|
||||
});
|
||||
|
||||
var EmployeeKanbanView = KanbanView.extend({
|
||||
config: _.extend({}, KanbanView.prototype.config, {
|
||||
Controller: EmployeeKanbanController,
|
||||
Controller: KanbanController,
|
||||
Renderer: EmployeeKanbanRenderer
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,48 +8,26 @@ odoo.define('hr.Many2OneAvatarEmployee', function (require) {
|
||||
// Usage:
|
||||
// <field name="employee_id" widget="many2one_avatar_employee"/>
|
||||
|
||||
const { _t } = require('web.core');
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
const { Many2OneAvatarUser, KanbanMany2OneAvatarUser } = require('mail.Many2OneAvatarUser');
|
||||
const session = require('web.session');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
const Many2OneAvatarEmployeeMixin = {
|
||||
supportedModel: 'hr.employee',
|
||||
|
||||
/**
|
||||
* Set the field to read on 'hr.employee' to get the partner id.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.partnerField = 'user_partner_id';
|
||||
},
|
||||
supportedModels: ['hr.employee', 'hr.employee.public'],
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Display a warning if the user clicked on himself, or on an employee
|
||||
* not associated with any user.
|
||||
*
|
||||
* @override
|
||||
* @param {number} [partnerId] the id of the clicked partner
|
||||
*/
|
||||
_displayWarning(partnerId) {
|
||||
if (partnerId !== session.partner_id) {
|
||||
// this is not ourself, so if we get here it means that the
|
||||
// employee is not associated with any user
|
||||
this.displayNotification({
|
||||
message: _t('You can only chat with employees that have a dedicated user'),
|
||||
type: 'info',
|
||||
});
|
||||
} else {
|
||||
this._super(...arguments);
|
||||
}
|
||||
},
|
||||
async _onAvatarClicked(ev) {
|
||||
ev.stopPropagation(); // in list view, prevent from opening the record
|
||||
const env = Component.env;
|
||||
await env.messaging.openChat({ employeeId: this.value.res_id });
|
||||
}
|
||||
};
|
||||
|
||||
const Many2OneAvatarEmployee = Many2OneAvatarUser.extend(Many2OneAvatarEmployeeMixin);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
odoo.define('hr/static/src/models/employee/employee.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { registerNewModel } = require('mail/static/src/model/model_core.js');
|
||||
const { attr, one2one } = require('mail/static/src/model/model_field.js');
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class Employee extends dependencies['mail.model'] {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('user_id' in data) {
|
||||
data2.hasCheckedUser = true;
|
||||
if (!data.user_id) {
|
||||
data2.user = [['unlink']];
|
||||
} else {
|
||||
const partnerNameGet = data['user_partner_id'];
|
||||
const partnerData = {
|
||||
display_name: partnerNameGet[1],
|
||||
id: partnerNameGet[0],
|
||||
};
|
||||
const userNameGet = data['user_id'];
|
||||
const userData = {
|
||||
id: userNameGet[0],
|
||||
partner: [['insert', partnerData]],
|
||||
partnerDisplayName: userNameGet[1],
|
||||
};
|
||||
data2.user = [['insert', userData]];
|
||||
}
|
||||
}
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `read` RPC on the `hr.employee.public`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {Object} param0.context
|
||||
* @param {string[]} param0.fields
|
||||
* @param {integer[]} param0.ids
|
||||
*/
|
||||
static async performRpcRead({ context, fields, ids }) {
|
||||
const employeesData = await this.env.services.rpc({
|
||||
model: 'hr.employee.public',
|
||||
method: 'read',
|
||||
args: [ids],
|
||||
kwargs: {
|
||||
context,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
this.env.models['hr.employee'].insert(employeesData.map(employeeData =>
|
||||
this.env.models['hr.employee'].convertData(employeeData)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `search_read` RPC on `hr.employee.public`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {Object} param0.context
|
||||
* @param {Array[]} param0.domain
|
||||
* @param {string[]} param0.fields
|
||||
*/
|
||||
static async performRpcSearchRead({ context, domain, fields }) {
|
||||
const employeesData = await this.env.services.rpc({
|
||||
model: 'hr.employee.public',
|
||||
method: 'search_read',
|
||||
kwargs: {
|
||||
context,
|
||||
domain,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
this.env.models['hr.employee'].insert(employeesData.map(employeeData =>
|
||||
this.env.models['hr.employee'].convertData(employeeData)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this employee has a related user and partner and links
|
||||
* them if applicable.
|
||||
*/
|
||||
async checkIsUser() {
|
||||
return this.env.models['hr.employee'].performRpcRead({
|
||||
ids: [this.id],
|
||||
fields: ['user_id', 'user_partner_id'],
|
||||
context: { active_test: false },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chat between the user of this employee and the current user.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async getChat() {
|
||||
if (!this.user && !this.hasCheckedUser) {
|
||||
await this.async(() => this.checkIsUser());
|
||||
}
|
||||
// prevent chatting with non-users
|
||||
if (!this.user) {
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("You can only chat with employees that have a dedicated user."),
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
return this.user.getChat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a chat between the user of this employee and the current user
|
||||
* and returns it.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @param {Object} [options] forwarded to @see `mail.thread:open()`
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async openChat(options) {
|
||||
const chat = await this.async(() => this.getChat());
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
await this.async(() => chat.open(options));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the most appropriate view that is a profile for this employee.
|
||||
*/
|
||||
async openProfile() {
|
||||
return this.env.messaging.openDocument({
|
||||
id: this.id,
|
||||
model: 'hr.employee.public',
|
||||
});
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static _createRecordLocalId(data) {
|
||||
return `${this.modelName}_${data.id}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Employee.fields = {
|
||||
/**
|
||||
* Whether an attempt was already made to fetch the user corresponding
|
||||
* to this employee. This prevents doing the same RPC multiple times.
|
||||
*/
|
||||
hasCheckedUser: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Unique identifier for this employee.
|
||||
*/
|
||||
id: attr(),
|
||||
/**
|
||||
* Partner related to this employee.
|
||||
*/
|
||||
partner: one2one('mail.partner', {
|
||||
inverse: 'employee',
|
||||
related: 'user.partner',
|
||||
}),
|
||||
/**
|
||||
* User related to this employee.
|
||||
*/
|
||||
user: one2one('mail.user', {
|
||||
inverse: 'employee',
|
||||
}),
|
||||
};
|
||||
|
||||
Employee.modelName = 'hr.employee';
|
||||
|
||||
return Employee;
|
||||
}
|
||||
|
||||
registerNewModel('hr.employee', factory);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
odoo.define('hr/static/src/models/messaging/messaging.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
registerInstancePatchModel,
|
||||
} = require('mail/static/src/model/model_core.js');
|
||||
|
||||
registerInstancePatchModel('mail.messaging', 'hr/static/src/models/messaging/messaging.js', {
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {integer} [param0.employeeId]
|
||||
*/
|
||||
async getChat({ employeeId }) {
|
||||
if (employeeId) {
|
||||
const employee = this.env.models['hr.employee'].insert({ id: employeeId });
|
||||
return employee.getChat();
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async openProfile({ id, model }) {
|
||||
if (model === 'hr.employee' || model === 'hr.employee.public') {
|
||||
const employee = this.env.models['hr.employee'].insert({ id });
|
||||
return employee.openProfile();
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
odoo.define('hr/static/src/models/partner/partner.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
registerInstancePatchModel,
|
||||
registerFieldPatchModel,
|
||||
} = require('mail/static/src/model/model_core.js');
|
||||
const { attr, one2one } = require('mail/static/src/model/model_field.js');
|
||||
|
||||
registerInstancePatchModel('mail.partner', 'hr/static/src/models/partner/partner.js', {
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Checks whether this partner has a related employee and links them if
|
||||
* applicable.
|
||||
*/
|
||||
async checkIsEmployee() {
|
||||
await this.async(() => this.env.models['hr.employee'].performRpcSearchRead({
|
||||
context: { active_test: false },
|
||||
domain: [['user_partner_id', '=', this.id]],
|
||||
fields: ['user_id', 'user_partner_id'],
|
||||
}));
|
||||
this.update({ hasCheckedEmployee: true });
|
||||
},
|
||||
/**
|
||||
* When a partner is an employee, its employee profile contains more useful
|
||||
* information to know who he is than its partner profile.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async openProfile() {
|
||||
// limitation of patch, `this._super` becomes unavailable after `await`
|
||||
const _super = this._super.bind(this, ...arguments);
|
||||
if (!this.employee && !this.hasCheckedEmployee) {
|
||||
await this.async(() => this.checkIsEmployee());
|
||||
}
|
||||
if (this.employee) {
|
||||
return this.employee.openProfile();
|
||||
}
|
||||
return _super();
|
||||
},
|
||||
});
|
||||
|
||||
registerFieldPatchModel('mail.partner', 'hr/static/src/models/partner/partner.js', {
|
||||
/**
|
||||
* Employee related to this partner. It is computed through
|
||||
* the inverse relation and should be considered read-only.
|
||||
*/
|
||||
employee: one2one('hr.employee', {
|
||||
inverse: 'partner',
|
||||
}),
|
||||
/**
|
||||
* Whether an attempt was already made to fetch the employee corresponding
|
||||
* to this partner. This prevents doing the same RPC multiple times.
|
||||
*/
|
||||
hasCheckedEmployee: attr({
|
||||
default: false,
|
||||
}),
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define('hr/static/src/models/user/user.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
registerFieldPatchModel,
|
||||
} = require('mail/static/src/model/model_core.js');
|
||||
const { one2one } = require('mail/static/src/model/model_field.js');
|
||||
|
||||
registerFieldPatchModel('mail.user', 'hr/static/src/models/user/user.js', {
|
||||
/**
|
||||
* Employee related to this user.
|
||||
*/
|
||||
employee: one2one('hr.employee', {
|
||||
inverse: 'user',
|
||||
}),
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
odoo.define('hr/static/tests/helpers/mock_models.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const MockModels = require('mail/static/tests/helpers/mock_models.js');
|
||||
|
||||
MockModels.patch('hr/static/tests/helpers/mock_models.js', T =>
|
||||
class extends T {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
static generateData() {
|
||||
const data = super.generateData(...arguments);
|
||||
Object.assign(data, {
|
||||
'hr.employee.public': {
|
||||
fields: {
|
||||
display_name: { string: "Name", type: "char" },
|
||||
user_id: { string: "User", type: "many2one", relation: 'res.users' },
|
||||
user_partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' },
|
||||
},
|
||||
records: [],
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
odoo.define('hr.Many2OneAvatarEmployeeTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
|
||||
const {
|
||||
afterEach,
|
||||
afterNextRender,
|
||||
beforeEach,
|
||||
start,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const KanbanView = require('web.KanbanView');
|
||||
@@ -20,7 +25,7 @@ QUnit.module('hr', {}, function () {
|
||||
Object.assign(this.data, {
|
||||
'foo': {
|
||||
fields: {
|
||||
employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee' },
|
||||
employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee.public' },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, employee_id: 11 },
|
||||
@@ -29,26 +34,17 @@ QUnit.module('hr', {}, function () {
|
||||
{ id: 4, employee_id: 23 },
|
||||
],
|
||||
},
|
||||
'hr.employee': {
|
||||
fields: {
|
||||
display_name: { string: "Name", type: "char" },
|
||||
user_partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' },
|
||||
},
|
||||
records: [{
|
||||
id: 11,
|
||||
name: "Mario",
|
||||
user_partner_id: 11,
|
||||
}, {
|
||||
id: 7,
|
||||
name: "Luigi",
|
||||
user_partner_id: 12,
|
||||
}, {
|
||||
id: 23,
|
||||
name: "Yoshi",
|
||||
user_partner_id: 13,
|
||||
}],
|
||||
},
|
||||
});
|
||||
this.data['hr.employee.public'].records.push(
|
||||
{ id: 11, name: "Mario", user_id: 11, user_partner_id: 11 },
|
||||
{ id: 7, name: "Luigi", user_id: 12, user_partner_id: 12 },
|
||||
{ id: 23, name: "Yoshi", user_id: 13, user_partner_id: 13 }
|
||||
);
|
||||
this.data['res.users'].records.push(
|
||||
{ id: 11, partner_id: 11 },
|
||||
{ id: 12, partner_id: 12 },
|
||||
{ id: 13, partner_id: 13 }
|
||||
);
|
||||
this.data['res.partner'].records.push(
|
||||
{ id: 11, display_name: "Mario" },
|
||||
{ id: 12, display_name: "Luigi" },
|
||||
@@ -61,9 +57,10 @@ QUnit.module('hr', {}, function () {
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_employee widget in list view', async function (assert) {
|
||||
assert.expect(4);
|
||||
assert.expect(11);
|
||||
|
||||
const { widget: list } = await start({
|
||||
hasChatWindow: true,
|
||||
hasView: true,
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
@@ -79,18 +76,59 @@ QUnit.module('hr', {}, function () {
|
||||
|
||||
assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi');
|
||||
|
||||
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'));
|
||||
// click on first employee
|
||||
await afterNextRender(() =>
|
||||
dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar'))
|
||||
);
|
||||
assert.verifySteps(
|
||||
['read hr.employee.public 11'],
|
||||
"first employee should have been read to find its partner"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ChatWindowHeader_name',
|
||||
'should have opened chat window'
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ChatWindowHeader_name').textContent,
|
||||
"Mario",
|
||||
'chat window should be with clicked employee'
|
||||
);
|
||||
|
||||
// click on second employee
|
||||
await afterNextRender(() =>
|
||||
dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar')
|
||||
));
|
||||
assert.verifySteps(
|
||||
['read hr.employee.public 7'],
|
||||
"second employee should have been read to find its partner"
|
||||
);
|
||||
assert.containsN(
|
||||
document.body,
|
||||
'.o_ChatWindowHeader_name',
|
||||
2,
|
||||
'should have opened second chat window'
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_ChatWindowHeader_name')[1].textContent,
|
||||
"Luigi",
|
||||
'chat window should be with clicked employee'
|
||||
);
|
||||
|
||||
assert.verifySteps([
|
||||
'read hr.employee 11',
|
||||
// 'call service openDMChatWindow 1',
|
||||
'read hr.employee 7',
|
||||
// 'call service openDMChatWindow 2',
|
||||
// 'call service openDMChatWindow 1',
|
||||
]);
|
||||
// click on third employee (same as first)
|
||||
await afterNextRender(() =>
|
||||
dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar'))
|
||||
);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
"employee should not have been read again because we already know its partner"
|
||||
);
|
||||
assert.containsN(
|
||||
document.body,
|
||||
'.o_ChatWindowHeader_name',
|
||||
2,
|
||||
"should still have only 2 chat windows because third is the same partner as first"
|
||||
);
|
||||
|
||||
list.destroy();
|
||||
});
|
||||
@@ -117,18 +155,19 @@ QUnit.module('hr', {}, function () {
|
||||
|
||||
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/hr.employee/11/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/hr.employee/7/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/hr.employee/11/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/hr.employee/23/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/hr.employee.public/11/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/hr.employee.public/7/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/hr.employee.public/11/image_128');
|
||||
assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/hr.employee.public/23/image_128');
|
||||
|
||||
kanban.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_employee: click on an employee not associated with a user', async function (assert) {
|
||||
assert.expect(5);
|
||||
assert.expect(6);
|
||||
|
||||
this.data['hr.employee'].records[0].user_partner_id = false;
|
||||
this.data['hr.employee.public'].records[0].user_id = false;
|
||||
this.data['hr.employee.public'].records[0].user_partner_id = false;
|
||||
const { widget: form } = await start({
|
||||
hasView: true,
|
||||
View: FormView,
|
||||
@@ -156,50 +195,19 @@ QUnit.module('hr', {}, function () {
|
||||
|
||||
assert.verifySteps([
|
||||
'read foo 1',
|
||||
'read hr.employee 11',
|
||||
'display notification "You can only chat with employees that have a dedicated user"',
|
||||
'read hr.employee.public 11',
|
||||
]);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_employee: click on self', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.currentPartnerId = 11;
|
||||
const { widget: form } = await start({
|
||||
hasView: true,
|
||||
View: FormView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
arch: '<form><field name="employee_id" widget="many2one_avatar_employee"/></form>',
|
||||
mockRPC(route, args) {
|
||||
if (args.method === 'read') {
|
||||
assert.step(`read ${args.model} ${args.args[0]}`);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
session: {
|
||||
partner_id: this.data.currentPartnerId,
|
||||
},
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
mock.intercept(form, 'call_service', (ev) => {
|
||||
if (ev.data.service === 'notification') {
|
||||
assert.step(`display notification "${ev.data.args[0].message}"`);
|
||||
}
|
||||
}, true);
|
||||
|
||||
assert.strictEqual(form.$('.o_field_widget[name=employee_id]').text().trim(), 'Mario');
|
||||
|
||||
await dom.click(form.$('.o_m2o_avatar'));
|
||||
|
||||
assert.verifySteps([
|
||||
'read foo 1',
|
||||
'read hr.employee 11',
|
||||
'display notification "You cannot chat with yourself"',
|
||||
]);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.toast .o_notification_content',
|
||||
"should display a toast notification after failing to open chat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_notification_content').textContent,
|
||||
"You can only chat with employees that have a dedicated user.",
|
||||
"should display the correct information in the notification"
|
||||
);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
<template id="assets_backend" name="hr assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<link rel="stylesheet" type="text/scss" href="/hr/static/src/scss/hr.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr/static/src/bugfix/bugfix.scss"/>
|
||||
|
||||
<script type="text/javascript" src="/hr/static/src/bugfix/bugfix.js"/>
|
||||
<script type="text/javascript" src="/hr/static/src/js/chat.js"></script>
|
||||
<script type="text/javascript" src="/hr/static/src/js/language.js"></script>
|
||||
<script type="text/javascript" src="/hr/static/src/js/many2one_avatar_employee.js"></script>
|
||||
<script type="text/javascript" src="/hr/static/src/js/standalone_m2o_avatar_employee.js"></script>
|
||||
<script type="text/javascript" src="/hr/static/src/models/employee/employee.js"/>
|
||||
<script type="text/javascript" src="/hr/static/src/models/messaging/messaging.js"/>
|
||||
<script type="text/javascript" src="/hr/static/src/models/partner/partner.js"/>
|
||||
<script type="text/javascript" src="/hr/static/src/models/user/user.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="qunit_suite" name="hr tests" inherit_id="web.qunit_suite_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/hr/static/src/bugfix/bugfix_tests.js"/>
|
||||
<script type="text/javascript" src="/hr/static/tests/helpers/mock_models.js"/>
|
||||
<script type="text/javascript" src="/hr/static/tests/many2one_avatar_employee_tests.js"></script>
|
||||
<script type="text/javascript" src="/hr/static/tests/standalone_m2o_avatar_employee_tests.js"></script>
|
||||
</xpath>
|
||||
|
||||
+3
-6
@@ -11,12 +11,9 @@ registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/s
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {integer} param0.partner_id
|
||||
*/
|
||||
_handleNotificationChannelTypingStatus(data) {
|
||||
const { channelId, partner_id } = data;
|
||||
_handleNotificationChannelTypingStatus(channelId, data) {
|
||||
const { partner_id } = data;
|
||||
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
|
||||
id: channelId,
|
||||
model: 'mail.channel',
|
||||
@@ -32,7 +29,7 @@ registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/s
|
||||
} else {
|
||||
partnerId = partner_id;
|
||||
}
|
||||
this._super(Object.assign(data, {
|
||||
this._super(channelId, Object.assign(data, {
|
||||
partner_id: partnerId,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -167,20 +167,15 @@ class ChatWindow extends Component {
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
_onAutocompleteSelect(ev, ui) {
|
||||
const partnerId = ui.item.id;
|
||||
const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId);
|
||||
const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
|
||||
if (chat) {
|
||||
chat.open({ chatWindowMode: 'from_new_message' });
|
||||
} else {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
autoselectChatWindowMode: 'from_new_message',
|
||||
partnerId,
|
||||
type: 'chat',
|
||||
});
|
||||
async _onAutocompleteSelect(ev, ui) {
|
||||
const chat = await this.env.messaging.getChat({ partnerId: ui.item.id });
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
this.env.messaging.chatWindowManager.openThread(chat, {
|
||||
makeActive: true,
|
||||
replaceNewMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,6 +220,12 @@ class ChatWindow extends Component {
|
||||
if (this.chatWindow.isFocused) {
|
||||
return;
|
||||
}
|
||||
if (isEventHandled(ev, 'Message.authorOpenChat')) {
|
||||
return;
|
||||
}
|
||||
if (isEventHandled(ev, 'Message.authorOpenProfile')) {
|
||||
return;
|
||||
}
|
||||
this.chatWindow.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
hasComposerCurrentPartnerAvatar="false"
|
||||
hasComposerSendButton="env.messaging.device.isMobile"
|
||||
hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'"
|
||||
haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'"
|
||||
threadViewLocalId="chatWindow.threadView.localId"
|
||||
t-on-focusin="_onFocusinThread"
|
||||
t-ref="thread"
|
||||
|
||||
+1
-3
@@ -123,9 +123,7 @@ class ChatWindowHiddenMenu extends Component {
|
||||
*/
|
||||
_onClickedChatWindow(ev) {
|
||||
const chatWindow = ev.detail.chatWindow;
|
||||
chatWindow.makeVisible();
|
||||
chatWindow.unfold();
|
||||
chatWindow.focus();
|
||||
chatWindow.makeActive();
|
||||
this.env.messaging.chatWindowManager.closeHiddenMenu();
|
||||
}
|
||||
|
||||
|
||||
+123
-1
@@ -255,6 +255,128 @@ QUnit.test('chat window new message: fold', async function (assert) {
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('open chat from "new message" chat window should open chat in place of this "new message" chat window', async function (assert) {
|
||||
/**
|
||||
* InnerWith computation uses following info:
|
||||
* ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method)
|
||||
* (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual)
|
||||
*
|
||||
* - chat window width: 325px
|
||||
* - start/end/between gap width: 10px/10px/5px
|
||||
* - hidden menu width: 200px
|
||||
* - global width: 1920px
|
||||
*
|
||||
* Enough space for 3 visible chat windows:
|
||||
* 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920
|
||||
*/
|
||||
assert.expect(11);
|
||||
|
||||
this.data['res.partner'].records.push({ id: 131, name: "Partner 131" });
|
||||
this.data['res.users'].records.push({ partner_id: 131 });
|
||||
this.data['mail.channel'].records.push(
|
||||
{ is_minimized: true },
|
||||
{ is_minimized: true },
|
||||
);
|
||||
const imSearchDef = makeDeferred();
|
||||
await this.start({
|
||||
env: {
|
||||
browser: {
|
||||
innerWidth: 1920,
|
||||
},
|
||||
},
|
||||
async mockRPC(route, args) {
|
||||
const res = await this._super(...arguments);
|
||||
if (args.method === 'im_search') {
|
||||
imSearchDef.resolve();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
});
|
||||
assert.containsN(
|
||||
document.body,
|
||||
'.o_ChatWindow',
|
||||
2,
|
||||
"should have 2 chat windows initially"
|
||||
);
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_ChatWindow.o-new-message',
|
||||
"should not have any 'new message' chat window initially"
|
||||
);
|
||||
|
||||
// open "new message" chat window
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_MessagingMenu_toggler`).click()
|
||||
);
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ChatWindow.o-new-message',
|
||||
"should have 'new message' chat window after clicking 'new message' in messaging menu"
|
||||
);
|
||||
assert.containsN(
|
||||
document.body,
|
||||
'.o_ChatWindow',
|
||||
3,
|
||||
"should have 3 chat window after opening 'new message' chat window",
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ChatWindow_newMessageFormInput',
|
||||
"'new message' chat window should have new message form input"
|
||||
);
|
||||
assert.hasClass(
|
||||
document.querySelector('.o_ChatWindow[data-visible-index="2"]'),
|
||||
'o-new-message',
|
||||
"'new message' chat window should be the last chat window initially",
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_ChatWindow[data-visible-index="2"] .o_ChatWindowHeader_commandShiftRight').click()
|
||||
);
|
||||
assert.hasClass(
|
||||
document.querySelector('.o_ChatWindow[data-visible-index="1"]'),
|
||||
'o-new-message',
|
||||
"'new message' chat window should have moved to the middle after clicking shift previous",
|
||||
);
|
||||
|
||||
// search for a user in "new message" autocomplete
|
||||
document.execCommand('insertText', false, "131");
|
||||
document.querySelector(`.o_ChatWindow_newMessageFormInput`)
|
||||
.dispatchEvent(new window.KeyboardEvent('keydown'));
|
||||
document.querySelector(`.o_ChatWindow_newMessageFormInput`)
|
||||
.dispatchEvent(new window.KeyboardEvent('keyup'));
|
||||
// Wait for search RPC to be resolved. The following await lines are
|
||||
// necessary because autocomplete is an external lib therefore it is not
|
||||
// possible to use `afterNextRender`.
|
||||
await imSearchDef;
|
||||
await nextAnimationFrame();
|
||||
const link = document.querySelector('.ui-autocomplete .ui-menu-item a');
|
||||
assert.ok(
|
||||
link,
|
||||
"should have autocomplete suggestion after typing on 'new message' input"
|
||||
);
|
||||
assert.strictEqual(
|
||||
link.textContent,
|
||||
"Partner 131",
|
||||
"autocomplete suggestion should target the partner matching search term"
|
||||
);
|
||||
|
||||
await afterNextRender(() => link.click());
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_ChatWindow.o-new-message',
|
||||
"should have removed the 'new message' chat window after selecting a partner"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ChatWindow[data-visible-index="1"] .o_ChatWindowHeader_name').textContent,
|
||||
"Partner 131",
|
||||
"chat window with selected partner should be opened in position where 'new message' chat window was, which is in the middle"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('chat window: basic rendering', async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
@@ -1351,7 +1473,7 @@ QUnit.test('chat window: switch on TAB', async function (assert) {
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('chat window: TAB cycle with 3 open chat windows', async function (assert) {
|
||||
QUnit.test('chat window: TAB cycle with 3 open chat windows [REQUIRE FOCUS]', async function (assert) {
|
||||
/**
|
||||
* InnerWith computation uses following info:
|
||||
* ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method)
|
||||
|
||||
@@ -37,19 +37,18 @@ class Chatter extends Component {
|
||||
attachments: 1,
|
||||
},
|
||||
});
|
||||
this._threadRef = useRef('thread');
|
||||
/**
|
||||
* Reference of the composer. Useful to focus it.
|
||||
*/
|
||||
this._composerRef = useRef('composer');
|
||||
}
|
||||
|
||||
mounted() {
|
||||
if (this.chatter.thread) {
|
||||
this._notifyRendered();
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
|
||||
patched() {
|
||||
if (this.chatter.thread) {
|
||||
this._notifyRendered();
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -77,6 +76,22 @@ class Chatter extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_update() {
|
||||
if (this.chatter.thread) {
|
||||
this._notifyRendered();
|
||||
}
|
||||
if (this.chatter.isDoFocus) {
|
||||
this.chatter.update({ isDoFocus: false });
|
||||
const composer = this._composerRef.comp;
|
||||
if (composer) {
|
||||
composer.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -39,10 +39,8 @@
|
||||
class="o_Chatter_thread"
|
||||
hasComposer="false"
|
||||
hasScrollAdjust="chatter.hasMessageListScrollAdjust"
|
||||
haveMessagesAuthorRedirect="true"
|
||||
order="'desc'"
|
||||
threadViewLocalId="chatter.threadView.localId"
|
||||
t-ref="thread"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
@@ -121,7 +121,7 @@ class Composer extends Component {
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
this.el.scrollIntoView();
|
||||
}
|
||||
this.composer.focus();
|
||||
this._textInputRef.comp.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +288,7 @@ class Composer extends Component {
|
||||
ev.stopPropagation();
|
||||
this._textInputRef.comp.saveStateInStore();
|
||||
this.composer.insertIntoTextInput(ev.detail.unicode);
|
||||
this.composer.focus();
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,7 +323,7 @@ class Composer extends Component {
|
||||
if (ev.key === 'Escape') {
|
||||
if (this._emojisPopoverRef.comp) {
|
||||
this._emojisPopoverRef.comp.close();
|
||||
this.composer.focus();
|
||||
this.focus();
|
||||
markEventHandled(ev, 'Composer.closeEmojisPopover');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ class ComposerTextInput extends Component {
|
||||
return this.env._t("Write something...");
|
||||
}
|
||||
|
||||
focus() {
|
||||
this._textareaRef.el.focus();
|
||||
}
|
||||
|
||||
focusout() {
|
||||
this.saveStateInStore();
|
||||
this._textareaRef.el.blur();
|
||||
@@ -155,10 +159,6 @@ class ComposerTextInput extends Component {
|
||||
this.composer.textInputCursorEnd
|
||||
);
|
||||
this._updateHeight();
|
||||
if (this.composer.isDoFocus) {
|
||||
this._textareaRef.el.focus();
|
||||
this.composer.update({ isDoFocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,7 +273,7 @@ class ComposerTextInput extends Component {
|
||||
if (this.composer.activeSuggestedPartner) {
|
||||
this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner);
|
||||
this.composer.closeMentionSuggestions();
|
||||
this.composer.focus();
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -322,7 +322,7 @@ class ComposerTextInput extends Component {
|
||||
_onPartnerMentionSuggestionClicked(ev) {
|
||||
this.composer.insertMentionedPartner(ev.detail.partner);
|
||||
this.composer.closeMentionSuggestions();
|
||||
this.composer.focus();
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,12 +47,13 @@ class Discuss extends Component {
|
||||
});
|
||||
this._updateLocalStoreProps();
|
||||
/**
|
||||
* Reference of the thread. Useful to update scroll position correctly
|
||||
* on patch. AKU TODO: this made sense when composer was outside of
|
||||
* thread, but this may no longer be necessary??
|
||||
* Reference of the composer. Useful to focus it.
|
||||
*/
|
||||
this._threadRef = useRef('thread');
|
||||
|
||||
this._composerRef = useRef('composer');
|
||||
/**
|
||||
* Reference of the ThreadView. Useful to focus it.
|
||||
*/
|
||||
this._threadViewRef = useRef('threadView');
|
||||
// bind since passed as props
|
||||
this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this);
|
||||
this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this);
|
||||
@@ -66,6 +67,7 @@ class Discuss extends Component {
|
||||
this.discuss.openInitThread();
|
||||
}
|
||||
this._updateLocalStoreProps();
|
||||
this._update();
|
||||
}
|
||||
|
||||
patched() {
|
||||
@@ -83,6 +85,7 @@ class Discuss extends Component {
|
||||
}
|
||||
this._activeThreadCache = this.discuss.threadView.threadCache;
|
||||
this._updateLocalStoreProps();
|
||||
this._update();
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
@@ -139,6 +142,24 @@ class Discuss extends Component {
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_update() {
|
||||
if (this.discuss.isDoFocus) {
|
||||
this.discuss.update({ isDoFocus: false });
|
||||
const composer = this._composerRef.comp;
|
||||
if (composer) {
|
||||
composer.focus();
|
||||
} else {
|
||||
const threadView = this._threadViewRef.comp;
|
||||
if (threadView) {
|
||||
threadView.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -248,16 +269,6 @@ class Discuss extends Component {
|
||||
this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
* @param {Object} ev.detail
|
||||
* @param {mail.thread} ev.detail.thread
|
||||
*/
|
||||
_onSelectThread(ev) {
|
||||
this.discuss.threadView.update({ thread: [['link', ev.detail.thread]] });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
hasComposerThreadTyping="true"
|
||||
hasMessageCheckbox="true"
|
||||
hasSquashCloseMessages="discuss.thread.model !== 'mail.box'"
|
||||
haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'"
|
||||
haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox"
|
||||
haveMessagesReplyIcon="discuss.thread === env.messaging.inbox"
|
||||
selectedMessageLocalId="
|
||||
@@ -66,6 +65,7 @@
|
||||
discuss.replyingToMessage.localId"
|
||||
threadViewLocalId="discuss.threadView.localId"
|
||||
t-on-o-rendered="_onThreadRendered"
|
||||
t-ref="threadView"
|
||||
/>
|
||||
</t>
|
||||
<t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')">
|
||||
@@ -77,7 +77,6 @@
|
||||
<NotificationList
|
||||
class="o_Discuss_notificationList"
|
||||
filter="discuss.activeMobileNavbarTabId"
|
||||
t-on-o-select-thread="_onSelectThread"
|
||||
/>
|
||||
</t>
|
||||
<t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage">
|
||||
@@ -96,6 +95,7 @@
|
||||
hasDiscardButton="true"
|
||||
hasThreadName="true"
|
||||
t-on-o-message-posted="_onReplyingToMessageMessagePosted"
|
||||
t-ref="composer"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
@@ -2116,7 +2116,7 @@ QUnit.test('message origin redirect to channel', async function (assert) {
|
||||
});
|
||||
|
||||
QUnit.test('redirect to author (open chat)', async function (assert) {
|
||||
assert.expect(9);
|
||||
assert.expect(7);
|
||||
|
||||
// expected correspondent, with a random unique id that will be used to link
|
||||
// partner to chat and a random name that will be asserted in the test
|
||||
@@ -2143,13 +2143,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) {
|
||||
id: 100,
|
||||
model: 'mail.channel',
|
||||
res_id: 1,
|
||||
},
|
||||
{
|
||||
author_id: this.data.currentPartnerId,
|
||||
channel_ids: [1],
|
||||
id: 101,
|
||||
model: 'mail.channel',
|
||||
res_id: 1,
|
||||
}
|
||||
);
|
||||
await this.start({
|
||||
@@ -2187,8 +2180,8 @@ QUnit.test('redirect to author (open chat)', async function (assert) {
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
|
||||
2,
|
||||
"should have 2 messages"
|
||||
1,
|
||||
"should have 1 message"
|
||||
);
|
||||
const msg1 = document.querySelector(`
|
||||
.o_Discuss_thread
|
||||
@@ -2196,12 +2189,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) {
|
||||
this.env.models['mail.message'].find(message => message.id === 100).localId
|
||||
}"]
|
||||
`);
|
||||
const msg2 = document.querySelector(`
|
||||
.o_Discuss_thread
|
||||
.o_Message[data-message-local-id="${
|
||||
this.env.models['mail.message'].find(message => message.id === 101).localId
|
||||
}"]
|
||||
`);
|
||||
assert.strictEqual(
|
||||
msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
|
||||
1,
|
||||
@@ -2211,15 +2198,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) {
|
||||
msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
|
||||
"message1 should have redirect to author"
|
||||
);
|
||||
assert.strictEqual(
|
||||
msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
|
||||
1,
|
||||
"message2 should have author image"
|
||||
);
|
||||
assert.notOk(
|
||||
msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
|
||||
"message2 should not have redirect to author (self-author)"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
msg1.querySelector(`:scope .o_Message_authorAvatar`).click()
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class DiscussMobileMailboxSelection extends Component {
|
||||
*/
|
||||
_onClick(ev) {
|
||||
const { mailbox } = ev.currentTarget.dataset;
|
||||
this.discuss.threadView.update({ thread: [['link', mailbox]] });
|
||||
mailbox.open();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ class DiscussSidebarItem extends Component {
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClick(ev) {
|
||||
this.discuss.threadView.update({ thread: [['link', this.thread]] });
|
||||
this.thread.open();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,10 +43,7 @@ class Follower extends Component {
|
||||
_onClickDetails(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.env.messaging.openDocument({
|
||||
id: this.follower.resId,
|
||||
model: this.follower.resModel,
|
||||
});
|
||||
this.follower.openProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ odoo.define('mail/static/src/components/follower/follower_tests.js', function (r
|
||||
const components = {
|
||||
Follower: require('mail/static/src/components/follower/follower.js'),
|
||||
};
|
||||
const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
|
||||
const {
|
||||
afterEach,
|
||||
afterNextRender,
|
||||
@@ -141,8 +142,8 @@ QUnit.test('click on channel follower details', async function (assert) {
|
||||
assert.step('do_action');
|
||||
assert.strictEqual(
|
||||
payload.action.res_id,
|
||||
1,
|
||||
"The redirect action should redirect to the right res id (1)"
|
||||
10,
|
||||
"The redirect action should redirect to the right res id (10)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_model,
|
||||
@@ -155,7 +156,8 @@ QUnit.test('click on channel follower details', async function (assert) {
|
||||
"The redirect action should be of type 'ir.actions.act_window'"
|
||||
);
|
||||
});
|
||||
|
||||
this.data['res.partner'].records.push({ id: 100 });
|
||||
this.data['mail.channel'].records.push({ id: 10 });
|
||||
await this.start({
|
||||
env: { bus },
|
||||
});
|
||||
@@ -164,7 +166,7 @@ QUnit.test('click on channel follower details', async function (assert) {
|
||||
model: 'res.partner',
|
||||
});
|
||||
const follower = await this.env.models['mail.follower'].create({
|
||||
channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]],
|
||||
channel: [['insert', { id: 10, model: 'mail.channel', name: "channel" }]],
|
||||
followedThread: [['link', thread]],
|
||||
id: 2,
|
||||
isActive: true,
|
||||
@@ -192,6 +194,7 @@ QUnit.test('click on channel follower details', async function (assert) {
|
||||
QUnit.test('click on partner follower details', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const openFormDef = makeDeferred();
|
||||
const bus = new Bus();
|
||||
bus.on('do-action', null, payload => {
|
||||
assert.step('do_action');
|
||||
@@ -210,8 +213,9 @@ QUnit.test('click on partner follower details', async function (assert) {
|
||||
"ir.actions.act_window",
|
||||
"The redirect action should be of type 'ir.actions.act_window'"
|
||||
);
|
||||
openFormDef.resolve();
|
||||
});
|
||||
|
||||
this.data['res.partner'].records.push({ id: 100 });
|
||||
await this.start({
|
||||
env: { bus },
|
||||
});
|
||||
@@ -243,6 +247,7 @@ QUnit.test('click on partner follower details', async function (assert) {
|
||||
);
|
||||
|
||||
document.querySelector('.o_Follower_details').click();
|
||||
await openFormDef;
|
||||
assert.verifySteps(
|
||||
['do_action'],
|
||||
"clicking on follower should redirect to partner form view"
|
||||
@@ -302,6 +307,7 @@ QUnit.test('click on edit follower', async function (assert) {
|
||||
QUnit.test('edit follower and close subtype dialog', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
this.data['res.partner'].records.push({ id: 100 });
|
||||
await this.start({
|
||||
hasDialog: true,
|
||||
async mockRPC(route, args) {
|
||||
|
||||
@@ -11,6 +11,7 @@ const components = {
|
||||
PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
|
||||
};
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
const { markEventHandled } = require('mail/static/src/utils/utils.js');
|
||||
const { timeFromNow } = require('mail.utils');
|
||||
|
||||
const { _lt } = require('web.core');
|
||||
@@ -156,19 +157,18 @@ class Message extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether author redirect feature is enabled on message.
|
||||
* Click on message author should redirect to author.
|
||||
* Determines whether author open chat feature is enabled on message.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get hasAuthorRedirect() {
|
||||
if (!this.props.hasAuthorRedirect) {
|
||||
return false;
|
||||
}
|
||||
get hasAuthorOpenChat() {
|
||||
if (!this.message.author) {
|
||||
return false;
|
||||
}
|
||||
if (this.message.author === this.env.messaging.currentPartner) {
|
||||
if (
|
||||
this.message.originThread &&
|
||||
this.message.originThread.correspondent === this.message.author
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -236,6 +236,13 @@ class Message extends Component {
|
||||
return this.message.attachments.filter(attachment => attachment.fileType !== 'image');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get OPEN_CHAT() {
|
||||
return this.env._t("Open chat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this message viewable in its enclosing scroll environment (usually
|
||||
* message list).
|
||||
@@ -245,7 +252,7 @@ class Message extends Component {
|
||||
* @param {string} [param0.block='end']
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async scrollIntoView({ behavior='auto', block='end' }={}) {
|
||||
async scrollIntoView({ behavior = 'auto', block = 'end' } = {}) {
|
||||
this.el.scrollIntoView({
|
||||
behavior,
|
||||
block,
|
||||
@@ -419,23 +426,33 @@ class Message extends Component {
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClick(ev) {
|
||||
if (ev.target.closest('.o_channel_redirect')) {
|
||||
this.env.messaging.openProfile({
|
||||
id: Number(ev.target.dataset.oeId),
|
||||
model: 'mail.channel',
|
||||
});
|
||||
// avoid following dummy href
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (ev.target.closest('.o_mention')) {
|
||||
this.env.messaging.redirect({
|
||||
this.env.messaging.openProfile({
|
||||
id: Number(ev.target.dataset.oeId),
|
||||
model: ev.target.dataset.oeModel,
|
||||
});
|
||||
// avoid following dummy href
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (ev.target.closest('.o_mail_redirect')) {
|
||||
this.env.messaging.redirect({
|
||||
this.env.messaging.openProfile({
|
||||
id: Number(ev.target.dataset.oeId),
|
||||
model: ev.target.dataset.oeModel,
|
||||
});
|
||||
// avoid following dummy href
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.state.isClicked = !this.state.isClicked;
|
||||
}
|
||||
|
||||
@@ -443,17 +460,24 @@ class Message extends Component {
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickAuthor(ev) {
|
||||
if (!this.hasAuthorRedirect) {
|
||||
_onClickAuthorAvatar(ev) {
|
||||
if (!this.hasAuthorOpenChat) {
|
||||
return;
|
||||
}
|
||||
markEventHandled(ev, 'Message.authorOpenChat');
|
||||
this.message.author.openChat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickAuthorName(ev) {
|
||||
if (!this.message.author) {
|
||||
return;
|
||||
}
|
||||
this.env.messaging.redirect({
|
||||
id: this.message.author.id,
|
||||
model: this.message.author.model,
|
||||
});
|
||||
markEventHandled(ev, 'Message.authorOpenProfile');
|
||||
this.message.author.openProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,11 +538,9 @@ class Message extends Component {
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickOriginThread(ev) {
|
||||
// avoid following dummy href
|
||||
ev.preventDefault();
|
||||
this.env.messaging.redirect({
|
||||
id: this.message.originThread.id,
|
||||
model: this.message.originThread.model,
|
||||
});
|
||||
this.message.originThread.open();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,7 +603,6 @@ class Message extends Component {
|
||||
Object.assign(Message, {
|
||||
components,
|
||||
defaultProps: {
|
||||
hasAuthorRedirect: false,
|
||||
hasCheckbox: false,
|
||||
hasMarkAsReadIcon: false,
|
||||
hasReplyIcon: false,
|
||||
@@ -594,7 +615,6 @@ Object.assign(Message, {
|
||||
optional: true,
|
||||
validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
|
||||
},
|
||||
hasAuthorRedirect: Boolean,
|
||||
hasCheckbox: Boolean,
|
||||
hasMarkAsReadIcon: Boolean,
|
||||
hasReplyIcon: Boolean,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }">
|
||||
<t t-if="!props.isSquashed">
|
||||
<div class="o_Message_authorAvatarContainer o_Message_sidebarItem">
|
||||
<img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/>
|
||||
<img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorOpenChat, o_redirect: hasAuthorOpenChat }" t-att-src="avatar" t-on-click="_onClickAuthorAvatar" t-att-title="hasAuthorOpenChat ? OPEN_CHAT : ''" alt="Avatar"/>
|
||||
<t t-if="message.author and message.author.im_status">
|
||||
<PartnerImStatusIcon
|
||||
class="o_Message_partnerImStatusIcon"
|
||||
@@ -58,7 +58,7 @@
|
||||
<t t-if="!props.isSquashed">
|
||||
<div class="o_Message_header">
|
||||
<t t-if="message.author">
|
||||
<div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''">
|
||||
<div class="o_Message_authorName o_Message_authorRedirect o_redirect" t-on-click="_onClickAuthorName" title="Open profile">
|
||||
<t t-esc="message.author.nameOrDisplayName"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -141,9 +141,6 @@ class MessageList extends Component {
|
||||
case 'more-messages-loaded':
|
||||
this._adjustFromMoreMessagesLoaded(hint);
|
||||
break;
|
||||
default:
|
||||
this.threadView.markComponentHintProcessed(hint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._willPatchSnapshot = undefined;
|
||||
@@ -552,7 +549,6 @@ Object.assign(MessageList, {
|
||||
hasMessageCheckbox: false,
|
||||
hasScrollAdjust: true,
|
||||
hasSquashCloseMessages: false,
|
||||
haveMessagesAuthorRedirect: false,
|
||||
haveMessagesMarkAsReadIcon: false,
|
||||
haveMessagesReplyIcon: false,
|
||||
order: 'asc',
|
||||
@@ -560,7 +556,6 @@ Object.assign(MessageList, {
|
||||
props: {
|
||||
hasMessageCheckbox: Boolean,
|
||||
hasSquashCloseMessages: Boolean,
|
||||
haveMessagesAuthorRedirect: Boolean,
|
||||
haveMessagesMarkAsReadIcon: Boolean,
|
||||
haveMessagesReplyIcon: Boolean,
|
||||
hasScrollAdjust: Boolean,
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
t-att-class="{
|
||||
'o-has-message-selection': props.selectedMessageLocalId,
|
||||
}"
|
||||
hasAuthorRedirect="props.haveMessagesAuthorRedirect"
|
||||
hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
|
||||
hasCheckbox="props.hasMessageCheckbox"
|
||||
hasReplyIcon="props.haveMessagesReplyIcon"
|
||||
|
||||
@@ -33,13 +33,6 @@ class MessagingMenu extends Component {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Reference of the new message input in mobile. Useful to include it
|
||||
* and autocomplete menu as "inside" the messaging menu, to prevent
|
||||
* closing the messaging menu otherwise.
|
||||
*/
|
||||
this._mobileNewMessageInputRef = useRef('mobileNewMessageInput');
|
||||
|
||||
// bind since passed as props
|
||||
this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this);
|
||||
this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this);
|
||||
@@ -121,20 +114,14 @@ class MessagingMenu extends Component {
|
||||
// in mobile: keeps the messaging menu open in background
|
||||
// TODO: maybe need to move this to a mobile component?
|
||||
// task-2089887
|
||||
if (
|
||||
this.env.messaging.device.isMobile &&
|
||||
this.env.messaging.chatWindowManager.hasVisibleChatWindows
|
||||
) {
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
return;
|
||||
}
|
||||
// ignore click inside the menu
|
||||
if (this.el.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
const input = this._mobileNewMessageInputRef.comp;
|
||||
if (input && input.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
// in all other cases: close the messaging menu when clicking outside
|
||||
this.messagingMenu.close();
|
||||
}
|
||||
|
||||
@@ -194,21 +181,7 @@ class MessagingMenu extends Component {
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
_onMobileNewMessageInputSelect(ev, ui) {
|
||||
const partnerId = ui.item.id;
|
||||
const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId);
|
||||
const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
|
||||
if (chat) {
|
||||
chat.open();
|
||||
} else {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
partnerId,
|
||||
type: 'chat',
|
||||
});
|
||||
}
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.messagingMenu.close();
|
||||
}
|
||||
this.env.messaging.openChat({ partnerId: ui.item.id });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,20 +219,6 @@ class MessagingMenu extends Component {
|
||||
this.messagingMenu.update({ activeTabId: ev.detail.tabId });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
* @param {Object} ev.detail
|
||||
* @param {mail.thread} ev.detail.thread
|
||||
*/
|
||||
_onSelectThread(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.detail.thread.open();
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(MessagingMenu, {
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
select="_onMobileNewMessageInputSelect"
|
||||
source="_onMobileNewMessageInputSource"
|
||||
t-on-o-hide="_onHideMobileNewMessage"
|
||||
t-ref="mobileNewMessageInput"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
@@ -54,7 +53,6 @@
|
||||
class="o_MessagingMenu_notificationList"
|
||||
t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
|
||||
filter="messagingMenu.activeTabId"
|
||||
t-on-o-select-thread="_onSelectThread"
|
||||
/>
|
||||
<t t-if="env.messaging.device.isMobile">
|
||||
<MobileMessagingNavbar
|
||||
|
||||
@@ -61,6 +61,9 @@ class NotificationGroup extends Component {
|
||||
return;
|
||||
}
|
||||
this.group.openDocuments();
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,9 @@ class NotificationGroup extends Component {
|
||||
*/
|
||||
_onClickMarkAsRead(ev) {
|
||||
this.group.openCancelAction();
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
@@ -364,6 +364,7 @@ QUnit.test('different mail.channel are not grouped', async function (assert) {
|
||||
// they are linked to different channels, even though the model is the same.
|
||||
assert.expect(6);
|
||||
|
||||
this.data['mail.channel'].records.push({ id: 31 }, { id: 32 });
|
||||
this.data['mail.message'].records.push(
|
||||
// first message that is expected to have a failure
|
||||
{
|
||||
|
||||
@@ -74,9 +74,7 @@ class NotificationRequest extends Component {
|
||||
if (def) {
|
||||
def.then(this._handleResponseNotificationPermission.bind(this));
|
||||
}
|
||||
|
||||
const device = this.env.messaging.device;
|
||||
if (!device.isMobile) {
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -105,7 +105,10 @@ class ThreadNeedactionPreview extends Component {
|
||||
// handled in `_onClickMarkAsRead`
|
||||
return;
|
||||
}
|
||||
this.trigger('o-select-thread', { thread: this.thread });
|
||||
this.thread.open();
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,7 +97,10 @@ class ThreadPreview extends Component {
|
||||
// handled in `_onClickMarkAsRead`
|
||||
return;
|
||||
}
|
||||
this.trigger('o-select-thread', { thread: this.thread });
|
||||
this.thread.open();
|
||||
if (!this.env.messaging.device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,7 +132,6 @@ Object.assign(ThreadView, {
|
||||
hasComposer: false,
|
||||
hasMessageCheckbox: false,
|
||||
hasSquashCloseMessages: false,
|
||||
haveMessagesAuthorRedirect: false,
|
||||
haveMessagesMarkAsReadIcon: false,
|
||||
haveMessagesReplyIcon: false,
|
||||
order: 'asc',
|
||||
@@ -168,7 +167,6 @@ Object.assign(ThreadView, {
|
||||
optional: true,
|
||||
},
|
||||
hasSquashCloseMessages: Boolean,
|
||||
haveMessagesAuthorRedirect: Boolean,
|
||||
haveMessagesMarkAsReadIcon: Boolean,
|
||||
haveMessagesReplyIcon: Boolean,
|
||||
order: {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
hasMessageCheckbox="props.hasMessageCheckbox"
|
||||
hasScrollAdjust="props.hasScrollAdjust"
|
||||
hasSquashCloseMessages="props.hasSquashCloseMessages"
|
||||
haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect"
|
||||
haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
|
||||
haveMessagesReplyIcon="props.haveMessagesReplyIcon"
|
||||
order="props.order"
|
||||
|
||||
@@ -304,7 +304,7 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe
|
||||
"Channel should have been fetched but not seen yet"
|
||||
);
|
||||
|
||||
await afterNextRender(() => thread.composer.focus());
|
||||
await afterNextRender(() => document.querySelector('.o_ComposerTextInput_textarea').focus());
|
||||
assert.verifySteps(
|
||||
['rpc:channel_seen'],
|
||||
"Channel should have been marked as seen after threadView got the focus"
|
||||
@@ -355,6 +355,8 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com
|
||||
|
||||
const threadView = this.env.models['mail.thread_view'].create({ thread: [['link', thread]] });
|
||||
await this.createThreadViewComponent(threadView, { hasComposer: true });
|
||||
|
||||
document.querySelector('.o_ComposerTextInput_textarea').focus();
|
||||
const notifications = [
|
||||
[['myDB', 'mail.channel', 100], {
|
||||
channelId: 100,
|
||||
|
||||
@@ -4,27 +4,18 @@ odoo.define('mail.Many2OneAvatarUser', function (require) {
|
||||
// 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').
|
||||
// opens a DM chat window with the corresponding user.
|
||||
//
|
||||
// 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.
|
||||
// This widget is supported on many2one fields pointing to 'res.users'.
|
||||
//
|
||||
// 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.
|
||||
// to other models than 'res.users'.
|
||||
|
||||
const { _t } = require('web.core');
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
const { Many2OneAvatar } = require('web.relational_fields');
|
||||
const session = require('web.session');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
@@ -32,71 +23,17 @@ odoo.define('mail.Many2OneAvatarUser', function (require) {
|
||||
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',
|
||||
supportedModels: ['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.supportedModels.includes(this.field.relation)) {
|
||||
throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`);
|
||||
}
|
||||
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({
|
||||
message: _t('You cannot chat with yourself'),
|
||||
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];
|
||||
},
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
@@ -105,27 +42,15 @@ odoo.define('mail.Many2OneAvatarUser', function (require) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* corresponding user.
|
||||
*
|
||||
* @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) {
|
||||
const env = Component.env;
|
||||
const partner = env.models['mail.partner'].insert({
|
||||
id: partnerId,
|
||||
});
|
||||
partner.openChat();
|
||||
} else {
|
||||
this._displayWarning(partnerId);
|
||||
}
|
||||
const env = Component.env;
|
||||
await env.messaging.openChat({ userId: this.value.res_id });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function factory(dependencies) {
|
||||
|
||||
expand() {
|
||||
if (this.thread) {
|
||||
this.thread.openExpanded();
|
||||
this.thread.open({ expanded: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +82,23 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assume that this chat window was hidden before-hand.
|
||||
* Makes this chat window active, which consists of making it visible,
|
||||
* unfolding it, and focusing it.
|
||||
*/
|
||||
makeActive() {
|
||||
this.makeVisible();
|
||||
this.unfold();
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes this chat window visible by swapping it with the last visible
|
||||
* chat window, or do nothing if it is already visible.
|
||||
*/
|
||||
makeVisible() {
|
||||
if (this.isVisible) {
|
||||
return;
|
||||
}
|
||||
const lastVisible = this.manager.lastVisible;
|
||||
this.manager.swap(this, lastVisible);
|
||||
}
|
||||
@@ -159,6 +173,17 @@ function factory(dependencies) {
|
||||
return this._isFolded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeIsVisible() {
|
||||
if (!this.manager) {
|
||||
return false;
|
||||
}
|
||||
return this.manager.allOrderedVisible.includes(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
@@ -316,6 +341,17 @@ function factory(dependencies) {
|
||||
],
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Whether this chat window is visible or not. Should be considered
|
||||
* read-only. Setting this value manually will not make it visible.
|
||||
* @see `makeVisible`
|
||||
*/
|
||||
isVisible: attr({
|
||||
compute: '_computeIsVisible',
|
||||
dependencies: [
|
||||
'managerAllOrderedVisible',
|
||||
],
|
||||
}),
|
||||
manager: many2one('mail.chat_window_manager', {
|
||||
inverse: 'chatWindows',
|
||||
}),
|
||||
|
||||
@@ -70,6 +70,19 @@ function factory(dependencies) {
|
||||
this.update({ isHiddenMenuOpen: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all chat windows related to the given thread.
|
||||
*
|
||||
* @param {mail.thread} thread
|
||||
*/
|
||||
closeThread(thread) {
|
||||
for (const chatWindow of this.chatWindows) {
|
||||
if (chatWindow.thread === thread) {
|
||||
chatWindow.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openHiddenMenu() {
|
||||
this.update({ isHiddenMenuOpen: true });
|
||||
}
|
||||
@@ -81,18 +94,17 @@ function factory(dependencies) {
|
||||
manager: [['link', this]],
|
||||
});
|
||||
}
|
||||
newMessageChatWindow.makeVisible();
|
||||
newMessageChatWindow.unfold();
|
||||
newMessageChatWindow.focus();
|
||||
newMessageChatWindow.makeActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mail.thread} thread
|
||||
* @param {Object} [param1={}]
|
||||
* @param {string} [param1.mode]
|
||||
* @param {boolean} [param1.makeActive=false]
|
||||
* @param {boolean} [param1.replaceNewMessage=false]
|
||||
*/
|
||||
openThread(thread, { mode } = {}) {
|
||||
let chatWindow = this.env.models['mail.chat_window'].find(chatWindow =>
|
||||
openThread(thread, { makeActive = false, replaceNewMessage = false } = {}) {
|
||||
let chatWindow = this.chatWindows.find(chatWindow =>
|
||||
chatWindow.thread === thread
|
||||
);
|
||||
if (!chatWindow) {
|
||||
@@ -103,20 +115,15 @@ function factory(dependencies) {
|
||||
}]],
|
||||
});
|
||||
}
|
||||
if (mode === 'last_visible' && !chatWindow.isVisible) {
|
||||
chatWindow.makeVisible();
|
||||
chatWindow.unfold();
|
||||
chatWindow.focus();
|
||||
if (thread.foldState === 'closed') {
|
||||
thread.update({ pendingFoldState: 'open' });
|
||||
}
|
||||
if (mode === 'from_new_message') {
|
||||
if (!this.newMessageChatWindow) {
|
||||
throw new Error('Cannot open thread in chat window in mode "from_new_message" without any new message chat window');
|
||||
}
|
||||
if (replaceNewMessage && this.newMessageChatWindow) {
|
||||
this.swap(chatWindow, this.newMessageChatWindow);
|
||||
this.newMessageChatWindow.close();
|
||||
chatWindow.makeVisible();
|
||||
chatWindow.unfold();
|
||||
chatWindow.focus();
|
||||
}
|
||||
if (makeActive) {
|
||||
chatWindow.makeActive();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ function factory(dependencies) {
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
focus() {
|
||||
this.update({ isDoFocus: true });
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const thread = this.thread;
|
||||
if (!thread || thread.isTemporary) {
|
||||
@@ -76,18 +80,14 @@ function factory(dependencies) {
|
||||
|
||||
showLogNote() {
|
||||
this.update({ isComposerVisible: true });
|
||||
this.thread.composer.update({
|
||||
isDoFocus: true,
|
||||
isLog: true,
|
||||
});
|
||||
this.thread.composer.update({ isLog: true });
|
||||
this.focus();
|
||||
}
|
||||
|
||||
showSendMessage() {
|
||||
this.update({ isComposerVisible: true });
|
||||
this.thread.composer.update({
|
||||
isDoFocus: true,
|
||||
isLog: false,
|
||||
});
|
||||
this.thread.composer.update({ isLog: false });
|
||||
this.focus();
|
||||
}
|
||||
|
||||
toggleActivityBoxVisibility() {
|
||||
@@ -310,6 +310,12 @@ function factory(dependencies) {
|
||||
default: false,
|
||||
dependencies: ['threadId'],
|
||||
}),
|
||||
/**
|
||||
* Determine whether this chatter should be focused at next render.
|
||||
*/
|
||||
isDoFocus: attr({
|
||||
default: false,
|
||||
}),
|
||||
isShowingAttachmentsLoading: attr({
|
||||
default: false,
|
||||
}),
|
||||
|
||||
@@ -38,10 +38,6 @@ function factory(dependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.update({ isDoFocus: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts text content in text input based on selection.
|
||||
*
|
||||
@@ -606,9 +602,6 @@ function factory(dependencies) {
|
||||
],
|
||||
default: false,
|
||||
}),
|
||||
isDoFocus: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* If true composer will log a note, else a comment will be posted.
|
||||
*/
|
||||
|
||||
@@ -38,24 +38,35 @@ function factory(dependencies) {
|
||||
this.update({ isOpen: false });
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.update({ isDoFocus: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
handleAddChannelAutocompleteSelect(ev, ui) {
|
||||
if (ui.item.special) {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
name: this.addingChannelValue,
|
||||
public: ui.item.special,
|
||||
type: 'channel',
|
||||
});
|
||||
} else {
|
||||
this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true });
|
||||
}
|
||||
async handleAddChannelAutocompleteSelect(ev, ui) {
|
||||
const name = this.addingChannelValue;
|
||||
this.clearIsAddingItem();
|
||||
if (ui.item.special) {
|
||||
const channel = await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcCreateChannel({
|
||||
name,
|
||||
privacy: ui.item.special,
|
||||
})
|
||||
);
|
||||
channel.open();
|
||||
} else {
|
||||
const channel = await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcJoinChannel({
|
||||
channelId: ui.item.id,
|
||||
})
|
||||
);
|
||||
channel.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,20 +117,7 @@ function factory(dependencies) {
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
handleAddChatAutocompleteSelect(ev, ui) {
|
||||
const partnerId = ui.item.id;
|
||||
const partner = this.env.models['mail.partner'].find(partner =>
|
||||
partner.id === partnerId
|
||||
);
|
||||
const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
|
||||
if (chat) {
|
||||
this.threadView.update({ thread: [['link', chat]] });
|
||||
} else {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
partnerId,
|
||||
type: 'chat',
|
||||
});
|
||||
}
|
||||
this.env.messaging.openChat({ partnerId: ui.item.id });
|
||||
this.clearIsAddingItem();
|
||||
}
|
||||
|
||||
@@ -160,11 +158,31 @@ function factory(dependencies) {
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
thread.open();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Opens the given thread in Discuss, and opens Discuss if necessary.
|
||||
*
|
||||
* @param {mail.thread} thread
|
||||
*/
|
||||
async openThread(thread) {
|
||||
this.threadView.update({
|
||||
stringifiedDomain: '[]',
|
||||
thread: [['link', thread]],
|
||||
});
|
||||
thread.open({ resetDiscussDomain: true });
|
||||
this.focus();
|
||||
if (!this.isOpen) {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'mail.action_discuss',
|
||||
options: {
|
||||
active_id: this.threadToActiveId(this),
|
||||
clear_breadcrumbs: false,
|
||||
on_reverse_breadcrumb: () => this.close(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +207,7 @@ function factory(dependencies) {
|
||||
this.replyingToMessageOriginThreadComposer.update({
|
||||
isLog: !message.is_discussion && !message.is_notification
|
||||
});
|
||||
this.replyingToMessageOriginThreadComposer.focus();
|
||||
this.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,6 +399,12 @@ function factory(dependencies) {
|
||||
default: false,
|
||||
dependencies: ['isOpen'],
|
||||
}),
|
||||
/**
|
||||
* Determine whether this discuss should be focused at next render.
|
||||
*/
|
||||
isDoFocus: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Whether the discuss app is open or not. Useful to determine
|
||||
* whether the discuss or chat window logic should be applied.
|
||||
|
||||
@@ -63,6 +63,16 @@ function factory(dependencies) {
|
||||
this._subtypesListDialog = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the most appropriate view that is a profile for this follower.
|
||||
*/
|
||||
async openProfile() {
|
||||
if (this.partner) {
|
||||
return this.partner.openProfile();
|
||||
}
|
||||
return this.channel.openProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this follower from its related thread.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,17 @@ function factory(dependencies) {
|
||||
return super._willDelete(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts messaging and related records.
|
||||
*/
|
||||
async start() {
|
||||
this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
|
||||
this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
|
||||
await this.async(() => this.initializer.start());
|
||||
this.notificationHandler.start();
|
||||
this.update({ isInitialized: true });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
@@ -32,12 +43,53 @@ function factory(dependencies) {
|
||||
|
||||
/**
|
||||
* Open the form view of the record with provided id and model.
|
||||
* Gets the chat with the provided person and returns it.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {integer} [param0.partnerId]
|
||||
* @param {integer} [param0.userId]
|
||||
* @param {Object} [options]
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async getChat({ partnerId, userId }) {
|
||||
if (userId) {
|
||||
const user = this.env.models['mail.user'].insert({ id: userId });
|
||||
return user.getChat();
|
||||
}
|
||||
if (partnerId) {
|
||||
const partner = this.env.models['mail.partner'].insert({ id: partnerId });
|
||||
return partner.getChat();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a chat with the provided person and returns it.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @param {Object} person forwarded to @see `getChat()`
|
||||
* @param {Object} [options] forwarded to @see `mail.thread:open()`
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async openChat(person, options) {
|
||||
const chat = await this.async(() => this.getChat(person));
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
await this.async(() => chat.open(options));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the form view of the record with provided id and model.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.id
|
||||
* @param {string} param0.model
|
||||
*/
|
||||
openDocument({ id, model }) {
|
||||
async openDocument({ id, model }) {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
type: 'ir.actions.act_window',
|
||||
@@ -46,79 +98,47 @@ function factory(dependencies) {
|
||||
res_id: id,
|
||||
},
|
||||
});
|
||||
this.messagingMenu.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles redirection to a model and id. Try to handle it in the context
|
||||
* of messaging (e.g. open chat if this is a user), otherwise fallback to
|
||||
* opening form view of record.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.id
|
||||
* @param {string} param0.model
|
||||
* FIXME needs to be tested and maybe refactored (see task-2244279)
|
||||
*/
|
||||
async redirect({ id, model }) {
|
||||
if (model === 'mail.channel') {
|
||||
const channel = this.env.models['mail.thread'].find(thread =>
|
||||
thread.id === id &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
if (!channel || !channel.isPinned) {
|
||||
this.env.models['mail.thread'].joinChannel(id, { autoselect: true });
|
||||
return;
|
||||
}
|
||||
channel.open();
|
||||
} else if (model === 'res.partner') {
|
||||
if (id === this.currentPartner.id) {
|
||||
this.openDocument({
|
||||
model: 'res.partner',
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const partner = this.env.models['mail.partner'].insert({ id });
|
||||
if (!partner.user) {
|
||||
await this.async(() => partner.checkIsUser());
|
||||
}
|
||||
if (!partner.user) {
|
||||
// partner is not a user, open document instead
|
||||
this.openDocument({
|
||||
model: 'res.partner',
|
||||
id: partner.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const chat = partner.correspondentThreads.find(thread =>
|
||||
thread.channel_type === 'chat'
|
||||
);
|
||||
if (!chat) {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
partnerId: id,
|
||||
type: 'chat',
|
||||
});
|
||||
return;
|
||||
}
|
||||
chat.open();
|
||||
} else {
|
||||
this.openDocument({
|
||||
model,
|
||||
id,
|
||||
});
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
// messaging menu has a higher z-index than views so it must
|
||||
// be closed to ensure the visibility of the view
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start messaging and related records.
|
||||
* Opens the most appropriate view that is a profile for provided id and
|
||||
* model.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.id
|
||||
* @param {string} param0.model
|
||||
*/
|
||||
async start() {
|
||||
this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
|
||||
this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
|
||||
await this.async(() => this.initializer.start());
|
||||
this.notificationHandler.start();
|
||||
this.update({ isInitialized: true });
|
||||
async openProfile({ id, model }) {
|
||||
if (model === 'res.partner') {
|
||||
const partner = this.env.models['mail.partner'].insert({ id });
|
||||
return partner.openProfile();
|
||||
}
|
||||
if (model === 'res.users') {
|
||||
const user = this.env.models['mail.user'].insert({ id });
|
||||
return user.openProfile();
|
||||
}
|
||||
if (model === 'mail.channel') {
|
||||
let channel = this.env.models['mail.thread'].findFromIdentifyingData({ id, model: 'mail.channel' });
|
||||
if (!channel) {
|
||||
channel = (await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcChannelInfo({ ids: [id] })
|
||||
))[0];
|
||||
}
|
||||
if (!channel) {
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("You can only open the profile of existing channels."),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
return channel.openProfile();
|
||||
}
|
||||
return this.env.messaging.openDocument({ id, model });
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,102 @@ QUnit.module('messaging_tests.js', {
|
||||
afterEach() {
|
||||
afterEach(this);
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.test('openChat: display notification for partner without user', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
this.data['res.partner'].records.push({ id: 14 });
|
||||
await this.start();
|
||||
|
||||
await this.env.messaging.openChat({ partnerId: 14 });
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.toast .o_notification_content',
|
||||
"should display a toast notification after failing to open chat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_notification_content').textContent,
|
||||
"You can only chat with partners that have a dedicated user.",
|
||||
"should display the correct information in the notification"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('openChat: display notification for wrong user', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await this.start();
|
||||
|
||||
// user id not in this.data
|
||||
await this.env.messaging.openChat({ userId: 14 });
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.toast .o_notification_content',
|
||||
"should display a toast notification after failing to open chat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_notification_content').textContent,
|
||||
"You can only chat with existing users.",
|
||||
"should display the correct information in the notification"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('openChat: open new chat for user', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.data['res.partner'].records.push({ id: 14 });
|
||||
this.data['res.users'].records.push({ id: 11, partner_id: 14 });
|
||||
await this.start();
|
||||
|
||||
const existingChat = this.env.models['mail.thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === 14 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
assert.notOk(existingChat, 'a chat should not exist with the target partner initially');
|
||||
|
||||
await this.env.messaging.openChat({ partnerId: 14 });
|
||||
const chat = this.env.models['mail.thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === 14 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
assert.ok(chat, 'a chat should exist with the target partner');
|
||||
assert.strictEqual(chat.threadViews.length, 1, 'the chat should be displayed in a `mail.thread_view`');
|
||||
});
|
||||
|
||||
QUnit.test('openChat: open existing chat for user', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data['res.partner'].records.push({ id: 14 });
|
||||
this.data['res.users'].records.push({ id: 11, partner_id: 14 });
|
||||
this.data['mail.channel'].records.push({
|
||||
channel_type: "chat",
|
||||
id: 10,
|
||||
members: [this.data.currentPartnerId, 14],
|
||||
public: 'private',
|
||||
});
|
||||
await this.start();
|
||||
const existingChat = this.env.models['mail.thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === 14 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
assert.ok(existingChat, 'a chat should initially exist with the target partner');
|
||||
assert.strictEqual(existingChat.threadViews.length, 0, 'the chat should not be displayed in a `mail.thread_view`');
|
||||
|
||||
await this.env.messaging.openChat({ partnerId: 14 });
|
||||
assert.ok(existingChat, 'a chat should still exist with the target partner');
|
||||
assert.strictEqual(existingChat.id, 10, 'the chat should be the existing chat');
|
||||
assert.strictEqual(existingChat.threadViews.length, 1, 'the chat should now be displayed in a `mail.thread_view`');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+92
-79
@@ -78,9 +78,7 @@ function factory(dependencies) {
|
||||
case 'ir.needaction':
|
||||
return this._handleNotificationNeedaction(message);
|
||||
case 'mail.channel':
|
||||
return this._handleNotificationChannel(Object.assign({
|
||||
channelId: id,
|
||||
}, message));
|
||||
return this._handleNotificationChannel(id, message);
|
||||
case 'res.partner':
|
||||
if (id !== this.env.messaging.currentPartner.id) {
|
||||
// ignore broadcast to other partners
|
||||
@@ -97,16 +95,15 @@ function factory(dependencies) {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} channelId
|
||||
* @param {Object} data
|
||||
* @param {integer} data.channelId
|
||||
* @param {string} [data.info]
|
||||
* @param {boolean} [data.is_typing]
|
||||
* @param {integer} [data.last_message_id]
|
||||
* @param {integer} [data.partner_id]
|
||||
*/
|
||||
async _handleNotificationChannel(data) {
|
||||
async _handleNotificationChannel(channelId, data) {
|
||||
const {
|
||||
channelId,
|
||||
id,
|
||||
info,
|
||||
is_typing,
|
||||
@@ -115,41 +112,37 @@ function factory(dependencies) {
|
||||
} = data;
|
||||
switch (info) {
|
||||
case 'channel_fetched':
|
||||
return this._handleNotificationChannelFetched({
|
||||
return this._handleNotificationChannelFetched(channelId, {
|
||||
id,
|
||||
channelId,
|
||||
last_message_id,
|
||||
partner_id,
|
||||
});
|
||||
case 'channel_seen':
|
||||
return this._handleNotificationChannelSeen({
|
||||
return this._handleNotificationChannelSeen(channelId, {
|
||||
id,
|
||||
channelId,
|
||||
last_message_id,
|
||||
partner_id,
|
||||
});
|
||||
case 'typing_status':
|
||||
return this._handleNotificationChannelTypingStatus({
|
||||
channelId,
|
||||
return this._handleNotificationChannelTypingStatus(channelId, {
|
||||
is_typing,
|
||||
partner_id,
|
||||
});
|
||||
default:
|
||||
return this._handleNotificationChannelMessage(data);
|
||||
return this._handleNotificationChannelMessage(channelId, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {integer} param0.id
|
||||
* @param {integer} param0.last_message_id
|
||||
* @param {integer} param0.partner_id
|
||||
* @param {integer} channelId
|
||||
* @param {Object} param1
|
||||
* @param {integer} param1.id
|
||||
* @param {integer} param1.last_message_id
|
||||
* @param {integer} param1.partner_id
|
||||
*/
|
||||
async _handleNotificationChannelFetched({
|
||||
async _handleNotificationChannelFetched(channelId, {
|
||||
id,
|
||||
channelId,
|
||||
last_message_id,
|
||||
partner_id,
|
||||
}) {
|
||||
@@ -183,21 +176,18 @@ function factory(dependencies) {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {...Object} param0.data
|
||||
* @param {Array} [param0.data.author_id]
|
||||
* @param {integer} param0.data.author_id[0]
|
||||
* @param {integer[]} param0.data.channel_ids
|
||||
* @param {integer} channelId
|
||||
* @param {Object} messageData
|
||||
*/
|
||||
async _handleNotificationChannelMessage(param0) {
|
||||
const channelId = param0.channelId;
|
||||
const convertedData = this.env.models['mail.message'].convertData(param0);
|
||||
|
||||
const oldMessage = this.env.models['mail.message'].find(message =>
|
||||
message.id === convertedData.id
|
||||
);
|
||||
// locally save old value, as insert would overwrite them
|
||||
async _handleNotificationChannelMessage(channelId, messageData) {
|
||||
let channel = this.env.models['mail.thread'].findFromIdentifyingData({
|
||||
id: channelId,
|
||||
model: 'mail.channel',
|
||||
});
|
||||
const wasChannelExisting = !!channel;
|
||||
const convertedData = this.env.models['mail.message'].convertData(messageData);
|
||||
const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData);
|
||||
// locally save old values, as insert would overwrite them
|
||||
const oldMessageModerationStatus = (
|
||||
oldMessage && oldMessage.moderation_status
|
||||
);
|
||||
@@ -205,16 +195,21 @@ function factory(dependencies) {
|
||||
oldMessage && oldMessage.isModeratedByCurrentPartner
|
||||
);
|
||||
|
||||
const message = this.env.models['mail.message'].insert(convertedData);
|
||||
|
||||
// join the corresponding channel if necessary
|
||||
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
|
||||
id: channelId,
|
||||
model: 'mail.channel',
|
||||
});
|
||||
if (!channel || !channel.isPinned) {
|
||||
this.env.models['mail.thread'].joinChannel(channelId);
|
||||
// Fetch missing info from channel before going further. Inserting
|
||||
// a channel with incomplete info can lead to issues. This is in
|
||||
// particular the case with the `uuid` field that is assumed
|
||||
// "required" by the rest of the code and is necessary for some
|
||||
// features such as chat windows.
|
||||
if (!channel) {
|
||||
channel = (await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcChannelInfo({ ids: [channelId] })
|
||||
))[0];
|
||||
}
|
||||
if (!channel.isPinned) {
|
||||
channel.update({ isPendingPinned: true });
|
||||
}
|
||||
|
||||
const message = this.env.models['mail.message'].insert(convertedData);
|
||||
|
||||
// If the message was already known: nothing else should be done,
|
||||
// except if it was pending moderation by the current partner, then
|
||||
@@ -236,34 +231,49 @@ function factory(dependencies) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the channel wasn't known, joining the channel already updated
|
||||
// the counter.
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Message from mailing channel should not make a notification in
|
||||
// Odoo for users with notification "Handled by Email".
|
||||
// Channel has been marked as read server-side in this case, so
|
||||
// it should not display a notification by incrementing the
|
||||
// unread counter.
|
||||
if (
|
||||
channel &&
|
||||
channel.mass_mailing &&
|
||||
this.env.session.notification_type === 'email'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// In all other cases: update counter and notify if necessary
|
||||
|
||||
// In all other cases: update counter and notify if out of focus.
|
||||
const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
|
||||
if (!isOdooFocused) {
|
||||
this._notifyNewChannelMessageWhileOutOfFocus({ channel, message });
|
||||
}
|
||||
if (channel.model === 'mail.channel') {
|
||||
// Chat from OdooBot is considered disturbing and should only be
|
||||
// shown on the menu, but no notification and no thread open.
|
||||
const isChatWithOdooBot = (
|
||||
channel.correspondent &&
|
||||
channel.correspondent === this.env.messaging.partnerRoot
|
||||
);
|
||||
if (!isChatWithOdooBot) {
|
||||
// Notify if out of focus
|
||||
const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
|
||||
if (!isOdooFocused) {
|
||||
this._notifyNewChannelMessageWhileOutOfFocus({
|
||||
channel,
|
||||
message,
|
||||
});
|
||||
}
|
||||
channel.markAsFetched();
|
||||
// (re)open chat on receiving new message
|
||||
if (channel.channel_type !== 'channel') {
|
||||
this.env.messaging.chatWindowManager.openThread(channel);
|
||||
}
|
||||
}
|
||||
channel.update({ message_unread_counter: channel.message_unread_counter + 1 });
|
||||
|
||||
// If the channel wasn't known its correct counter was fetched at
|
||||
// the start of the method, no need update it here.
|
||||
if (!wasChannelExisting) {
|
||||
return;
|
||||
}
|
||||
channel.update({
|
||||
message_unread_counter: channel.message_unread_counter + 1,
|
||||
});
|
||||
// manually force recompute of counter
|
||||
this.messaging.messagingMenu.update();
|
||||
}
|
||||
@@ -273,14 +283,13 @@ function factory(dependencies) {
|
||||
* last message seen. Useful in order to track last message seen.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {integer} param0.id
|
||||
* @param {integer} param0.last_message_id
|
||||
* @param {integer} param0.partner_id
|
||||
* @param {integer} channelId
|
||||
* @param {Object} param1
|
||||
* @param {integer} param1.id
|
||||
* @param {integer} param1.last_message_id
|
||||
* @param {integer} param1.partner_id
|
||||
*/
|
||||
async _handleNotificationChannelSeen({
|
||||
channelId,
|
||||
async _handleNotificationChannelSeen(channelId, {
|
||||
id,
|
||||
last_message_id,
|
||||
partner_id,
|
||||
@@ -328,16 +337,12 @@ function factory(dependencies) {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {boolean} param0.is_typing
|
||||
* @param {integer} param0.partner_id
|
||||
* @param {integer} channelId
|
||||
* @param {Object} param1
|
||||
* @param {boolean} param1.is_typing
|
||||
* @param {integer} param1.partner_id
|
||||
*/
|
||||
_handleNotificationChannelTypingStatus({
|
||||
channelId,
|
||||
is_typing,
|
||||
partner_id,
|
||||
}) {
|
||||
_handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id }) {
|
||||
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
|
||||
id: channelId,
|
||||
model: 'mail.channel',
|
||||
@@ -456,7 +461,9 @@ function factory(dependencies) {
|
||||
* @param {string} data.uuid
|
||||
*/
|
||||
_handleNotificationPartnerChannel(data) {
|
||||
const convertedData = this.env.models['mail.thread'].convertData(data);
|
||||
const convertedData = this.env.models['mail.thread'].convertData(
|
||||
Object.assign({ model: 'mail.channel' }, data)
|
||||
);
|
||||
|
||||
let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData);
|
||||
const wasCurrentPartnerMember = (
|
||||
@@ -686,12 +693,18 @@ function factory(dependencies) {
|
||||
* @param {integer} param0.partner_id
|
||||
* @param {string} param0.title
|
||||
*/
|
||||
_handleNotificationPartnerUserConnection({ message, partner_id, title }) {
|
||||
async _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
|
||||
// If the current user invited a new user, and the new user is
|
||||
// connecting for the first time while the current user is present
|
||||
// then open a chat for the current user with the new user.
|
||||
this.env.services['bus_service'].sendNotification(title, message);
|
||||
const partner = this.env.models['mail.partner'].insert({
|
||||
id: partner_id,
|
||||
});
|
||||
partner.openChat();
|
||||
const chat = await this.async(() =>
|
||||
this.env.messaging.getChat({ partnerId: partner_id }
|
||||
));
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
this.env.messaging.chatWindowManager.openThread(chat);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ odoo.define('mail/static/src/models/notification_group/notification_group.js', f
|
||||
'use strict';
|
||||
|
||||
const { registerNewModel } = require('mail/static/src/model/model_core.js');
|
||||
const { attr, one2many } = require('mail/static/src/model/model_field.js');
|
||||
const { attr, many2one, one2many } = require('mail/static/src/model/model_field.js');
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
@@ -16,21 +16,18 @@ function factory(dependencies) {
|
||||
* Opens the view that allows to cancel all notifications of the group.
|
||||
*/
|
||||
openCancelAction() {
|
||||
const device = this.env.messaging.device;
|
||||
if (this.notification_type === 'email') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'mail.mail_resend_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
if (this.notification_type !== 'email') {
|
||||
return;
|
||||
}
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'mail.mail_resend_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!device.isMobile) {
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,19 +35,8 @@ function factory(dependencies) {
|
||||
* all the records in the group.
|
||||
*/
|
||||
openDocuments() {
|
||||
if (this.res_id) {
|
||||
if (this.res_model === 'mail.channel') {
|
||||
const channel = this.env.models['mail.thread'].insert({
|
||||
id: this.res_id,
|
||||
model: this.res_model,
|
||||
});
|
||||
channel.open();
|
||||
} else {
|
||||
this.env.messaging.openDocument({
|
||||
id: this.res_id,
|
||||
model: this.res_model,
|
||||
});
|
||||
}
|
||||
if (this.thread) {
|
||||
this.thread.open();
|
||||
} else {
|
||||
this._openDocuments();
|
||||
}
|
||||
@@ -60,6 +46,20 @@ function factory(dependencies) {
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
_computeThread() {
|
||||
if (this.res_id) {
|
||||
return [['insert', {
|
||||
id: this.res_id,
|
||||
model: this.res_model,
|
||||
}]];
|
||||
}
|
||||
return [['unlink']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
@@ -73,21 +73,23 @@ function factory(dependencies) {
|
||||
* @private
|
||||
*/
|
||||
_openDocuments() {
|
||||
const device = this.env.messaging.device;
|
||||
if (this.notification_type === 'email') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("Mail Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_has_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.notification_type !== 'email') {
|
||||
return;
|
||||
}
|
||||
if (!device.isMobile) {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("Mail Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_has_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
// messaging menu has a higher z-index than views so it must
|
||||
// be closed to ensure the visibility of the view
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
}
|
||||
@@ -102,6 +104,16 @@ function factory(dependencies) {
|
||||
res_id: attr(),
|
||||
res_model: attr(),
|
||||
res_model_name: attr(),
|
||||
/**
|
||||
* Related thread when the notification group concerns a single thread.
|
||||
*/
|
||||
thread: many2one('mail.thread', {
|
||||
compute: '_computeThread',
|
||||
dependencies: [
|
||||
'res_id',
|
||||
'res_model',
|
||||
],
|
||||
})
|
||||
};
|
||||
|
||||
NotificationGroup.modelName = 'mail.notification_group';
|
||||
|
||||
@@ -125,31 +125,73 @@ function factory(dependencies) {
|
||||
this._loopFetchImStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this partner has a related user and links them if
|
||||
* applicable.
|
||||
*/
|
||||
async checkIsUser() {
|
||||
const userIds = await this.async(() => this.env.services.rpc({
|
||||
model: 'res.users',
|
||||
method: 'search',
|
||||
args: [[['partner_id', '=', this.id]]],
|
||||
kwargs: {
|
||||
context: { active_test: false },
|
||||
},
|
||||
}));
|
||||
if (userIds.length) {
|
||||
this.update({ hasCheckedUser: true });
|
||||
if (userIds.length > 0) {
|
||||
this.update({ user: [['insert', { id: userIds[0] }]] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an existing or new chat.
|
||||
* Gets the chat between the user of this partner and the current user.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
openChat() {
|
||||
const chat = this.correspondentThreads.find(thread => thread.channel_type === 'chat');
|
||||
if (chat) {
|
||||
chat.open();
|
||||
} else {
|
||||
this.env.models['mail.thread'].createChannel({
|
||||
autoselect: true,
|
||||
partnerId: this.id,
|
||||
type: 'chat',
|
||||
});
|
||||
async getChat() {
|
||||
if (!this.user && !this.hasCheckedUser) {
|
||||
await this.async(() => this.checkIsUser());
|
||||
}
|
||||
// prevent chatting with non-users
|
||||
if (!this.user) {
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("You can only chat with partners that have a dedicated user."),
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
return this.user.getChat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a chat between the user of this partner and the current user
|
||||
* and returns it.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @param {Object} [options] forwarded to @see `mail.thread:open()`
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async openChat(options) {
|
||||
const chat = await this.async(() => this.getChat());
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
await this.async(() => chat.open(options));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the most appropriate view that is a profile for this partner.
|
||||
*/
|
||||
async openProfile() {
|
||||
return this.env.messaging.openDocument({
|
||||
id: this.id,
|
||||
model: 'res.partner',
|
||||
});
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
@@ -235,6 +277,13 @@ function factory(dependencies) {
|
||||
failureNotifications: one2many('mail.notification', {
|
||||
related: 'messagesAsAuthor.failureNotifications',
|
||||
}),
|
||||
/**
|
||||
* Whether an attempt was already made to fetch the user corresponding
|
||||
* to this partner. This prevents doing the same RPC multiple times.
|
||||
*/
|
||||
hasCheckedUser: attr({
|
||||
default: false,
|
||||
}),
|
||||
id: attr(),
|
||||
im_status: attr(),
|
||||
memberThreads: many2many('mail.thread', {
|
||||
|
||||
@@ -133,6 +133,9 @@ function factory(dependencies) {
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('model' in data) {
|
||||
data2.model = data.model;
|
||||
}
|
||||
if ('channel_type' in data) {
|
||||
data2.channel_type = data.channel_type;
|
||||
data2.model = 'mail.channel';
|
||||
@@ -248,82 +251,6 @@ function factory(dependencies) {
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a channel, which is a special kind of thread on model
|
||||
* 'mail.channel' with multiple members.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {boolean} [param0.autoselect=false] if set, when channel
|
||||
* has been created, it auto-open the channel. This opens in discuss
|
||||
* or chat window, depending on whether discuss is open or not.
|
||||
* @param {string} [param0.autoselectChatWindowMode]
|
||||
* @param {string} param0.name
|
||||
* @param {integer} [param0.partnerId]
|
||||
* @param {string} [param0.public]
|
||||
* @param {string} param0.type
|
||||
*/
|
||||
static async createChannel({
|
||||
autoselect = false,
|
||||
autoselectChatWindowMode,
|
||||
name,
|
||||
partnerId,
|
||||
public: publicStatus,
|
||||
type,
|
||||
}) {
|
||||
const device = this.env.messaging.device;
|
||||
// TODO FIX: potential duplicate chat task-2276490
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: type === 'chat' ? 'channel_get' : 'channel_create',
|
||||
args: type === 'chat' ? [[partnerId]] : [name, publicStatus],
|
||||
kwargs: {
|
||||
context: Object.assign({}, this.env.session.user_content, {
|
||||
isMobile: device.isMobile,
|
||||
}),
|
||||
}
|
||||
});
|
||||
const thread = this.insert(this.convertData(data));
|
||||
if (autoselect) {
|
||||
thread.open({ chatWindowMode: autoselectChatWindowMode });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a channel. This channel may not yet exists in the store.
|
||||
*
|
||||
* @static
|
||||
* @param {integer} channelId
|
||||
* @param {Object} [param1={}]
|
||||
* @param {boolean} [param1.autoselect=false]
|
||||
*/
|
||||
static async joinChannel(channelId, { autoselect = false } = {}) {
|
||||
let channel = this.find(thread =>
|
||||
thread.id === channelId &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
if (channel && channel.isPinned) {
|
||||
return;
|
||||
}
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_join_and_get_info',
|
||||
args: [[channelId]]
|
||||
});
|
||||
// We just joined the channel because of the previous rpc
|
||||
// the main assumption here is that we didn't have the channel
|
||||
// in memory. If we did though, clear the pending state and
|
||||
// let the server's data be the master
|
||||
const convertedData = Object.assign(
|
||||
{ isPendingPinned: undefined },
|
||||
this.convertData(data),
|
||||
);
|
||||
channel = this.insert(convertedData);
|
||||
if (autoselect) {
|
||||
channel.open({ resetDiscussDomain: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previews of the specified threads. Basically, it fetches the
|
||||
* last messages, since they are used to display inline content of them.
|
||||
@@ -348,6 +275,119 @@ function factory(dependencies) {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `channel_info` RPC on `mail.channel`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {integer[]} param0.ids list of id of channels
|
||||
* @returns {mail.thread[]}
|
||||
*/
|
||||
static async performRpcChannelInfo({ ids }) {
|
||||
const channelInfos = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_info',
|
||||
args: [ids],
|
||||
});
|
||||
const channels = this.env.models['mail.thread'].insert(
|
||||
channelInfos.map(channelInfo => this.env.models['mail.thread'].convertData(channelInfo))
|
||||
);
|
||||
// manually force recompute of counter
|
||||
this.env.messaging.messagingMenu.update();
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `channel_create` RPC on `mail.channel`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.name
|
||||
* @param {string} [param0.privacy]
|
||||
* @returns {mail.thread} the created channel
|
||||
*/
|
||||
static async performRpcCreateChannel({ name, privacy }) {
|
||||
const device = this.env.messaging.device;
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_create',
|
||||
args: [name, privacy],
|
||||
kwargs: {
|
||||
context: Object.assign({}, this.env.session.user_content, {
|
||||
// optimize the return value by avoiding useless queries
|
||||
// in non-mobile devices
|
||||
isMobile: device.isMobile,
|
||||
}),
|
||||
},
|
||||
});
|
||||
return this.env.models['mail.thread'].insert(
|
||||
this.env.models['mail.thread'].convertData(data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `channel_get` RPC on `mail.channel`.
|
||||
*
|
||||
* `openChat` is preferable in business code because it will avoid the
|
||||
* RPC if the chat already exists.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {integer[]} param0.partnerIds
|
||||
* @param {boolean} [param0.pinForCurrentPartner]
|
||||
* @returns {mail.thread|undefined} the created or existing chat
|
||||
*/
|
||||
static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) {
|
||||
const device = this.env.messaging.device;
|
||||
// TODO FIX: potential duplicate chat task-2276490
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_get',
|
||||
kwargs: {
|
||||
context: Object.assign({}, this.env.session.user_content, {
|
||||
// optimize the return value by avoiding useless queries
|
||||
// in non-mobile devices
|
||||
isMobile: device.isMobile,
|
||||
}),
|
||||
partners_to: partnerIds,
|
||||
pin: pinForCurrentPartner,
|
||||
},
|
||||
});
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
return this.env.models['mail.thread'].insert(
|
||||
this.env.models['mail.thread'].convertData(data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `channel_join_and_get_info` RPC on `mail.channel`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @returns {mail.thread} the channel that was joined
|
||||
*/
|
||||
static async performRpcJoinChannel({ channelId }) {
|
||||
const device = this.env.messaging.device;
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_join_and_get_info',
|
||||
args: [[channelId]],
|
||||
kwargs: {
|
||||
context: Object.assign({}, this.env.session.user_content, {
|
||||
// optimize the return value by avoiding useless queries
|
||||
// in non-mobile devices
|
||||
isMobile: device.isMobile,
|
||||
}),
|
||||
},
|
||||
});
|
||||
return this.env.models['mail.thread'].insert(
|
||||
this.env.models['mail.thread'].convertData(data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [stringifiedDomain='[]']
|
||||
* @returns {mail.thread_cache}
|
||||
@@ -453,7 +493,7 @@ function factory(dependencies) {
|
||||
*
|
||||
* Only makes sense if pendingFoldState is set to the desired value.
|
||||
*/
|
||||
async notifyFoldStateToServer() {
|
||||
notifyFoldStateToServer() {
|
||||
// method is called from _updateAfter so it cannot be async
|
||||
this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
@@ -469,75 +509,67 @@ function factory(dependencies) {
|
||||
* Notify server to leave the current channel. Useful for cross-tab
|
||||
* and cross-device chat window state synchronization.
|
||||
*
|
||||
* Only makes sense if pendingServerState is set to 'unpin'.
|
||||
* Only makes sense if isPendingPinned is set to the desired value.
|
||||
*/
|
||||
notifyUnPinToServer() {
|
||||
notifyPinStateToServer() {
|
||||
// method is called from _updateAfter so it cannot be async
|
||||
this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'execute_command',
|
||||
args: [[this.id], 'leave']
|
||||
});
|
||||
if (this.isPendingPinned) {
|
||||
this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_pin',
|
||||
kwargs: {
|
||||
uuid: this.uuid,
|
||||
pinned: true,
|
||||
},
|
||||
}, { shadow: true });
|
||||
} else {
|
||||
this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'execute_command',
|
||||
args: [[this.id], 'leave']
|
||||
}, { shadow: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open provided thread, either in discuss app or as a chat window.
|
||||
* Opens this thread either as form view, in discuss app, or as a chat
|
||||
* window. The thread will be opened in an "active" matter, which will
|
||||
* interrupt current user flow.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {string} [param0.chatWindowMode='last_visible']
|
||||
* @param {boolean} [param0.resetDiscussDomain=false]
|
||||
* @param {Object} [param0]
|
||||
* @param {boolean} [param0.expanded=false]
|
||||
*/
|
||||
open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) {
|
||||
const device = this.env.messaging.device;
|
||||
const discuss = this.env.messaging.discuss;
|
||||
const messagingMenu = this.env.messaging.messagingMenu;
|
||||
async open({ expanded = false } = {}) {
|
||||
// check if thread must be opened in form view
|
||||
if (!['mail.box', 'mail.channel'].includes(this.model)) {
|
||||
this.env.messaging.openDocument({
|
||||
return this.env.messaging.openDocument({
|
||||
id: this.id,
|
||||
model: this.model,
|
||||
});
|
||||
}
|
||||
// check if thread must be opened in discuss
|
||||
const device = this.env.messaging.device;
|
||||
const discuss = this.env.messaging.discuss;
|
||||
if (
|
||||
(!device.isMobile && discuss.isOpen) ||
|
||||
(device.isMobile && this.model === 'mail.box')
|
||||
(!device.isMobile && (discuss.isOpen || expanded)) ||
|
||||
this.model === 'mail.box'
|
||||
) {
|
||||
if (resetDiscussDomain) {
|
||||
discuss.threadView.update({ stringifiedDomain: '[]' });
|
||||
}
|
||||
discuss.threadView.update({ thread: [['link', this]] });
|
||||
} else {
|
||||
this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode });
|
||||
}
|
||||
if (!device.isMobile) {
|
||||
messagingMenu.close();
|
||||
return discuss.openThread(this);
|
||||
}
|
||||
// thread must be opened in chat window
|
||||
return this.env.messaging.chatWindowManager.openThread(this, {
|
||||
makeActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open this thread in an expanded way, that is not in a chat window.
|
||||
* Opens the most appropriate view that is a profile for this thread.
|
||||
*/
|
||||
openExpanded() {
|
||||
const discuss = this.env.messaging.discuss;
|
||||
if (['mail.channel', 'mail.box'].includes(this.model)) {
|
||||
discuss.threadView.update({ thread: [['link', this]] });
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'mail.action_discuss',
|
||||
options: {
|
||||
clear_breadcrumbs: false,
|
||||
active_id: discuss.threadToActiveId(this),
|
||||
on_reverse_breadcrumb: () => discuss.close(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: this.model,
|
||||
views: [[false, 'form']],
|
||||
res_id: this.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
async openProfile() {
|
||||
return this.env.messaging.openDocument({
|
||||
id: this.id,
|
||||
model: this.model,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,15 +790,6 @@ function factory(dependencies) {
|
||||
return [['replace', allAttachments]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.chat_window[]}
|
||||
*/
|
||||
_computeChatWindows() {
|
||||
const chatWindowThreadViews = this.threadViews.filter(threadView => !!threadView.chatWindow);
|
||||
return [['replace', chatWindowThreadViews.map(threadView => threadView.chatWindow)]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.partner}
|
||||
@@ -1088,10 +1111,10 @@ function factory(dependencies) {
|
||||
this.update({ pendingFoldState: undefined });
|
||||
}
|
||||
if (
|
||||
this.isPendingPinned === false &&
|
||||
this.isPendingPinned !== undefined &&
|
||||
previous.isPendingPinned !== this.isPendingPinned
|
||||
) {
|
||||
this.notifyUnPinToServer();
|
||||
this.notifyPinStateToServer();
|
||||
}
|
||||
if (
|
||||
this.isServerPinned === this.isPendingPinned
|
||||
@@ -1110,16 +1133,14 @@ function factory(dependencies) {
|
||||
// the process
|
||||
return;
|
||||
}
|
||||
if (this.foldState !== 'closed' && this.chatWindows.length === 0) {
|
||||
// condition to avoid crash during destroy
|
||||
if (this.env.messaging.chatWindowManager) {
|
||||
this.env.messaging.chatWindowManager.openThread(this);
|
||||
}
|
||||
if (!this.env.messaging.chatWindowManager) {
|
||||
// avoid crash during destroy
|
||||
return;
|
||||
}
|
||||
if (this.foldState === 'closed' && this.chatWindows.length > 0) {
|
||||
for (const chatWindow of this.chatWindows) {
|
||||
chatWindow.close();
|
||||
}
|
||||
if (this.foldState !== 'closed') {
|
||||
this.env.messaging.chatWindowManager.openThread(this);
|
||||
} else {
|
||||
this.env.messaging.chatWindowManager.closeThread(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,18 +1213,8 @@ function factory(dependencies) {
|
||||
isCausal: true,
|
||||
}),
|
||||
channel_type: attr(),
|
||||
chatWindows: one2many('mail.chat_window', {
|
||||
compute: '_computeChatWindows',
|
||||
dependencies: ['threadViewsChatWindow'],
|
||||
}),
|
||||
composer: one2one('mail.composer', {
|
||||
/**
|
||||
* FIXME With this, whenever client is aware of new thread, this will
|
||||
* (almost) always focus its composer when displayed. This shouldn't be
|
||||
* the case, instead auto-focus of composer is flow-specific.
|
||||
* See task-2277537
|
||||
*/
|
||||
default: [['create', { isDoFocus: true }]],
|
||||
default: [['create']],
|
||||
inverse: 'thread',
|
||||
isCausal: true,
|
||||
}),
|
||||
@@ -1475,9 +1486,6 @@ function factory(dependencies) {
|
||||
threadViews: one2many('mail.thread_view', {
|
||||
inverse: 'thread',
|
||||
}),
|
||||
threadViewsChatWindow: many2many('mail.chat_window', {
|
||||
related: 'threadViews.chatWindow',
|
||||
}),
|
||||
};
|
||||
|
||||
Thread.modelName = 'mail.thread';
|
||||
|
||||
@@ -24,6 +24,66 @@ function factory(dependencies) {
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
static convertData(data) {
|
||||
const data2 = {};
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('partner_id' in data) {
|
||||
if (!data.partner_id) {
|
||||
data2.partner = [['unlink']];
|
||||
} else {
|
||||
const partnerNameGet = data['partner_id'];
|
||||
const partnerData = {
|
||||
display_name: partnerNameGet[1],
|
||||
id: partnerNameGet[0],
|
||||
};
|
||||
data2.partner = [['insert', partnerData]];
|
||||
}
|
||||
}
|
||||
return data2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `read` RPC on `res.users`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {Object} param0.context
|
||||
* @param {string[]} param0.fields
|
||||
* @param {integer[]} param0.ids
|
||||
*/
|
||||
static async performRpcRead({ context, fields, ids }) {
|
||||
const usersData = await this.env.services.rpc({
|
||||
model: 'res.users',
|
||||
method: 'read',
|
||||
args: [ids],
|
||||
kwargs: {
|
||||
context,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
return this.env.models['mail.user'].insert(usersData.map(userData =>
|
||||
this.env.models['mail.user'].convertData(userData)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the partner of this user.
|
||||
*/
|
||||
async fetchPartner() {
|
||||
return this.env.models['mail.user'].performRpcRead({
|
||||
ids: [this.id],
|
||||
fields: ['partner_id'],
|
||||
context: { active_test: false },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -35,6 +95,94 @@ function factory(dependencies) {
|
||||
return partner.nameOrDisplayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chat between this user and the current user.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async getChat() {
|
||||
if (!this.partner) {
|
||||
await this.async(() => this.fetchPartner());
|
||||
}
|
||||
if (!this.partner) {
|
||||
// This user has been deleted from the server or never existed:
|
||||
// - Validity of id is not verified at insert.
|
||||
// - There is no bus notification in case of user delete from
|
||||
// another tab or by another user.
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("You can only chat with existing users."),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// in other cases a chat would be valid, find it or try to create it
|
||||
let chat = this.env.models['mail.thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent === this.partner &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
if (!chat) {
|
||||
chat = await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcCreateChat({
|
||||
partnerIds: [this.partner.id],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!chat) {
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("An unexpected error occurred during the creation of the chat."),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
return chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a chat between this user and the current user and returns it.
|
||||
*
|
||||
* If a chat is not appropriate, a notification is displayed instead.
|
||||
*
|
||||
* @param {Object} [options] forwarded to @see `mail.thread:open()`
|
||||
* @returns {mail.thread|undefined}
|
||||
*/
|
||||
async openChat(options) {
|
||||
const chat = await this.async(() => this.getChat());
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
await this.async(() => chat.open(options));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the most appropriate view that is a profile for this user.
|
||||
* Because user is a rather technical model to allow login, it's the
|
||||
* partner profile that contains the most useful information.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async openProfile() {
|
||||
if (!this.partner) {
|
||||
await this.async(() => this.fetchPartner());
|
||||
}
|
||||
if (!this.partner) {
|
||||
// This user has been deleted from the server or never existed:
|
||||
// - Validity of id is not verified at insert.
|
||||
// - There is no bus notification in case of user delete from
|
||||
// another tab or by another user.
|
||||
this.env.services['notification'].notify({
|
||||
message: this.env._t("You can only open the profile of existing users."),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
return this.partner.openProfile();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ QUnit.module('notification_alert_tests.js', {
|
||||
QUnit.skip('notification_alert widget: display blocked notification alert', async function (assert) {
|
||||
// FIXME: Test should work, but for some reasons OWL always flags the
|
||||
// component as not mounted, even though it is in the DOM and it's state
|
||||
// is good for rendering...
|
||||
// is good for rendering... task-227947
|
||||
assert.expect(1);
|
||||
|
||||
await this.start({
|
||||
|
||||
@@ -3,7 +3,6 @@ odoo.define('mail.Many2OneAvatarUserTests', function (require) {
|
||||
|
||||
const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const ListView = require('web.ListView');
|
||||
const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser');
|
||||
@@ -91,45 +90,6 @@ QUnit.module('mail', {}, function () {
|
||||
list.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_user widget: click on self', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const { widget: form } = await start({
|
||||
hasView: true,
|
||||
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 === 'notification') {
|
||||
assert.step(`display notification "${ev.data.args[0].message}"`);
|
||||
}
|
||||
}, 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 "You cannot chat with yourself"',
|
||||
]);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ registerInstancePatchModel('mail.messaging_initializer', 'mail_bot/static/src/mo
|
||||
* @private
|
||||
*/
|
||||
async _initializeOdooBot() {
|
||||
await this.async(() => new Promise(resolve =>
|
||||
this.env.browser.setTimeout(resolve, 2 * 60 * 1000)
|
||||
));
|
||||
const data = await this.async(() => this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'init_odoobot',
|
||||
|
||||
+3
-11
@@ -24,10 +24,10 @@ QUnit.module('messaging_initializer_tests.js', {
|
||||
});
|
||||
|
||||
|
||||
QUnit.test('OdooBot initialized after 2 minutes', async function (assert) {
|
||||
QUnit.test('OdooBot initialized at init', async function (assert) {
|
||||
// TODO this test should be completed in combination with
|
||||
// implementing _mockMailChannelInitOdooBot task-2300480
|
||||
assert.expect(3);
|
||||
assert.expect(2);
|
||||
|
||||
await this.start({
|
||||
env: {
|
||||
@@ -35,7 +35,6 @@ QUnit.test('OdooBot initialized after 2 minutes', async function (assert) {
|
||||
odoobot_initialized: false,
|
||||
},
|
||||
},
|
||||
hasTimeControl: true,
|
||||
async mockRPC(route, args) {
|
||||
if (args.method === 'init_odoobot') {
|
||||
assert.step('init_odoobot');
|
||||
@@ -44,16 +43,9 @@ QUnit.test('OdooBot initialized after 2 minutes', async function (assert) {
|
||||
},
|
||||
});
|
||||
|
||||
await this.env.testUtils.advanceTime(119 * 1000);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
"should not have initialized OdooBot after 1 minute 59 seconds"
|
||||
);
|
||||
|
||||
await this.env.testUtils.advanceTime(1 * 1000);
|
||||
assert.verifySteps(
|
||||
['init_odoobot'],
|
||||
"should have initialized OdooBot after 2 minutes"
|
||||
"should have initialized OdooBot at init"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,18 +15,18 @@ registerInstancePatchModel('mail.notification_group', 'sms/static/src/models/not
|
||||
* @override
|
||||
*/
|
||||
openCancelAction() {
|
||||
if (this.notification_type === 'sms') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'sms.sms_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (this.notification_type !== 'sms') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'sms.sms_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -37,20 +37,25 @@ registerInstancePatchModel('mail.notification_group', 'sms/static/src/models/not
|
||||
* @override
|
||||
*/
|
||||
_openDocuments() {
|
||||
if (this.notification_type === 'sms') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("SMS Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_has_sms_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.notification_type !== 'sms') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("SMS Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_has_sms_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
// messaging menu has a higher z-index than views so it must
|
||||
// be closed to ensure the visibility of the view
|
||||
this.messagingMenu.close();
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,18 +15,18 @@ registerInstancePatchModel('mail.notification_group', 'snailmail/static/src/mode
|
||||
* @override
|
||||
*/
|
||||
openCancelAction() {
|
||||
if (this.notification_type === 'snail') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'snailmail.snailmail_letter_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (this.notification_type !== 'snail') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: 'snailmail.snailmail_letter_cancel_action',
|
||||
options: {
|
||||
additional_context: {
|
||||
default_model: this.res_model,
|
||||
unread_counter: this.notifications.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -37,20 +37,25 @@ registerInstancePatchModel('mail.notification_group', 'snailmail/static/src/mode
|
||||
* @override
|
||||
*/
|
||||
_openDocuments() {
|
||||
if (this.notification_type === 'snail') {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("Snailmail Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_ids.snailmail_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.notification_type !== 'snail') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
this.env.bus.trigger('do-action', {
|
||||
action: {
|
||||
name: this.env._t("Snailmail Failures"),
|
||||
type: 'ir.actions.act_window',
|
||||
view_mode: 'kanban,list,form',
|
||||
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
|
||||
target: 'current',
|
||||
res_model: this.res_model,
|
||||
domain: [['message_ids.snailmail_error', '=', true]],
|
||||
},
|
||||
});
|
||||
if (this.env.messaging.device.isMobile) {
|
||||
// messaging menu has a higher z-index than views so it must
|
||||
// be closed to ensure the visibility of the view
|
||||
this.env.messaging.messagingMenu.close();
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user