[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter, chat window, and messaging menu using OWL. The behavior should be broadly the same, with some slight functional changes here and there. From a technical standpoint, the code of messaging is mainly organized in 2 main groups of modules: - models, which are logical entities that depict the client-side state of messaging as a whole. - components, which are in charge of displaying information from models. This refactoring also introduces new JS guidelines regarding folder structure (/static) and naming rules for JS modules. Community PR: https://github.com/odoo/odoo/pull/39023 Enterprise PR: https://github.com/odoo/enterprise/pull/6249 Task-1914207 This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier, with the precious help of Lucas to speed it up towards the end. closes odoo/odoo#39023 Related: odoo/enterprise#6249 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com> Co-authored-by: Alexandre Kühn <aku@odoo.com> Co-authored-by: Julien Giannone <jgi@odoo.com> Co-authored-by: Lucas Perais <lpe@odoo.com> Co-authored-by: Sébastien Theys <seb@odoo.com> Co-authored-by: Xavier Dubuc <xdu@odoo.com>
This commit is contained in:
co-authored by
Alexandre Kühn
Julien Giannone
Lucas Perais
Xavier Dubuc
parent
0a82848606
commit
3fea5b2136
@@ -231,8 +231,8 @@
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -122,9 +122,9 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -1049,9 +1049,9 @@
|
||||
('state', '!=', 'draft')]}" />
|
||||
<!-- Chatter -->
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -247,9 +247,9 @@
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -182,8 +182,8 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
odoo.define('calendar.systray.ActivityMenuTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { start } = require('mail/static/src/utils/test_utils.js');
|
||||
var ActivityMenu = require('mail.systray.ActivityMenu');
|
||||
var mailUtils = require('mail.testUtils');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
@@ -11,7 +11,6 @@ QUnit.module('calendar', {}, function () {
|
||||
|
||||
QUnit.module('ActivityMenu', {
|
||||
beforeEach: function () {
|
||||
this.services = mailUtils.getMailServices();
|
||||
this.data = {
|
||||
'calendar.event': {
|
||||
records: [{
|
||||
@@ -34,15 +33,14 @@ QUnit.module('ActivityMenu', {
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('activity menu widget:today meetings', async function (assert) {
|
||||
assert.expect(6);
|
||||
var self = this;
|
||||
var activityMenu = new ActivityMenu();
|
||||
await testUtils.mock.addMockEnvironment(activityMenu, {
|
||||
services: this.services,
|
||||
|
||||
const { widget } = await start({
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'systray_get_activities') {
|
||||
return Promise.resolve(self.data['calendar.event']['records']);
|
||||
@@ -51,6 +49,7 @@ QUnit.test('activity menu widget:today meetings', async function (assert) {
|
||||
},
|
||||
});
|
||||
|
||||
const activityMenu = new ActivityMenu(widget);
|
||||
await activityMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
assert.hasClass(activityMenu.$el,'o_mail_systray_item', 'should be the instance of widget');
|
||||
@@ -66,7 +65,7 @@ QUnit.test('activity menu widget:today meetings', async function (assert) {
|
||||
assert.containsN(activityMenu, '.o_meeting_filter', 2, 'there should be 2 meetings');
|
||||
assert.hasClass(activityMenu.$('.o_meeting_filter').eq(0), 'o_meeting_bold', 'this meeting is yet to start');
|
||||
assert.doesNotHaveClass(activityMenu.$('.o_meeting_filter').eq(1), 'o_meeting_bold', 'this meeting has been started');
|
||||
activityMenu.destroy();
|
||||
widget.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,8 +239,8 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="message_ids" widget="mail_thread" />
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids" />
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -286,9 +286,9 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options="{'post_refresh': 'recipients'}"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids" options="{'post_refresh': 'recipients'}"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -271,9 +271,9 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
@@ -562,9 +562,9 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options="{'post_refresh': 'recipients'}"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids" options="{'post_refresh': 'recipients'}"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -54,9 +54,9 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options='{"thread_level": 1}'/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
@@ -228,9 +228,9 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options='{"thread_level": 1}'/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options='{"thread_level": 1}'/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -130,8 +130,8 @@
|
||||
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -11,6 +11,8 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
var KanbanRenderer = require('web.KanbanRenderer');
|
||||
var KanbanRecord = require('web.KanbanRecord');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
// CHAT MIXIN
|
||||
var ChatMixin = {
|
||||
/**
|
||||
@@ -22,8 +24,7 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
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 {
|
||||
} else {
|
||||
$chat_button.off('click').on('click', self._onOpenChat.bind(self));
|
||||
}
|
||||
});
|
||||
@@ -34,7 +35,7 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
return this._super();
|
||||
},
|
||||
|
||||
_onOpenChat: function(ev) {
|
||||
_onOpenChat: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
this.trigger_up('open_chat', {
|
||||
@@ -53,7 +54,11 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
}),
|
||||
|
||||
_onOpenChat: function (ev) {
|
||||
this.call('mail_service', 'openDMChatWindow', ev.data.partner_id);
|
||||
const env = Component.env;
|
||||
const partner = env.models['mail.partner'].insert({
|
||||
id: ev.data.partner_id,
|
||||
});
|
||||
partner.openChat();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +86,11 @@ odoo.define('hr.employee_chat', function (require) {
|
||||
}),
|
||||
|
||||
_onOpenChat: function (ev) {
|
||||
this.call('mail_service', 'openDMChatWindow', ev.data.partner_id);
|
||||
const env = Component.env;
|
||||
const partner = env.models['mail.partner'].insert({
|
||||
id: ev.data.partner_id,
|
||||
});
|
||||
partner.openChat();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
odoo.define('hr.Many2OneAvatarEmployeeTests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { start } = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
const FormView = require('web.FormView');
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const ListView = require('web.ListView');
|
||||
@@ -65,9 +67,10 @@ QUnit.module('hr', {}, function () {
|
||||
});
|
||||
|
||||
QUnit.test('many2one_avatar_employee widget in list view', async function (assert) {
|
||||
assert.expect(7);
|
||||
assert.expect(4);
|
||||
|
||||
const list = await createView({
|
||||
const { widget: list } = await start({
|
||||
hasView: true,
|
||||
View: ListView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
@@ -80,12 +83,6 @@ QUnit.module('hr', {}, function () {
|
||||
},
|
||||
});
|
||||
|
||||
mock.intercept(list, 'call_service', ev => {
|
||||
if (ev.data.service === 'mail_service') {
|
||||
assert.step(`call service ${ev.data.method} ${ev.data.args[0]}`);
|
||||
}
|
||||
}, true);
|
||||
|
||||
assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi');
|
||||
|
||||
await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar'));
|
||||
@@ -95,10 +92,10 @@ QUnit.module('hr', {}, function () {
|
||||
|
||||
assert.verifySteps([
|
||||
'read hr.employee 11',
|
||||
'call service openDMChatWindow 1',
|
||||
// 'call service openDMChatWindow 1',
|
||||
'read hr.employee 7',
|
||||
'call service openDMChatWindow 2',
|
||||
'call service openDMChatWindow 1',
|
||||
// 'call service openDMChatWindow 2',
|
||||
// 'call service openDMChatWindow 1',
|
||||
]);
|
||||
|
||||
list.destroy();
|
||||
@@ -107,7 +104,8 @@ QUnit.module('hr', {}, function () {
|
||||
QUnit.test('many2one_avatar_employee widget in kanban view', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const kanban = await createView({
|
||||
const { widget: kanban } = await start({
|
||||
hasView: true,
|
||||
View: KanbanView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
@@ -137,7 +135,8 @@ QUnit.module('hr', {}, function () {
|
||||
assert.expect(5);
|
||||
|
||||
this.data['hr.employee'].records[0].user_partner_id = false;
|
||||
const form = await createView({
|
||||
const { widget: form } = await start({
|
||||
hasView: true,
|
||||
View: FormView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
@@ -152,9 +151,6 @@ QUnit.module('hr', {}, function () {
|
||||
});
|
||||
|
||||
mock.intercept(form, 'call_service', (ev) => {
|
||||
if (ev.data.service === 'mail_service') {
|
||||
throw new Error('should not call mail_service');
|
||||
}
|
||||
if (ev.data.service === 'notification') {
|
||||
assert.step(`display notification "${ev.data.args[0].message}"`);
|
||||
}
|
||||
@@ -176,7 +172,8 @@ QUnit.module('hr', {}, function () {
|
||||
QUnit.test('many2one_avatar_employee: click on self', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const form = await createView({
|
||||
const { widget: form } = await start({
|
||||
hasView: true,
|
||||
View: FormView,
|
||||
model: 'foo',
|
||||
data: this.data,
|
||||
@@ -194,9 +191,6 @@ QUnit.module('hr', {}, function () {
|
||||
});
|
||||
|
||||
mock.intercept(form, 'call_service', (ev) => {
|
||||
if (ev.data.service === 'mail_service') {
|
||||
throw new Error('should not call mail_service');
|
||||
}
|
||||
if (ev.data.service === 'notification') {
|
||||
assert.step(`display notification "${ev.data.args[0].message}"`);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -189,9 +189,9 @@
|
||||
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="message_follower_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -214,9 +214,9 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -132,9 +132,9 @@
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
@@ -559,9 +559,9 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -55,6 +55,10 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p
|
||||
'data/hr_holidays_demo.xml',
|
||||
],
|
||||
'qweb': [
|
||||
'static/src/bugfix/bugfix.xml',
|
||||
'static/src/components/partner_im_status_icon/partner_im_status_icon.xml',
|
||||
'static/src/components/thread_icon/thread_icon.xml',
|
||||
'static/src/components/thread_viewer/thread_viewer.xml',
|
||||
'static/src/xml/*.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@@ -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_holidays/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_holidays/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_holidays', {}, function () {
|
||||
QUnit.module('bugfix', {}, function () {
|
||||
QUnit.module('bugfix_tests.js', {
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Style
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.o_PartnerImStatusIcon_icon {
|
||||
&.o-leave-online {
|
||||
color: $o-enterprise-primary-color;
|
||||
}
|
||||
&.o-leave-offline {
|
||||
color: theme-color('warning');
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.PartnerImStatusIcon" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='rootCondition']" position="inside">
|
||||
<t t-if="partner.im_status === 'leave_online'">
|
||||
<i class="o_PartnerImStatusIcon_icon o-leave-online fa fa-plane fa-stack-1x" title="Online" role="img" aria-label="User is online"/>
|
||||
</t>
|
||||
<t t-if="partner.im_status === 'leave_offline'">
|
||||
<i class="o_PartnerImStatusIcon_icon o-leave-offline fa fa-plane fa-stack-1x" title="Out of office" role="img" aria-label="User is out of office"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,11 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ThreadIcon_leaveOnline {
|
||||
color: $o-enterprise-primary-color;
|
||||
}
|
||||
|
||||
.o_ThreadIcon_leaveOffline {
|
||||
color: theme-color('warning');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='noImStatusCondition']" position="before">
|
||||
<t t-elif="thread.correspondent.im_status === 'leave_online'">
|
||||
<div class="o_ThreadIcon_leaveOnline fa fa-plane" title="Online"/>
|
||||
</t>
|
||||
<t t-elif="thread.correspondent.im_status === 'leave_offline'">
|
||||
<div class="o_ThreadIcon_leaveOffline fa fa-plane" title="Out of office"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,58 @@
|
||||
odoo.define('hr_holidays/static/src/components/thread_viewer/thread_viewer.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
|
||||
};
|
||||
|
||||
const { str_to_datetime } = require('web.time');
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.ThreadViewer, 'hr_holidays/static/src/components/thread_viewer/thread_viewer.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the "out of office" text for the correspondent of the thread if
|
||||
* applicable.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getOutOfOfficeText() {
|
||||
if (!this.threadViewer.thread.correspondent) {
|
||||
return "";
|
||||
}
|
||||
if (!this.threadViewer.thread.correspondent.out_of_office_date_end) {
|
||||
return "";
|
||||
}
|
||||
const currentDate = new Date();
|
||||
const date = str_to_datetime(this.threadViewer.thread.correspondent.out_of_office_date_end);
|
||||
const options = { day: 'numeric', month: 'short' };
|
||||
if (currentDate.getFullYear() !== date.getFullYear()) {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
const formattedDate = date.toLocaleDateString(window.navigator.language, options);
|
||||
return _.str.sprintf(this.env._t("Out of office until %s."), formattedDate);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_useStoreSelector(props) {
|
||||
const res = this._super(...arguments);
|
||||
const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
|
||||
const thread = threadViewer ? threadViewer.thread : undefined;
|
||||
const correspondent = thread ? thread.correspondent : undefined;
|
||||
return Object.assign({}, res, {
|
||||
correspondent: correspondent ? correspondent.__state : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Layout
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.o_ThreadViewer_outOfOffice {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ThreadViewer" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='loadingCondition']" position="before">
|
||||
<t t-if="threadViewer.thread.correspondent and threadViewer.thread.correspondent.out_of_office_date_end">
|
||||
<div class="o_ThreadViewer_outOfOffice alert alert-primary" t-esc="getOutOfOfficeText()" role="alert"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-extend="mail.UserStatus">
|
||||
<t t-jquery="i:last" t-operation="after">
|
||||
<i t-if="status == 'leave_online'" class="o_mail_user_status o_user_online fa fa-plane" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'leave_offline'" class="o_mail_user_status o_user_idle fa fa-plane" title="Out of office" role="img" aria-label="User is out of office"/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -112,9 +112,9 @@
|
||||
<field name="notes" nolabel="1" placeholder="Add a reason..."/>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/hr_holidays/static/src/js/time_off_calendar.js"></script>
|
||||
<script type="text/javascript" src="/hr_holidays/static/src/js/leave_stats_widget.js"></script>
|
||||
<script type="text/javascript" src="/hr_holidays/static/src/bugfix/bugfix.js"/>
|
||||
<script type="text/javascript" src="/hr_holidays/static/src/components/thread_viewer/thread_viewer.js"/>
|
||||
</xpath>
|
||||
<xpath expr="link[last()]" position="after">
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/scss/hr_leave_mobile.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/src/scss/time_off.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/src/bugfix/bugfix.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/src/components/partner_im_status_icon/partner_im_status_icon.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/src/components/thread_icon/thread_icon.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/hr_holidays/static/src/components/thread_viewer/thread_viewer.scss"/>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="qunit_suite" name="hr_holidays_qunit_suite" inherit_id="web.qunit_suite_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/hr_holidays/static/src/bugfix/bugfix_tests.js"></script>
|
||||
<script type="text/javascript" src="/hr_holidays/static/tests/test_leave_stats_widget.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -333,9 +333,9 @@
|
||||
</div>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -150,9 +150,9 @@
|
||||
<field name="description" placeholder="Feedback of interviews..."/>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread" options="{'post_refresh': 'recipients', 'open_attachments': True}"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids" options="{'post_refresh': 'recipients', 'open_attachments': True}"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -39,7 +39,11 @@ Help your customers with this chat, and analyse their feedback.
|
||||
'data/mail_shortcode_demo.xml',
|
||||
],
|
||||
'depends': ["mail", "rating", "digest"],
|
||||
'qweb': ['static/src/xml/*.xml'],
|
||||
'qweb': [
|
||||
'static/src/bugfix/bugfix.xml',
|
||||
'static/src/components/discuss_sidebar/discuss_sidebar.xml',
|
||||
'static/src/components/thread_icon/thread_icon.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
|
||||
@@ -33,10 +33,7 @@ class LivechatController(http.Controller):
|
||||
def load_templates(self, **kwargs):
|
||||
base_url = request.httprequest.base_url
|
||||
templates = [
|
||||
'mail/static/src/xml/abstract_thread_window.xml',
|
||||
'mail/static/src/xml/discuss.xml',
|
||||
'mail/static/src/xml/thread.xml',
|
||||
'im_livechat/static/src/xml/im_livechat.xml',
|
||||
'im_livechat/static/src/legacy/public_livechat.xml',
|
||||
]
|
||||
return [tools.file_open(tmpl, 'rb').read() for tmpl in templates]
|
||||
|
||||
@@ -149,7 +146,7 @@ class LivechatController(http.Controller):
|
||||
"""
|
||||
Channel = request.env['mail.channel']
|
||||
channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1)
|
||||
channel.notify_typing(is_typing=is_typing, is_website_user=True)
|
||||
channel.notify_typing(is_typing=is_typing)
|
||||
|
||||
@http.route('/im_livechat/email_livechat_transcript', type='json', auth='public', cors="*")
|
||||
def email_livechat_transcript(self, uuid, email):
|
||||
|
||||
@@ -86,7 +86,7 @@ class MailChannel(models.Model):
|
||||
res = channel.livechat_operator_id.with_context(im_livechat_use_username=True).name_get()[0]
|
||||
channel_infos_dict[channel.id]['operator_pid'] = (res[0], res[1].replace(',', ''))
|
||||
# add the anonymous or partner name
|
||||
channel_infos_dict[channel.id]['correspondent_name'] = channel._channel_get_livechat_partner_name()
|
||||
channel_infos_dict[channel.id]['livechat_visitor'] = channel._channel_get_livechat_visitor_info()
|
||||
last_msg = self.env['mail.message'].search([("channel_ids", "in", [channel.id])], limit=1)
|
||||
if last_msg:
|
||||
channel_infos_dict[channel.id]['last_message_date'] = last_msg.date
|
||||
@@ -99,6 +99,26 @@ class MailChannel(models.Model):
|
||||
values['channel_livechat'] = self.search([('channel_type', '=', 'livechat'), ('id', 'in', pinned_channels.ids)]).channel_info()
|
||||
return values
|
||||
|
||||
def _channel_get_livechat_visitor_info(self):
|
||||
partners = self.channel_partner_ids - self.livechat_operator_id
|
||||
if partners:
|
||||
return {
|
||||
'country': partners[0].country_id.name_get() if partners[0].country_id else False,
|
||||
'id': partners[0].id,
|
||||
'name': partners[0].name,
|
||||
}
|
||||
if self.anonymous_name:
|
||||
return {
|
||||
'country': False,
|
||||
'id': False,
|
||||
'name': self.anonymous_name,
|
||||
}
|
||||
return {
|
||||
'country': False,
|
||||
'id': False,
|
||||
'name': _("Visitor"),
|
||||
}
|
||||
|
||||
def _channel_get_livechat_partner_name(self):
|
||||
if self.livechat_operator_id in self.channel_partner_ids:
|
||||
partners = self.channel_partner_ids - self.livechat_operator_id
|
||||
|
||||
@@ -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('im_livechat/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('im_livechat/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('im_livechat', {}, function () {
|
||||
QUnit.module('bugfix', {}, function () {
|
||||
QUnit.module('bugfix_tests.js', {
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
odoo.define('im_livechat/static/src/components/discuss/discuss.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
Discuss: require('mail/static/src/components/discuss/discuss.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.Discuss, 'im_livechat/static/src/components/discuss/discuss.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
mobileNavbarTabs(...args) {
|
||||
return [...this._super(...args), {
|
||||
icon: 'fa fa-comments',
|
||||
id: 'livechat',
|
||||
label: this.env._t("Livechat"),
|
||||
}];
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
odoo.define('im_livechat/static/src/components/discuss/discuss_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
afterEach: utilsAfterEach,
|
||||
beforeEach: utilsBeforeEach,
|
||||
start: utilsStart,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('discuss', {}, function () {
|
||||
QUnit.module('discuss_tests.js', {
|
||||
beforeEach() {
|
||||
utilsBeforeEach(this);
|
||||
|
||||
this.start = async params => {
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
let { env, widget } = await utilsStart(Object.assign({}, params, {
|
||||
autoOpenDiscuss: true,
|
||||
data: this.data,
|
||||
hasDiscuss: true,
|
||||
}));
|
||||
this.env = env;
|
||||
this.widget = widget;
|
||||
};
|
||||
},
|
||||
afterEach() {
|
||||
utilsAfterEach(this);
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('livechat in the sidebar', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_livechat: [{
|
||||
channel_type: "livechat",
|
||||
id: 1,
|
||||
livechat_visitor: {
|
||||
country: false,
|
||||
id: false,
|
||||
name: "Visitor",
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
await this.start();
|
||||
|
||||
assert.containsOnce(document.body, '.o_Discuss_sidebar',
|
||||
"should have a sidebar section"
|
||||
);
|
||||
const groupLivechat = document.querySelector('.o_DiscussSidebar_groupLivechat');
|
||||
assert.ok(groupLivechat,
|
||||
"should have a channel group livechat"
|
||||
);
|
||||
const grouptitle = groupLivechat.querySelector('.o_DiscussSidebar_groupTitle');
|
||||
assert.strictEqual(
|
||||
grouptitle.textContent.trim(),
|
||||
"Livechat",
|
||||
"should have a channel group named 'Livechat'"
|
||||
);
|
||||
const livechat = groupLivechat.querySelector(`
|
||||
.o_DiscussSidebarItem[data-thread-local-id="${
|
||||
this.env.models['mail.thread'].find(thread =>
|
||||
thread.id === 1 &&
|
||||
thread.model === 'mail.channel'
|
||||
).localId
|
||||
}"]
|
||||
`);
|
||||
assert.ok(
|
||||
livechat,
|
||||
"should have a livechat in sidebar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
livechat.textContent,
|
||||
"Visitor",
|
||||
"should have 'Visitor' as livechat name"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
odoo.define('im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the list of livechats that match the quick search value input.
|
||||
*
|
||||
* @returns {mail.thread[]}
|
||||
*/
|
||||
quickSearchOrderedAndPinnedLivechatList() {
|
||||
const allOrderedAndPinnedLivechats = this.env.models['mail.thread'].all(thread =>
|
||||
thread.channel_type === 'livechat' &&
|
||||
thread.isPinned &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
if (!this.discuss.sidebarQuickSearchValue) {
|
||||
return allOrderedAndPinnedLivechats;
|
||||
}
|
||||
const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
|
||||
return allOrderedAndPinnedLivechats.filter(livechat => {
|
||||
const nameVal = livechat.displayName.toLowerCase();
|
||||
return nameVal.includes(qsVal);
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_useStoreCompareDepth() {
|
||||
return Object.assign(this._super(...arguments), {
|
||||
allOrderedAndPinnedLivechats: 1,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Override to include livechat channels on the sidebar.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_useStoreSelector(props) {
|
||||
return Object.assign(this._super(...arguments), {
|
||||
allOrderedAndPinnedLivechats: this.env.models['mail.thread']
|
||||
.all(thread =>
|
||||
thread.channel_type === 'livechat' &&
|
||||
thread.isPinned &&
|
||||
thread.model === 'mail.channel'
|
||||
)
|
||||
.sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
|
||||
.map(livechat => livechat.__state),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussSidebar" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='root']" position="inside">
|
||||
<t t-set="livechats" t-value="quickSearchOrderedAndPinnedLivechatList()"/>
|
||||
<t t-if="livechats and livechats.length">
|
||||
<div class="o_DiscussSidebar_group o_DiscussSidebar_groupLivechat">
|
||||
<div class="o_DiscussSidebar_groupHeader">
|
||||
<div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle">
|
||||
Livechat
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_DiscussSidebar_list">
|
||||
<t t-foreach="livechats" t-as="livechat" t-key="livechat.localId">
|
||||
<DiscussSidebarItem
|
||||
class="o_DiscussSidebar_item"
|
||||
threadLocalId="livechat.localId"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,26 @@
|
||||
odoo.define('im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.DiscussSidebarItem, 'im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
hasUnpin(...args) {
|
||||
const res = this._super(...args);
|
||||
return res || this.thread.channel_type === 'livechat';
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
odoo.define('im_livechat/static/src/components/notification_list/notification_list.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
components.NotificationList._allowedFilters.push('livechat');
|
||||
|
||||
patch(components.NotificationList, 'im_livechat/static/src/components/notification_list/notification_list.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Override to include livechat channels.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_useStoreSelectorThreads(props) {
|
||||
if (props.filter === 'livechat') {
|
||||
return this.env.models['mail.thread'].all(thread =>
|
||||
thread.channel_type === 'livechat' &&
|
||||
thread.isPinned &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='rootCondition']" position="inside">
|
||||
<t t-elif="thread.channel_type === 'livechat'">
|
||||
<t t-if="thread.orderedOtherTypingMembers.length > 0">
|
||||
<ThreadTypingIcon
|
||||
class="o_ThreadIcon_typing"
|
||||
animation="'pulse'"
|
||||
title="thread.typingStatusText"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fa fa-comments" title="Livechat"/>
|
||||
</t>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,128 @@
|
||||
odoo.define('im_livechat/static/src/components/thread_icon/thread_icon_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
|
||||
};
|
||||
const {
|
||||
afterEach: utilsAfterEach,
|
||||
afterNextRender,
|
||||
beforeEach: utilsBeforeEach,
|
||||
start: utilsStart,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('thread_icon', {}, function () {
|
||||
QUnit.module('thread_icon_tests.js', {
|
||||
beforeEach() {
|
||||
utilsBeforeEach(this);
|
||||
|
||||
this.createThreadIcon = async thread => {
|
||||
const ThreadIconComponent = components.ThreadIcon;
|
||||
ThreadIconComponent.env = this.env;
|
||||
this.component = new ThreadIconComponent(null, { threadLocalId: thread.localId });
|
||||
await this.component.mount(this.widget.el);
|
||||
};
|
||||
|
||||
this.start = async params => {
|
||||
let { env, widget } = await utilsStart(Object.assign({}, params, {
|
||||
data: this.data,
|
||||
}));
|
||||
this.env = env;
|
||||
this.widget = widget;
|
||||
};
|
||||
|
||||
},
|
||||
afterEach() {
|
||||
utilsAfterEach(this);
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
this.env = undefined;
|
||||
delete components.ThreadIcon.env;
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('livechat: public website visitor is typing', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
Object.assign(this.data.initMessaging, {
|
||||
channel_slots: {
|
||||
channel_livechat: [{
|
||||
channel_type: 'livechat',
|
||||
id: 20,
|
||||
livechat_visitor: {
|
||||
country: false,
|
||||
id: false,
|
||||
name: "Visitor",
|
||||
},
|
||||
members: [{
|
||||
email: 'admin@odoo.com',
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
public_partner: {
|
||||
active: false,
|
||||
display_name: "Public Partner",
|
||||
id: 7,
|
||||
},
|
||||
});
|
||||
await this.start({
|
||||
env: {
|
||||
session: {
|
||||
name: 'Admin',
|
||||
partner_display_name: 'Your Company, Admin',
|
||||
partner_id: 3,
|
||||
uid: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
const thread = this.env.models['mail.thread'].find(thread =>
|
||||
thread.id === 20 &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
await this.createThreadIcon(thread);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadIcon',
|
||||
"should have thread icon"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadIcon .fa.fa-comments',
|
||||
"should have default livechat icon"
|
||||
);
|
||||
|
||||
// simulate receive typing notification from website visitor "is typing"
|
||||
await afterNextRender(() => {
|
||||
const typingData = {
|
||||
info: 'typing_status',
|
||||
partner_id: 7, // public partner_id
|
||||
is_typing: true,
|
||||
};
|
||||
const notification = [[false, 'mail.channel', 20], typingData];
|
||||
this.widget.call('bus_service', 'trigger', 'notification', [notification]);
|
||||
});
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadIcon_typing',
|
||||
"should have thread icon with visitor currently typing"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadIcon_typing').title,
|
||||
"Visitor is typing...",
|
||||
"title of icon should tell visitor is currently typing"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
odoo.define('im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.ThreadNeedactionPreview, 'thread_needaction_preview', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
image(...args) {
|
||||
if (this.thread.channel_type === 'livechat') {
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return this._super(...args);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
odoo.define('im_livechat/static/src/components/thread_preview/thread_preview.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'),
|
||||
};
|
||||
|
||||
const { patch } = require('web.utils');
|
||||
|
||||
patch(components.ThreadPreview, 'im_livechat/static/src/components/thread_preview/thread_preview.js', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
image(...args) {
|
||||
if (this.thread.channel_type === 'livechat') {
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return this._super(...args);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
odoo.define('im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
|
||||
};
|
||||
const {
|
||||
afterEach: utilsAfterEach,
|
||||
afterNextRender,
|
||||
beforeEach: utilsBeforeEach,
|
||||
start: utilsStart,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('thread_textual_typing_status', {}, function () {
|
||||
QUnit.module('thread_textual_typing_status_tests.js', {
|
||||
beforeEach() {
|
||||
utilsBeforeEach(this);
|
||||
|
||||
this.createThreadTextualTypingStatusComponent = async thread => {
|
||||
const ThreadTextualTypingStatusComponent = components.ThreadTextualTypingStatus;
|
||||
ThreadTextualTypingStatusComponent.env = this.env;
|
||||
this.component = new ThreadTextualTypingStatusComponent(null, {
|
||||
threadLocalId: thread.localId,
|
||||
});
|
||||
await this.component.mount(this.widget.el);
|
||||
};
|
||||
|
||||
this.start = async params => {
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
let { env, widget } = await utilsStart(Object.assign({}, params, {
|
||||
data: this.data,
|
||||
}));
|
||||
this.env = env;
|
||||
this.widget = widget;
|
||||
};
|
||||
},
|
||||
async afterEach() {
|
||||
utilsAfterEach(this);
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
this.env = undefined;
|
||||
delete components.ThreadTextualTypingStatus.env;
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('receive visitor typing status "is typing"', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
Object.assign(this.data.initMessaging, {
|
||||
channel_slots: {
|
||||
channel_livechat: [{
|
||||
channel_type: 'livechat',
|
||||
id: 20,
|
||||
livechat_visitor: {
|
||||
country: false,
|
||||
id: false,
|
||||
name: "Visitor",
|
||||
},
|
||||
members: [{
|
||||
email: 'admin@odoo.com',
|
||||
id: 3,
|
||||
name: 'Admin',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
public_partner: {
|
||||
active: false,
|
||||
display_name: "Public Partner",
|
||||
id: 7,
|
||||
},
|
||||
});
|
||||
await this.start({
|
||||
env: {
|
||||
session: {
|
||||
name: 'Admin',
|
||||
partner_display_name: 'Your Company, Admin',
|
||||
partner_id: 3,
|
||||
uid: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
const thread = this.env.models['mail.thread'].find(thread =>
|
||||
thread.id === 20 &&
|
||||
thread.model === 'mail.channel'
|
||||
);
|
||||
await this.createThreadTextualTypingStatusComponent(thread);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadTextualTypingStatus').textContent,
|
||||
"",
|
||||
"Should display no one is currently typing"
|
||||
);
|
||||
|
||||
// simulate receive typing notification from visitor "is typing"
|
||||
await afterNextRender(() => {
|
||||
const typingData = {
|
||||
info: 'typing_status',
|
||||
partner_id: 7, // public partner_id
|
||||
is_typing: true,
|
||||
};
|
||||
const notification = [[false, 'mail.channel', 20], typingData];
|
||||
this.widget.call('bus_service', 'trigger', 'notification', [notification]);
|
||||
});
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadTextualTypingStatus').textContent,
|
||||
"Visitor is typing...",
|
||||
"Should display that visitor is typing"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,576 +0,0 @@
|
||||
odoo.define('im_livechat.im_livechat', function (require) {
|
||||
"use strict";
|
||||
|
||||
require('bus.BusService');
|
||||
var concurrency = require('web.concurrency');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var time = require('web.time');
|
||||
var utils = require('web.utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var WebsiteLivechat = require('im_livechat.model.WebsiteLivechat');
|
||||
var WebsiteLivechatMessage = require('im_livechat.model.WebsiteLivechatMessage');
|
||||
var WebsiteLivechatWindow = require('im_livechat.WebsiteLivechatWindow');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
|
||||
// Constants
|
||||
var LIVECHAT_COOKIE_HISTORY = 'im_livechat_history';
|
||||
var HISTORY_LIMIT = 15;
|
||||
|
||||
var RATING_TO_EMOJI = {
|
||||
"10":"😊",
|
||||
"5":"😐",
|
||||
"1":"😞"
|
||||
};
|
||||
|
||||
// History tracking
|
||||
var page = window.location.href.replace(/^.*\/\/[^/]+/, '');
|
||||
var pageHistory = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
|
||||
var urlHistory = [];
|
||||
if (pageHistory) {
|
||||
urlHistory = JSON.parse(pageHistory) || [];
|
||||
}
|
||||
if (!_.contains(urlHistory, page)) {
|
||||
urlHistory.push(page);
|
||||
while (urlHistory.length > HISTORY_LIMIT) {
|
||||
urlHistory.shift();
|
||||
}
|
||||
utils.set_cookie(LIVECHAT_COOKIE_HISTORY, JSON.stringify(urlHistory), 60*60*24); // 1 day cookie
|
||||
}
|
||||
|
||||
var LivechatButton = Widget.extend({
|
||||
className:'openerp o_livechat_button d-print-none',
|
||||
custom_events: {
|
||||
'close_chat_window': '_onCloseChatWindow',
|
||||
'post_message_chat_window': '_onPostMessageChatWindow',
|
||||
'save_chat_window': '_onSaveChatWindow',
|
||||
'updated_typing_partners': '_onUpdatedTypingPartners',
|
||||
'updated_unread_counter': '_onUpdatedUnreadCounter',
|
||||
},
|
||||
events: {
|
||||
'click': '_openChat'
|
||||
},
|
||||
init: function (parent, serverURL, options) {
|
||||
this._super(parent);
|
||||
this.options = _.defaults(options || {}, {
|
||||
input_placeholder: _t("Ask something ..."),
|
||||
default_username: _t("Visitor"),
|
||||
button_text: _t("Chat with one of our collaborators"),
|
||||
default_message: _t("How may I help you?"),
|
||||
});
|
||||
|
||||
this._history = null;
|
||||
// livechat model
|
||||
this._livechat = null;
|
||||
// livechat window
|
||||
this._chatWindow = null;
|
||||
this._messages = [];
|
||||
this._serverURL = serverURL;
|
||||
},
|
||||
willStart: function () {
|
||||
var self = this;
|
||||
var cookie = utils.get_cookie('im_livechat_session');
|
||||
var ready;
|
||||
if (!cookie) {
|
||||
ready = session.rpc('/im_livechat/init', {channel_id: this.options.channel_id})
|
||||
.then(function (result) {
|
||||
if (!result.available_for_me) {
|
||||
return Promise.reject();
|
||||
}
|
||||
self._rule = result.rule;
|
||||
});
|
||||
} else {
|
||||
var channel = JSON.parse(cookie);
|
||||
ready = session.rpc('/mail/chat_history', {uuid: channel.uuid, limit: 100})
|
||||
.then(function (history) {
|
||||
self._history = history;
|
||||
});
|
||||
}
|
||||
return ready.then(this._loadQWebTemplate.bind(this));
|
||||
},
|
||||
start: function () {
|
||||
this.$el.text(this.options.button_text);
|
||||
if (this._history) {
|
||||
_.each(this._history.reverse(), this._addMessage.bind(this));
|
||||
this._openChat();
|
||||
} else if (!config.device.isMobile && this._rule.action === 'auto_popup') {
|
||||
var autoPopupCookie = utils.get_cookie('im_livechat_auto_popup');
|
||||
if (!autoPopupCookie || JSON.parse(autoPopupCookie)) {
|
||||
this._autoPopupTimeout =
|
||||
setTimeout(this._openChat.bind(this), this._rule.auto_popup_timer*1000);
|
||||
}
|
||||
}
|
||||
this.call('bus_service', 'onNotification', this, this._onNotification);
|
||||
if (this.options.button_background_color) {
|
||||
this.$el.css('background-color', this.options.button_background_color);
|
||||
}
|
||||
if (this.options.button_text_color) {
|
||||
this.$el.css('color', this.options.button_text_color);
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} data
|
||||
* @param {Object} [options={}]
|
||||
*/
|
||||
_addMessage: function (data, options) {
|
||||
options = _.extend({}, this.options, options, {
|
||||
serverURL: this._serverURL,
|
||||
});
|
||||
var message = new WebsiteLivechatMessage(this, data, options);
|
||||
|
||||
var hasAlreadyMessage = _.some(this._messages, function (msg) {
|
||||
return message.getID() === msg.getID();
|
||||
});
|
||||
if (hasAlreadyMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._livechat) {
|
||||
this._livechat.addMessage(message);
|
||||
}
|
||||
|
||||
if (options && options.prepend) {
|
||||
this._messages.unshift(message);
|
||||
} else {
|
||||
this._messages.push(message);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_askFeedback: function () {
|
||||
this._chatWindow.$('.o_thread_composer input').prop('disabled', true);
|
||||
|
||||
var feedback = new Feedback(this, this._livechat);
|
||||
this._chatWindow.replaceContentWith(feedback);
|
||||
|
||||
feedback.on('send_message', this, this._sendMessage);
|
||||
feedback.on('feedback_sent', this, this._closeChat);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeChat: function () {
|
||||
this._chatWindow.destroy();
|
||||
utils.set_cookie('im_livechat_session', "", -1); // remove cookie
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Array} notification
|
||||
*/
|
||||
_handleNotification: function (notification){
|
||||
if (this._livechat && (notification[0] === this._livechat.getUUID())) {
|
||||
if (notification[1]._type === 'history_command') { // history request
|
||||
var cookie = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
|
||||
var history = cookie ? JSON.parse(cookie) : [];
|
||||
session.rpc('/im_livechat/history', {
|
||||
pid: this._livechat.getOperatorPID()[0],
|
||||
channel_uuid: this._livechat.getUUID(),
|
||||
page_history: history,
|
||||
});
|
||||
} else if (notification[1].info === 'typing_status') {
|
||||
var isWebsiteUser = notification[1].is_website_user;
|
||||
if (isWebsiteUser) {
|
||||
return; // do not handle typing status notification of myself
|
||||
}
|
||||
var partnerID = notification[1].partner_id;
|
||||
if (notification[1].is_typing) {
|
||||
this._livechat.registerTyping({ partnerID: partnerID });
|
||||
} else {
|
||||
this._livechat.unregisterTyping({ partnerID: partnerID });
|
||||
}
|
||||
} else { // normal message
|
||||
// If message from notif is already in chatter messages, stop handling
|
||||
if (this._messages.some(message => message.getID() === notification[1].id)) {
|
||||
this._livechat.unregisterTyping({ partnerID: notification[1].author_id[0] });
|
||||
return;
|
||||
}
|
||||
this._addMessage(notification[1]);
|
||||
this._renderMessages();
|
||||
if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) {
|
||||
this._livechat.incrementUnreadCounter();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_loadQWebTemplate: function () {
|
||||
return session.rpc('/im_livechat/load_templates').then(function (templates) {
|
||||
_.each(templates, function (template) {
|
||||
QWeb.add_template(template);
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_openChat: _.debounce(function () {
|
||||
if (this._openingChat) {
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
var cookie = utils.get_cookie('im_livechat_session');
|
||||
var def;
|
||||
this._openingChat = true;
|
||||
clearTimeout(this._autoPopupTimeout);
|
||||
if (cookie) {
|
||||
def = Promise.resolve(JSON.parse(cookie));
|
||||
} else {
|
||||
this._messages = []; // re-initialize messages cache
|
||||
def = session.rpc('/im_livechat/get_session', {
|
||||
channel_id : this.options.channel_id,
|
||||
anonymous_name : this.options.default_username,
|
||||
previous_operator_id: this._get_previous_operator_id(),
|
||||
}, {shadow: true});
|
||||
}
|
||||
def.then(function (livechatData) {
|
||||
if (!livechatData || !livechatData.operator_pid) {
|
||||
self.displayNotification({
|
||||
message: _t("No available collaborator, please try again later."),
|
||||
sticky: true
|
||||
});
|
||||
} else {
|
||||
self._livechat = new WebsiteLivechat({
|
||||
parent: self,
|
||||
data: livechatData
|
||||
});
|
||||
return self._openChatWindow().then(function () {
|
||||
if (!self._history) {
|
||||
self._sendWelcomeMessage();
|
||||
}
|
||||
self._renderMessages();
|
||||
self.call('bus_service', 'addChannel', self._livechat.getUUID());
|
||||
self.call('bus_service', 'startPolling');
|
||||
|
||||
utils.set_cookie('im_livechat_session', JSON.stringify(self._livechat.toData()), 60*60);
|
||||
utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60);
|
||||
if (livechatData.operator_pid[0]) {
|
||||
// livechatData.operator_pid contains a tuple (id, name)
|
||||
// we are only interested in the id
|
||||
var operatorPidId = livechatData.operator_pid[0];
|
||||
var oneWeek = 7*24*60*60;
|
||||
utils.set_cookie('im_livechat_previous_operator_pid', operatorPidId, oneWeek);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).then(function () {
|
||||
self._openingChat = false;
|
||||
}).guardedCatch(function() {
|
||||
self._openingChat = false;
|
||||
});
|
||||
}, 200, true),
|
||||
/**
|
||||
* Will try to get a previous operator for this visitor.
|
||||
* If the visitor already had visitor A, it's better for his user experience
|
||||
* to get operator A again.
|
||||
*
|
||||
* The information is stored in the 'im_livechat_previous_operator_pid' cookie.
|
||||
*
|
||||
* @private
|
||||
* @return {integer} operator_id.partner_id.id if the cookie is set
|
||||
*/
|
||||
_get_previous_operator_id: function () {
|
||||
var cookie = utils.get_cookie('im_livechat_previous_operator_pid');
|
||||
if (cookie) {
|
||||
return cookie;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @return {Promise}
|
||||
*/
|
||||
_openChatWindow: function () {
|
||||
var self = this;
|
||||
var options = {
|
||||
displayStars: false,
|
||||
headerBackgroundColor: this.options.header_background_color,
|
||||
placeholder: this.options.input_placeholder || "",
|
||||
titleColor: this.options.title_color,
|
||||
};
|
||||
this._chatWindow = new WebsiteLivechatWindow(this, this._livechat, options);
|
||||
return this._chatWindow.appendTo($('body')).then(function () {
|
||||
var cssProps = {bottom: 0};
|
||||
cssProps[_t.database.parameters.direction === 'rtl' ? 'left' : 'right'] = 0;
|
||||
self._chatWindow.$el.css(cssProps);
|
||||
self.$el.hide();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderMessages: function () {
|
||||
var shouldScroll = !this._chatWindow.isFolded() && this._chatWindow.isAtBottom();
|
||||
this._livechat.setMessages(this._messages);
|
||||
this._chatWindow.render();
|
||||
if (shouldScroll) {
|
||||
this._chatWindow.scrollToBottom();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
* @return {Promise}
|
||||
*/
|
||||
_sendMessage: function (message) {
|
||||
var self = this;
|
||||
return session
|
||||
.rpc('/mail/chat_post', {uuid: this._livechat.getUUID(), message_content: message.content})
|
||||
.then(function (messageId) {
|
||||
if (!messageId) {
|
||||
self.displayNotification({
|
||||
message: _t("Session expired... Please refresh and try again."),
|
||||
sticky: true
|
||||
});
|
||||
self._closeChat();
|
||||
}
|
||||
self._chatWindow.scrollToBottom();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_sendWelcomeMessage: function () {
|
||||
if (this.options.default_message) {
|
||||
this._addMessage({
|
||||
id: '_welcome',
|
||||
attachment_ids: [],
|
||||
author_id: this._livechat.getOperatorPID(),
|
||||
body: this.options.default_message,
|
||||
channel_ids: [this._livechat.getID()],
|
||||
date: time.datetime_to_str(new Date()),
|
||||
tracking_value_ids: [],
|
||||
}, {prepend: true});
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onCloseChatWindow: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var isComposerDisabled = this._chatWindow.$('.o_thread_composer input').prop('disabled');
|
||||
var shouldAskFeedback = !isComposerDisabled && _.find(this._messages, function (message) {
|
||||
return message.getID() !== '_welcome';
|
||||
});
|
||||
if (shouldAskFeedback) {
|
||||
this._chatWindow.toggleFold(false);
|
||||
this._askFeedback();
|
||||
} else {
|
||||
this._closeChat();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Array[]} notifications
|
||||
*/
|
||||
_onNotification: function (notifications) {
|
||||
var self = this;
|
||||
_.each(notifications, function (notification) {
|
||||
self._handleNotification(notification);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {Object} ev.data.messageData
|
||||
*/
|
||||
_onPostMessageChatWindow: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var self = this;
|
||||
var messageData = ev.data.messageData;
|
||||
this._sendMessage(messageData).guardedCatch(function (reason) {
|
||||
reason.event.preventDefault();
|
||||
return self._sendMessage(messageData); // try again just in case
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onSaveChatWindow: function (ev) {
|
||||
ev.stopPropagation();
|
||||
utils.set_cookie('im_livechat_session', JSON.stringify(this._livechat.toData()), 60*60);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onUpdatedTypingPartners: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._chatWindow.renderHeader();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onUpdatedUnreadCounter: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._chatWindow.renderHeader();
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* Rating for Livechat
|
||||
*
|
||||
* This widget displays the 3 rating smileys, and a textarea to add a reason
|
||||
* (only for red smiley), and sends the user feedback to the server.
|
||||
*/
|
||||
var Feedback = Widget.extend({
|
||||
template: 'im_livechat.FeedBack',
|
||||
|
||||
events: {
|
||||
'click .o_livechat_rating_choices img': '_onClickSmiley',
|
||||
'click .o_livechat_no_feedback span': '_onClickNoFeedback',
|
||||
'click .o_rating_submit_button': '_onClickSend',
|
||||
'click .o_email_chat_button': '_onEmailChat',
|
||||
'click .o_livechat_email_error .alert-link': '_onTryAgain',
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {?} parent
|
||||
* @param {im_livechat.model.WebsiteLivechat} livechat
|
||||
*/
|
||||
init: function (parent, livechat) {
|
||||
this._super(parent);
|
||||
this._livechat = livechat;
|
||||
this.server_origin = session.origin;
|
||||
this.rating = undefined;
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} options
|
||||
*/
|
||||
_sendFeedback: function (reason) {
|
||||
var self = this;
|
||||
var args = {
|
||||
uuid: this._livechat.getUUID(),
|
||||
rate: this.rating,
|
||||
reason: reason,
|
||||
};
|
||||
this.dp.add(session.rpc('/im_livechat/feedback', args)).then(function () {
|
||||
var emoji = RATING_TO_EMOJI[self.rating] || "??" ;
|
||||
var content = _.str.sprintf(_t("Rating: %s"), emoji);
|
||||
if (reason) {
|
||||
content += " \n" + reason;
|
||||
}
|
||||
self.trigger('send_message', { content: content, isFeedback: true });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_showThanksMessage: function () {
|
||||
this.$('.o_livechat_rating_box').empty().append($('<div />', {
|
||||
text: _t('Thank you for your feedback'),
|
||||
class: 'text-muted'
|
||||
}));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickNoFeedback: function () {
|
||||
this.trigger('feedback_sent'); // will close the chat
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickSend: function () {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
this._showThanksMessage();
|
||||
if (_.isNumber(this.rating)) {
|
||||
this._sendFeedback(this.$('textarea').val());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickSmiley: function (ev) {
|
||||
this.rating = parseInt($(ev.currentTarget).data('value'));
|
||||
this.$('.o_livechat_rating_choices img').removeClass('selected');
|
||||
this.$('.o_livechat_rating_choices img[data-value="'+this.rating+'"]').addClass('selected');
|
||||
|
||||
// only display textearea if bad smiley selected
|
||||
if (this.rating !== 5) {
|
||||
this.$('.o_livechat_rating_reason').show();
|
||||
} else {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
this._showThanksMessage();
|
||||
this._sendFeedback();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEmailChat: function () {
|
||||
var self = this;
|
||||
var $email = this.$('#o_email');
|
||||
|
||||
if (utils.is_email($email.val())) {
|
||||
$email.removeAttr('title').removeClass('is-invalid').prop('disabled', true);
|
||||
this.$('.o_email_chat_button').prop('disabled', true);
|
||||
this._rpc({
|
||||
route: '/im_livechat/email_livechat_transcript',
|
||||
params: {
|
||||
uuid: this._livechat.getUUID(),
|
||||
email: $email.val(),
|
||||
}
|
||||
}).then(function () {
|
||||
self.$('.o_livechat_email').html($('<strong />', { text: _t('Conversation Sent') }));
|
||||
}).guardedCatch(function () {
|
||||
self.$('.o_livechat_email').hide();
|
||||
self.$('.o_livechat_email_error').show();
|
||||
});
|
||||
} else {
|
||||
$email.addClass('is-invalid').prop('title', _t('Invalid email address'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onTryAgain: function () {
|
||||
this.$('#o_email').prop('disabled', false);
|
||||
this.$('.o_email_chat_button').prop('disabled', false);
|
||||
this.$('.o_livechat_email_error').hide();
|
||||
this.$('.o_livechat_email').show();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
LivechatButton: LivechatButton,
|
||||
Feedback: Feedback,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
odoo.define('im_livechat.Discuss', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Discuss = require('mail.Discuss');
|
||||
|
||||
Discuss.include({
|
||||
/**
|
||||
* Override to sort livechats by last message's date
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {mail.model.Channel[]} channels
|
||||
* @returns {mail.model.Channel[]}
|
||||
*/
|
||||
_sortChannels: function (channels) {
|
||||
var partition = _.partition(channels, function (channel) {
|
||||
return channel.getType() === 'livechat';
|
||||
});
|
||||
partition[0].sort(function (c1, c2) {
|
||||
if (!c1.hasMessages()) {
|
||||
return -1;
|
||||
} else if (!c2.hasMessages()) {
|
||||
return 1;
|
||||
}
|
||||
return c2.getLastMessage().getDate().diff(c1.getLastMessage().getDate());
|
||||
});
|
||||
channels = partition[0].concat(partition[1]);
|
||||
return this._super(channels);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
odoo.define('im_livechat.model.WebsiteLivechat', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractThread = require('mail.model.AbstractThread');
|
||||
var ThreadTypingMixin = require('mail.model.ThreadTypingMixin');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* Thread model that represents a livechat on the website-side. This livechat
|
||||
* is not linked to the mail service.
|
||||
*/
|
||||
var WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, {
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object} params.data
|
||||
* @param {boolean} [params.data.folded] states whether the livechat is
|
||||
* folded or not. It is considered only if this is defined and it is a
|
||||
* boolean.
|
||||
* @param {integer} params.data.id the ID of this livechat.
|
||||
* @param {integer} [params.data.message_unread_counter] the unread counter
|
||||
* of this livechat.
|
||||
* @param {Array} params.data.operator_pid
|
||||
* @param {string} params.data.name the name of this livechat.
|
||||
* @param {string} [params.data.state] if 'folded', the livechat is folded.
|
||||
* This is ignored if `folded` is provided and is a boolean value.
|
||||
* @param {string} params.data.uuid the UUID of this livechat.
|
||||
* @param {im_livechat.im_livechat.LivechatButton} params.parent
|
||||
*/
|
||||
init: function (params) {
|
||||
this._super.apply(this, arguments);
|
||||
ThreadTypingMixin.init.call(this, arguments);
|
||||
|
||||
this._members = [];
|
||||
this._operatorPID = params.data.operator_pid;
|
||||
this._uuid = params.data.uuid;
|
||||
|
||||
if (params.data.message_unread_counter !== undefined) {
|
||||
this._unreadCounter = params.data.message_unread_counter;
|
||||
}
|
||||
|
||||
if (_.isBoolean(params.data.folded)) {
|
||||
this._folded = params.data.folded;
|
||||
} else {
|
||||
this._folded = params.data.state === 'folded';
|
||||
}
|
||||
|
||||
// Necessary for thread typing mixin to display is typing notification
|
||||
// bar text (at least, for the operator in the members).
|
||||
this._members.push({
|
||||
id: this._operatorPID[0],
|
||||
name: this._operatorPID[1]
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {im_livechat.model.WebsiteLivechatMessage[]}
|
||||
*/
|
||||
getMessages: function () {
|
||||
return this._messages;
|
||||
},
|
||||
/**
|
||||
* @returns {Array}
|
||||
*/
|
||||
getOperatorPID: function () {
|
||||
return this._operatorPID;
|
||||
},
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getUUID: function () {
|
||||
return this._uuid;
|
||||
},
|
||||
/**
|
||||
* Increments the unread counter of this livechat by 1 unit.
|
||||
*
|
||||
* Note: this public method makes sense because the management of messages
|
||||
* for website livechat is external. This method should be dropped when
|
||||
* this class handles messages by itself.
|
||||
*/
|
||||
incrementUnreadCounter: function () {
|
||||
this._incrementUnreadCounter();
|
||||
},
|
||||
/**
|
||||
* AKU: hack for the moment
|
||||
*
|
||||
* @param {im_livechat.model.WebsiteLivechatMessage[]} messages
|
||||
*/
|
||||
setMessages: function (messages) {
|
||||
this._messages = messages;
|
||||
},
|
||||
/**
|
||||
* @returns {Object}
|
||||
*/
|
||||
toData: function () {
|
||||
return {
|
||||
folded: this.isFolded(),
|
||||
id: this.getID(),
|
||||
message_unread_counter: this.getUnreadCounter(),
|
||||
operator_pid: this.getOperatorPID(),
|
||||
name: this.getName(),
|
||||
uuid: this.getUUID(),
|
||||
};
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override {mail.model.ThreadTypingMixin}
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.isWebsiteUser
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isTypingMyselfInfo: function (params) {
|
||||
return params.isWebsiteUser;
|
||||
},
|
||||
/**
|
||||
* @override {mail.model.ThreadTypingMixin}
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.typing
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_notifyMyselfTyping: function (params) {
|
||||
return session.rpc('/im_livechat/notify_typing', {
|
||||
uuid: this.getUUID(),
|
||||
is_typing: params.typing,
|
||||
}, { shadow: true });
|
||||
},
|
||||
/**
|
||||
* Warn views that the list of users that are currently typing on this
|
||||
* livechat has been updated.
|
||||
*
|
||||
* @override {mail.model.ThreadTypingMixin}
|
||||
* @private
|
||||
*/
|
||||
_warnUpdatedTypingPartners: function () {
|
||||
this.trigger_up('updated_typing_partners');
|
||||
},
|
||||
/**
|
||||
* Warn that the unread counter has been updated on this livechat
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_warnUpdatedUnreadCounter: function () {
|
||||
this.trigger_up('updated_unread_counter');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handler
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Override so that it only unregister typing operators.
|
||||
*
|
||||
* Note that in the frontend, there is no way to identify a message that is
|
||||
* from the current user, because there is no partner ID in the session and
|
||||
* a message with an author sets the partner ID of the author.
|
||||
*
|
||||
* @override {mail.model.ThreadTypingMixin}
|
||||
* @private
|
||||
* @param {mail.model.AbstractMessage} message
|
||||
*/
|
||||
_onTypingMessageAdded: function (message) {
|
||||
var operatorID = this.getOperatorPID()[0];
|
||||
if (message.hasAuthor() && message.getAuthorID() === operatorID) {
|
||||
this.unregisterTyping({ partnerID: operatorID });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return WebsiteLivechat;
|
||||
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
odoo.define('im_livechat.model.WebsiteLivechatMessage', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractMessage = require('mail.model.AbstractMessage');
|
||||
|
||||
/**
|
||||
* This is a message that is handled by im_livechat, without making use of the
|
||||
* mail.Manager. The purpose of this is to make im_livechat compatible with
|
||||
* mail.widget.Thread.
|
||||
*
|
||||
* @see mail.model.AbstractMessage for more information.
|
||||
*/
|
||||
var WebsiteLivechatMessage = AbstractMessage.extend({
|
||||
|
||||
/**
|
||||
* @param {im_livechat.im_livechat.LivechatButton} parent
|
||||
* @param {Object} data
|
||||
* @param {Object} options
|
||||
* @param {string} options.default_username
|
||||
* @param {string} options.serverURL
|
||||
*/
|
||||
init: function (parent, data, options) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this._defaultUsername = options.default_username;
|
||||
this._serverURL = options.serverURL;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the relative url of the avatar to display next to the message
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getAvatarSource: function () {
|
||||
var source = this._serverURL;
|
||||
if (this.hasAuthor()) {
|
||||
source += '/web/partner_image/' + this.getAuthorID();
|
||||
} else {
|
||||
source += '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
* Get the text to display for the author of the message
|
||||
*
|
||||
* Rule of precedence for the displayed author::
|
||||
*
|
||||
* author name > default usernane
|
||||
*
|
||||
* @override
|
||||
* @return {string}
|
||||
*/
|
||||
getDisplayedAuthor: function () {
|
||||
return this._super.apply(this, arguments) || this._defaultUsername;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return WebsiteLivechatMessage;
|
||||
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
odoo.define('im_livechat.WebsiteLivechatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractThreadWindow = require('mail.AbstractThreadWindow');
|
||||
|
||||
/**
|
||||
* This is the widget that represent windows of livechat in the frontend.
|
||||
*
|
||||
* @see mail.AbstractThreadWindow for more information
|
||||
*/
|
||||
var LivechatWindow = AbstractThreadWindow.extend({
|
||||
events: _.extend(AbstractThreadWindow.prototype.events, {
|
||||
'input .o_composer_text_field': '_onInput',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {im_livechat.im_livechat.LivechatButton} parent
|
||||
* @param {im_livechat.model.WebsiteLivechat} thread
|
||||
* @param {Object} [options={}]
|
||||
* @param {string} [options.headerBackgroundColor]
|
||||
* @param {string} [options.titleColor]
|
||||
*/
|
||||
init(parent, thread, options={}) {
|
||||
this._super.apply(this, arguments);
|
||||
this._thread = thread;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @return {Promise}
|
||||
*/
|
||||
async start() {
|
||||
await this._super(...arguments);
|
||||
if (this.options.headerBackgroundColor) {
|
||||
this.$('.o_thread_window_header').css('background-color', this.options.headerBackgroundColor);
|
||||
}
|
||||
if (this.options.titleColor) {
|
||||
this.$('.o_thread_window_header').css('color', this.options.titleColor);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
this.trigger_up('close_chat_window');
|
||||
},
|
||||
/**
|
||||
* Replace the thread content with provided new content
|
||||
*
|
||||
* @param {$.Element} $element
|
||||
*/
|
||||
replaceContentWith: function ($element) {
|
||||
$element.replace(this._threadWidget.$el);
|
||||
},
|
||||
/**
|
||||
* Warn the parent widget (LivechatButton)
|
||||
*
|
||||
* @override
|
||||
* @param {boolean} folded
|
||||
*/
|
||||
toggleFold: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.trigger_up('save_chat_window');
|
||||
this.updateVisualFoldState();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {Object} messageData
|
||||
*/
|
||||
_postMessage: function (messageData) {
|
||||
this.trigger_up('post_message_chat_window', { messageData: messageData });
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when the input in the composer changes
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onInput: function () {
|
||||
if (this.hasThread() && this._thread.hasTypingNotification()) {
|
||||
var isTyping = this.$input.val().length > 0;
|
||||
this._thread.setMyselfTyping({ typing: isTyping });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return LivechatWindow;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,634 @@
|
||||
//-------------------------------------
|
||||
// legacy: mail/abstract_thread_window.scss
|
||||
//-------------------------------------
|
||||
|
||||
$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
|
||||
|
||||
.o_thread_window {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
position: fixed;
|
||||
width: $o-mail-thread-window-width;
|
||||
max-width: 100%;
|
||||
height: 400px;
|
||||
max-height: 100%;
|
||||
font-size: 12px;
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: $o-mail-thread-window-zindex;
|
||||
box-shadow: -5px -5px 10px rgba(black, 0.18);
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
width: 100%;
|
||||
height: 100%!important;
|
||||
box-shadow: none;
|
||||
&.o_folded {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_thread_window_header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
color: white;
|
||||
padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
|
||||
border-radius: 3px 3px 0 0;
|
||||
border-bottom: 1px solid gray('300');
|
||||
background-color: $o-brand-odoo;
|
||||
padding: 8px;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
align-items: center;
|
||||
height: $o-mail-chat-header-height;
|
||||
padding: 0;
|
||||
border-radius: 0px;
|
||||
.o_thread_window_title {
|
||||
font-size: 16px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.o_thread_window_close {
|
||||
$o-close-font-size: 17px;
|
||||
padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
|
||||
font-size: $o-close-font-size;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.o_thread_window_avatar {
|
||||
margin: -6px 6px -6px 0;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
span {
|
||||
bottom: -4px;
|
||||
right: -2px;
|
||||
position: absolute;
|
||||
|
||||
.fa-circle-o {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_title {
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
@include o-text-overflow;
|
||||
|
||||
.o_mail_thread_typing_icon {
|
||||
padding-left: 2px;
|
||||
|
||||
.o_mail_thread_typing_icon_dot {
|
||||
background: gray('300');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_buttons {
|
||||
flex: 0 0 auto;
|
||||
.o_thread_window_close, .o_thread_window_expand {
|
||||
color: white;
|
||||
padding: 3px;
|
||||
margin-left: 5px;
|
||||
@include o-hover-opacity(0.7, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_mail_thread {
|
||||
flex: 1 1 100%;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
|
||||
|
||||
.o_thread_date_separator {
|
||||
margin: 0px 0px 15px 0px;
|
||||
.o_thread_date {
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
}
|
||||
}
|
||||
.o_thread_message {
|
||||
padding: 4px 5px;
|
||||
.o_thread_message_sidebar {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.o_attachment {
|
||||
@include media-breakpoint-up(md) {
|
||||
width: percentage(1/3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_composer input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_dropdown {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
color: white;
|
||||
background-color: gray('900');
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_thread_window_header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.o_thread_window_dropdown_toggler {
|
||||
padding: 5px;
|
||||
|
||||
.o_total_unread_counter {
|
||||
@include o-position-absolute(-10px, 0, auto, auto);
|
||||
background-color: $o-brand-primary;
|
||||
padding: 0 2px;
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
&.show .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> ul {
|
||||
max-width: $o-mail-thread-window-width;
|
||||
padding: 0;
|
||||
|
||||
> li.o_thread_window_header {
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
&~li.o_thread_window_header {
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
&:hover {
|
||||
background-color: darken($o-brand-odoo, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_ui_blocked .o_thread_window {
|
||||
// We cannot put the z-index of thread windows directly to be greater than
|
||||
// blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
|
||||
// appear under the thread windows (and ui-autocomplete is used to choose the
|
||||
// person you want to chat with). So we only raise the z-index value when
|
||||
// the ui is really blocked (in that case, the ui-autocomplete dropdowns
|
||||
// will disappear under the thread windows but this is not really an issue as
|
||||
// there should not be any at that time).
|
||||
z-index: 1101; // blockUI's z-index is 1100
|
||||
}
|
||||
|
||||
.o_no_thread_window .o_thread_window {
|
||||
display: none;
|
||||
}
|
||||
|
||||
//-------------------------------------
|
||||
// legacy: mail/thread.scss
|
||||
//-------------------------------------
|
||||
|
||||
|
||||
.o_mail_thread_loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.o_mail_thread_loading_icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.o_mail_thread, .o_mail_activity {
|
||||
.o_thread_show_more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_mail_thread_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.o_thread_bottom_free_space {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.o_thread_tooltip_container {
|
||||
display: inline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.o_thread_date_separator {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
@include media-breakpoint-down(sm) {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
border-bottom: 1px solid gray('400');
|
||||
text-align: center;
|
||||
|
||||
.o_thread_date {
|
||||
position: relative;
|
||||
top: 10px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_new_messages_separator {
|
||||
margin-bottom: 15px;
|
||||
border-bottom: solid lighten($o-brand-odoo, 15%) 1px;
|
||||
text-align: right;
|
||||
.o_thread_separator_label {
|
||||
position: relative;
|
||||
top: 8px;
|
||||
padding: 0 10px;
|
||||
background: white;
|
||||
color: lighten($o-brand-odoo, 15%);
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_message {
|
||||
display: flex;
|
||||
padding: 4px $o-horizontal-padding;
|
||||
margin-bottom: 0px;
|
||||
|
||||
&.o_mail_not_discussion {
|
||||
background-color: rgba(gray('300'), 0.5);
|
||||
border-bottom: 1px solid gray('400');
|
||||
}
|
||||
|
||||
.o_thread_message_sidebar {
|
||||
flex: 0 0 $o-mail-thread-avatar-size;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
font-size: smaller;
|
||||
.o_thread_message_sidebar_image {
|
||||
position: relative;
|
||||
height: $o-mail-thread-avatar-size;
|
||||
|
||||
.o_updatable_im_status {
|
||||
width: $o-mail-thread-avatar-size;
|
||||
}
|
||||
.o_mail_user_status {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
&.fa-circle-o {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
margin-top: 4px;
|
||||
font-size: x-small;
|
||||
}
|
||||
|
||||
.o_thread_message_avatar {
|
||||
width: $o-mail-thread-avatar-size;
|
||||
height: $o-mail-thread-avatar-size;
|
||||
object-fit: cover;
|
||||
}
|
||||
.o_thread_message_side_date {
|
||||
display: none;
|
||||
margin-left: -5px;
|
||||
}
|
||||
.o_thread_message_star {
|
||||
display: none;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
.o_thread_message_side_date {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.o_mail_thread_message_seen_icon:not(.o_all_seen) {
|
||||
opacity: $o-mail-thread-icon-opacity*0.5;
|
||||
}
|
||||
}
|
||||
.o_thread_icon {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
&.fa-star {
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
color: gold;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &.o_thread_selected_message {
|
||||
.o_thread_message_side_date {
|
||||
display: inline-block;
|
||||
opacity: $o-mail-thread-side-date-opacity;
|
||||
}
|
||||
.o_thread_icon {
|
||||
display: inline-block;
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.o_thread_message_sidebar {
|
||||
.o_mail_thread_message_seen_icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_redirect {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_thread_message_core {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
> pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.o_mail_note_title {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.o_mail_subject {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.o_mail_notification {
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
[summary~=o_mail_notification] { // name conflicts with channel notifications, but is odoo notification buttons to hide in chatter if present
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 9px; // Required by the old design to override a general rule on p's
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
:not(.o_image_box) > img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.o_mail_body_long {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_mail_info {
|
||||
margin-bottom: 2px;
|
||||
|
||||
strong {
|
||||
color: $headings-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.o_thread_message_notification {
|
||||
color: grey;
|
||||
&.o_thread_message_notification_error {
|
||||
color: red;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.o_attachments_list, .o_attachments_previews {
|
||||
&:last-child {
|
||||
margin-bottom: $grid-gutter-width;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_tooltip_container {
|
||||
display: inline;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_thread_title {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 125%;
|
||||
|
||||
&.o_neutral_face_icon:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
@include size(120px, 140px);
|
||||
background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_no_content {
|
||||
@include o-position-absolute(30%, 0, 0, 0);
|
||||
text-align: center;
|
||||
font-size: 115%;
|
||||
}
|
||||
|
||||
.o_thread_message .o_thread_message_core .o_mail_read_more {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.o_web_client .popover .o_thread_tooltip_icon {
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.o_web_client.o_touch_device {
|
||||
.o_mail_thread .o_thread_icon {
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Thread typing icon: shared between discuss and chat windows
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_thread_typing_icon {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
.o_mail_thread_typing_icon_dot {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: gray('800');
|
||||
animation: o_mail_thread_typing_icon_dot 1.5s linear infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: -1.35s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: -1.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes o_mail_thread_typing_icon_dot {
|
||||
0%, 40%, 100% {
|
||||
transform: initial;
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Thread seen icon: shared between discuss and chat windows
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_thread_message_seen_icon {
|
||||
position: relative;
|
||||
opacity: 0.6;
|
||||
|
||||
&.o_all_seen {
|
||||
color: $o-enterprise-color;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fa-check:nth-child(1) {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
.fa-check:nth-child(2) {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------------------------
|
||||
// legacy: im_livechat/im_livechat.scss
|
||||
//-------------------------------------
|
||||
|
||||
.o_livechat_button {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
right: 6px;
|
||||
min-width: 100px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background-color: rgba(60, 60, 60, 0.6);
|
||||
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, Verdana, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
color: white;
|
||||
text-shadow: rgb(59, 76, 88) 1px 1px 0px;
|
||||
border: 1px solid rgb(80, 80, 80);
|
||||
border-bottom: 0px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.o_thread_window {
|
||||
z-index: 1002; // to go over the navbar
|
||||
.o_thread_date_separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_livechat_rating {
|
||||
/* Livechat Rating : feedback smiley */
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
font-size: 14px;
|
||||
|
||||
.o_livechat_email {
|
||||
font-size: 12px;
|
||||
> div {
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_no_feedback {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_box {
|
||||
margin: 40px 0 30px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_choices {
|
||||
margin: 10px 0;
|
||||
|
||||
> img {
|
||||
width: 65px;
|
||||
opacity: 0.60;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
&:hover, &.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* feedback reason */
|
||||
.o_livechat_rating_reason {
|
||||
margin: 10px 0;
|
||||
display: none; /* hidden by default */
|
||||
|
||||
> textarea {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_rating_reason_button > input {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_operator_avatar {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.o_livechat_no_rating {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="im_livechat.legacy.im_livechat.FeedBack">
|
||||
<div class="o_livechat_rating text-center">
|
||||
<div class="o_livechat_rating_box">
|
||||
<div class="o_livechat_rating_feedback_text">
|
||||
Did we correctly answer your question ?
|
||||
</div>
|
||||
<div class="o_livechat_rating_choices">
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_5.png'" alt="Good" data-value="5"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_3.png'" alt="OK" data-value="3"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_1.png'" alt="Bad" data-value="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_rating_reason">
|
||||
<textarea id="reason" placeholder="Explain your note"></textarea>
|
||||
<div class="o_livechat_rating_reason_button">
|
||||
<input type="button" class="btn btn-primary btn-sm o_rating_submit_button" value="Send" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_email">
|
||||
<span class="text-muted">Receive a copy of this conversation</span>
|
||||
<div class="input-group">
|
||||
<input id="o_email" type="text" class="form-control" placeholder="mail@example.com"/>
|
||||
<button type="button" class="o_email_chat_button btn btn-primary rounded-0">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger px-0 o_livechat_email_error" style="display: none;" role="alert">
|
||||
Oops! Something went wrong.<br />Please check your internet connection.<br />
|
||||
<a href="#" class="alert-link">Try again</a>
|
||||
</div>
|
||||
<div class="o_livechat_no_feedback text-muted">
|
||||
<span>Close conversation</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {im_livechat.legacy.mail.AbstractThreadWindow} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.AbstractThreadWindow">
|
||||
<div class="o_thread_window o_in_home_menu" t-att-data-thread-id="widget.getID()">
|
||||
<div class="o_thread_window_header">
|
||||
<t t-call="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
|
||||
<t t-set="status" t-value="widget.getThreadStatus()"/>
|
||||
<t t-set="title" t-value="widget.getTitle()"/>
|
||||
<t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/>
|
||||
<t t-set="thread" t-value="widget.getThread()"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_thread_window_content">
|
||||
</div>
|
||||
<div t-if="widget.needsComposer()" class="o_thread_composer o_chat_mini_composer">
|
||||
<input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} [status] e.g. 'online', 'offline'.
|
||||
@param {string} title the title of the thread window, e.g. the record
|
||||
name of the document.
|
||||
@param {integer} [unreadCounter] the number of unread messages on the
|
||||
thread.
|
||||
@param {im_livechat.legacy.mail.model.Thread|undefined} thread
|
||||
@param {Object} widget
|
||||
@param {function} widget.isMobile function without any param that
|
||||
states whether it should render for desktop or mobile screen.
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
|
||||
<span t-if="widget.isMobile()">
|
||||
<a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left" aria-label="Close chat window" title="Close chat window"/>
|
||||
</span>
|
||||
<span class="o_thread_window_title">
|
||||
<t t-esc="title"/>
|
||||
<span t-if="unreadCounter"> (<t t-esc="unreadCounter"/>)</span>
|
||||
<t t-if="thread and thread.hasTypingNotification() and thread.isSomeoneTyping()" t-call="im_livechat.legacy.mail.ThreadTypingIcon"/>
|
||||
</span>
|
||||
<span t-if="!widget.isMobile()" class="o_thread_window_buttons">
|
||||
<a href="#" class="o_thread_window_close fa fa-close"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} status
|
||||
@param {integer|undefined} [partnerID]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.UserStatus">
|
||||
<span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID">
|
||||
<i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
|
||||
<i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
|
||||
<i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayEmptyThread]
|
||||
@param {boolean} [options.displayModerationCommands]
|
||||
@param {boolean} [options.displayNoMatchFound]
|
||||
@param {Array} [options.domain=[]] the domain to restrict messages on the thread.
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread">
|
||||
<t t-if="thread.hasMessages({ 'domain': options.domain || [] })">
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Content"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Rendering of thread when messaging not yet ready -->
|
||||
<div t-name="im_livechat.legacy.mail.widget.ThreadLoading" class="o_mail_thread_loading">
|
||||
<i class="o_mail_thread_loading_icon fa fa-spinner fa-spin"/>
|
||||
<span>Please wait...</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@param {im_livechat.legacy.mail.DocumentViewer} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.DocumentViewer.Content">
|
||||
<div class="o_viewer_content">
|
||||
<t t-set="model" t-value="widget.modelName"/>
|
||||
<div class="o_viewer-header">
|
||||
<span class="o_image_caption">
|
||||
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
|
||||
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
||||
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
|
||||
<t t-esc="widget.activeAttachment.name"/>
|
||||
<a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
|
||||
</span>
|
||||
<a role="button" class="o_close_btn float-right" href="#" aria-label="Close" title="Close">×</a>
|
||||
</div>
|
||||
<div class="o_viewer_img_wrapper">
|
||||
<div class="o_viewer_zoomer">
|
||||
<t t-if="widget.activeAttachment.fileType === 'image'">
|
||||
<div class="o_loading_img text-center">
|
||||
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
|
||||
</div>
|
||||
<t t-set="unique" t-value="widget.activeAttachment.checksum ? widget.activeAttachment.checksum.slice(-8) : ''"/>
|
||||
<img class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=#{unique}&model=#{model}" alt="Viewer"/>
|
||||
</t>
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model=#{model}" />
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
|
||||
<video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
|
||||
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_out disabled" data-toggle="tooltip" title="Zoom Out"><i class="fa fa-fw fa-minus" role="img" aria-label="Zoom Out"/></a>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_rotate" data-toggle="tooltip" title="Rotate"><i class="fa fa-fw fa-repeat" role="img" aria-label="Rotate"/></a>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_print_btn" data-toggle="tooltip" title="Print"><i class="fa fa-fw fa-print" role="img" aria-label="Print"/></a>
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_download_btn" data-toggle="tooltip" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {im_livechat.legacy.mail.DocumentViewer} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.DocumentViewer">
|
||||
<div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog">
|
||||
<t class="o_document_viewer_content_call" t-call="im_livechat.legacy.mail.DocumentViewer.Content"/>
|
||||
|
||||
<t t-if="widget.attachment.length !== 1">
|
||||
<a class="arrow arrow-left move_previous" href="#">
|
||||
<span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
|
||||
</a>
|
||||
<a class="arrow arrow-right move_next" href="#">
|
||||
<span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} src
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.PrintImage">
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
function onload_img() {
|
||||
setTimeout('print_img()', 10);
|
||||
}
|
||||
function print_img() {
|
||||
window.print();
|
||||
window.close();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload='onload_img()'>
|
||||
<img t-att-src='src' alt=""/>
|
||||
</body>
|
||||
</html>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {integer} [options.displayOrder] 1 or -1 ascending (respectively, descending) order for
|
||||
the thread messages (from top to bottom)
|
||||
@param {Array} [options.domain=[]] the domain to restrict messages on the thread.
|
||||
@param {Object} ORDER
|
||||
@param {integer} ORDER.ASC=1 messages are ordered by ascending order of IDs, (from top to bottom)
|
||||
@param {integer} ORDER.DESC=-1 messages are ordered by descending IDs, (from top to bottom)
|
||||
|
||||
_____________ _____________
|
||||
| | | |
|
||||
| message 1 | | message n |
|
||||
| message 2 | | ... |
|
||||
| ... | | message 2 |
|
||||
| message n | | message 1 |
|
||||
|_____________| |_____________|
|
||||
|
||||
ORDER: ASC DESC
|
||||
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content">
|
||||
<t t-set="messages" t-value="thread.getMessages({ 'domain': options.domain || [] })"/>
|
||||
<t t-if="options.displayOrder === ORDER.ASC" t-call="im_livechat.legacy.mail.widget.Thread.Content.ASC"/>
|
||||
<t t-else="" t-call="im_livechat.legacy.mail.widget.Thread.Content.DESC"/>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayBottomThreadFreeSpace=false]
|
||||
@param {boolean} [options.displayLoadMore=false]
|
||||
|
||||
_____________
|
||||
| |
|
||||
| message 1 |
|
||||
| message 2 |
|
||||
| ... |
|
||||
| message n |
|
||||
|_____________|
|
||||
|
||||
ASC Order
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content.ASC">
|
||||
<div class="o_mail_thread_content">
|
||||
<t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
|
||||
<t t-if="options.displayBottomThreadFreeSpace">
|
||||
<div class="o_thread_bottom_free_space"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayLoadMore=false]
|
||||
@param {string|integer} [options.messagesSeparatorPosition] 'top' or
|
||||
message ID, the separator is placed just after this message.
|
||||
|
||||
_____________
|
||||
| |
|
||||
| message n |
|
||||
| ... |
|
||||
| message 2 |
|
||||
| message 1 |
|
||||
|_____________|
|
||||
|
||||
DESC Order
|
||||
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content.DESC">
|
||||
<div class="o_mail_thread_content">
|
||||
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="im_livechat.legacy.mail.MessagesSeparator"/>
|
||||
<t t-set="messages" t-value="messages.slice().reverse()"/>
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
|
||||
<t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractMessage[]} messages messages are ordered based
|
||||
on desired display order
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Messages">
|
||||
<t t-set="current_day" t-value="0"/>
|
||||
<t t-foreach="messages" t-as="message">
|
||||
<div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
|
||||
<span class="o_thread_date">
|
||||
<t t-esc="message.getDateDay()"/>
|
||||
</span>
|
||||
<t t-set="current_day" t-value="message.getDateDay()"/>
|
||||
</div>
|
||||
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Message"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {string} dateFormat
|
||||
@param {Object} options
|
||||
@param {mail.model.AbstractMessage} message
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayAvatars]
|
||||
@param {boolean} [options.displayDocumentLinks]
|
||||
@param {boolean} [options.displayNotificationIcons]
|
||||
@param {boolean} [options.displayMarkAsRead]
|
||||
@param {boolean} [options.displayModerationCommands] when set, display the moderation commands on
|
||||
the message. This includes the moderation checkboxes (needs a control panel such as in Discuss app).
|
||||
@param {boolean} [options.displayReplyIcons]
|
||||
@param {boolean} [options.displayStars]
|
||||
@param {boolean} [options.displaySubjectsOnMessages]
|
||||
@param {boolean} options.hasMessageAttachmentDeletable
|
||||
@param {string|integer} [options.messagesSeparatorPosition] 'top' or
|
||||
message ID, the separator is placed just after this message.
|
||||
@param {integer} [options.selectedMessageID]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Message">
|
||||
<div t-if="!message.isEmpty()" t-att-class="'o_thread_message ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ')" t-att-data-message-id="message.getID()">
|
||||
<div t-if="options.displayAvatars" class="o_thread_message_sidebar">
|
||||
<t t-if="message.hasAuthor()">
|
||||
<div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image">
|
||||
<img
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
data-oe-model="res.partner"
|
||||
t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
|
||||
<t t-call="im_livechat.legacy.mail.UserStatus">
|
||||
<t t-set="status" t-value="message.getAuthorImStatus()"/>
|
||||
<t t-set="partnerID" t-value="message.getAuthorID()"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-if="displayAuthorMessages[message.getID()]"
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
class="o_thread_message_avatar rounded-circle"/>
|
||||
</t>
|
||||
<span t-if="!displayAuthorMessages[message.getID()]" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date">
|
||||
<t t-esc="message.getDate().format('hh:mm')"/>
|
||||
</span>
|
||||
<i t-if="!displayAuthorMessages[message.getID()] and options.displayStars and message.getType() !== 'notification'"
|
||||
t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
|
||||
t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as todo"/>
|
||||
<t t-if="!displayAuthorMessages[message.getID()] and thread.hasSeenFeature()" t-call="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon"/>
|
||||
</div>
|
||||
<div class="o_thread_message_core">
|
||||
<p t-if="displayAuthorMessages[message.getID()]" class="o_mail_info text-muted">
|
||||
<t t-if="message.isNote()">
|
||||
Note by
|
||||
</t>
|
||||
|
||||
<strong t-if="message.hasAuthor()"
|
||||
data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_author #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
<strong t-elif="message.hasEmailFrom()">
|
||||
<a class="text-muted" t-attf-href="mailto:#{message.getEmailFrom()}?subject=Re: #{message.hasSubject() ? message.getSubject() : ''}">
|
||||
<t t-esc="message.getEmailFrom()"/>
|
||||
</a>
|
||||
</strong>
|
||||
<strong t-else="" class="o_thread_author">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
|
||||
- <small class="o_mail_timestamp" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small>
|
||||
<t t-if="message.isLinkedToDocumentThread() and options.displayDocumentLinks">
|
||||
<small>on</small> <a t-att-href="message.getURL()" t-att-data-oe-model="message.getDocumentModel()" t-att-data-oe-id="message.getDocumentID()" class="o_document_link"><t t-esc="message.getDocumentName()"/></a>
|
||||
</t>
|
||||
<t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== thread.getID())">
|
||||
(<small>from</small> <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>)
|
||||
</t>
|
||||
<span t-if="options.displayNotificationIcons and message.hasNotifications()" class="o_thread_tooltip_container">
|
||||
<span name="notification_icon" t-attf-class="d-inline-flex align-items-center o_thread_tooltip o_thread_message_notification {{ message.hasNotificationsError() ? 'o_thread_message_notification_error' : '' }}" t-att-data-message-id="message.getID()" t-att-data-message-type="message.getType()">
|
||||
<i t-att-class="message.getNotificationIcon()"/>
|
||||
<small t-if="message.getNotificationText()" t-esc="message.getNotificationText()" class="font-weight-bold ml-1"/>
|
||||
</span>
|
||||
</span>
|
||||
<span t-attf-class="o_thread_icons">
|
||||
<t t-if="thread.hasSeenFeature()" t-call="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon"/>
|
||||
<i t-if="message.isLinkedToDocumentThread() and options.displayReplyIcons"
|
||||
class="fa fa-reply o_thread_icon o_thread_message_reply"
|
||||
t-att-data-message-id="message.getID()" title="Reply" role="img" aria-label="Reply"/>
|
||||
<i t-if="message.isNeedaction() and options.displayMarkAsRead"
|
||||
class="fa fa-check o_thread_icon o_thread_message_needaction"
|
||||
t-att-data-message-id="message.getID()" title="Mark as Read" role="img" aria-label="Mark as Read"/>
|
||||
</span>
|
||||
</p>
|
||||
<div class="o_thread_message_content">
|
||||
<t t-raw="message.getBody()"/>
|
||||
<t t-if="message.hasTrackingValues()">
|
||||
<t t-if="message.hasSubtypeDescription()">
|
||||
<p><t t-esc="message.getSubtypeDescription()"/></p>
|
||||
</t>
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.MessageTracking"/>
|
||||
</t>
|
||||
<t t-if="message.hasAttachments()">
|
||||
<div t-if="message.hasImageAttachments()" class="o_attachments_previews">
|
||||
<t t-foreach="message.getImageAttachments()" t-as="attachment">
|
||||
<t t-call="im_livechat.legacy.mail.AttachmentPreview">
|
||||
<t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="message.hasNonImageAttachments()" class="o_attachments_list">
|
||||
<t t-foreach="message.getNonImageAttachments()" t-as="attachment">
|
||||
<t t-call="im_livechat.legacy.mail.Attachment">
|
||||
<t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="options.messagesSeparatorPosition == message.getID()">
|
||||
<t t-call="im_livechat.legacy.mail.MessagesSeparator"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
Display the seen icon of a message in the thread.
|
||||
It shows the fetch 'check' before the seen 'check'. We change the order in the template
|
||||
so that fetch 'check' is on top of 'seen' check (z-index does not work with absolute positioning)
|
||||
|
||||
@param {mail.model.Thread} thread
|
||||
@param {mail.model.Message} message
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon">
|
||||
<t t-if="message.isMyselfAuthor() and message.getID() >= thread.getLastMessageIDSeenByEveryone()">
|
||||
<span t-attf-class="o_mail_thread_message_seen_icon #{thread.hasEveryoneSeen(message) ? 'o_all_seen' : ''}" t-att-data-message-id="message.getID()">
|
||||
<t t-if="thread.hasSomeoneFetched(message)">
|
||||
<i class="fa fa-check"/>
|
||||
</t>
|
||||
<t t-if="thread.hasSomeoneSeen(message)">
|
||||
<i class="fa fa-check"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
Display the popover content when clicking the seen icion of a message in the thread.
|
||||
|
||||
List the members that have received the messages, and the members that have seen it.
|
||||
|
||||
@param {mail.model.Thread} thread
|
||||
@param {mail.model.Message} message
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Message.SeenIconPopoverContent">
|
||||
<div class="o_mail_thread_message_seen_icon_content">
|
||||
<t t-if="thread.hasEveryoneSeen(message)">
|
||||
<p>Seen by Everyone</p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="thread.hasSomeoneSeen(message)">
|
||||
<t t-set="seen_members" t-value="thread.getSeenMembers(message)"/>
|
||||
Seen by:
|
||||
<ul>
|
||||
<li t-foreach="seen_members" t-as="member">
|
||||
<t t-esc="member.name"/> (<t t-esc="member.email"/>)
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-if="thread.hasSomeoneFetched(message)">
|
||||
<t t-if="thread.hasEveryoneFetched(message)">
|
||||
<p>Received by Everyone</p>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="fetched_members" t-value="thread.getFetchedNotSeenMembers(message)"/>
|
||||
<t t-if="!_.isEmpty(fetched_members)">
|
||||
Received by:
|
||||
<ul>
|
||||
<li t-foreach="fetched_members" t-as="member">
|
||||
<t t-esc="member.name"/> (<t t-esc="member.email"/>)
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Object[]} notifications: list of notifications
|
||||
A notification is an object with at least the following keys:
|
||||
{notification_status, partner_name}
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Message.MailTooltip">
|
||||
<div t-foreach="notifications" t-as="notification">
|
||||
<span name="notification_status" class="d-inline-block text-center o_thread_tooltip_icon">
|
||||
<i t-if="notification.notification_status === 'sent'" class='fa fa-check' title="Sent" role="img" aria-label="Sent"/>
|
||||
<i t-if="notification.notification_status === 'bounce'" class='fa fa-exclamation text-danger' title="Bounced" role="img" aria-label="Bounced"/>
|
||||
<i t-if="notification.notification_status === 'exception'" class='fa fa-exclamation text-danger' title="Error" role="img" aria-label="Error"/>
|
||||
<i t-if="notification.notification_status === 'ready'" class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/>
|
||||
<i t-if="notification.notification_status === 'canceled'" class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/>
|
||||
</span>
|
||||
<span name="partner_name" t-esc="notification.partner_name"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.legacy.mail.MessagesSeparator">
|
||||
<div class="o_thread_new_messages_separator">
|
||||
<span class="o_thread_separator_label">New messages</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.Message} message
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.MessageTracking">
|
||||
<ul class="o_mail_thread_message_tracking">
|
||||
<t t-foreach='message.getTrackingValues()' t-as='value'>
|
||||
<li>
|
||||
<t t-esc="value.changed_field"/>:
|
||||
<t t-if="value.old_value">
|
||||
<span> <t t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> </span>
|
||||
<span t-if="value.old_value !== value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/>
|
||||
</t>
|
||||
<span t-if="value.old_value !== value.new_value">
|
||||
<t t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
|
||||
</span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Array} attachments
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.composer.Attachments">
|
||||
<div t-if="attachments.length > 0" class="o_attachments o_attachments_list">
|
||||
<t t-foreach="attachments" t-as='attachment'>
|
||||
<t t-call="im_livechat.legacy.mail.Attachment">
|
||||
<t t-set="editable" t-value="true"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Object} attachment
|
||||
@param {integer} attachment.id
|
||||
@param {string} attachment.name
|
||||
@param {string} attachment.url
|
||||
@param {boolean} [isDeletable=false]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.AttachmentPreview">
|
||||
<div class="o_attachment" t-att-title="attachment.name">
|
||||
<div class="o_attachment_wrap">
|
||||
<div class="o_image_box">
|
||||
<div class="o_attachment_image" t-attf-style="background-image:url('/web/image/#{attachment.id}/160x160/?crop=true')"/>
|
||||
<div t-attf-class="o_image_overlay o_attachment_view" t-att-data-id="attachment.id">
|
||||
<span t-if="isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
|
||||
<span class="o_attachment_title text-white"><t t-esc="attachment.name"/></span>
|
||||
<a class="o_attachment_download" t-att-href='attachment.url'>
|
||||
<i t-attf-class="fa fa-download text-white" t-att-title="'Download ' + attachment.name" role="img" aria-label="Download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Object} attachment
|
||||
@param {string} attachment.filename
|
||||
@param {integer} attachment.id
|
||||
@param {string} [attachment.mimetype]
|
||||
@param {string} attachment.name
|
||||
@param {boolean} attachment.upload
|
||||
@param {string} attachment.url
|
||||
@param {boolean} [editable=false] if set, it means the attachment is rendered in the composer.
|
||||
Some changes are required in that case, such as "delete" button is not visible (pretty unlink is used instead).
|
||||
@param {boolean} [isDeletable=false]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.Attachment">
|
||||
<t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/>
|
||||
<div t-attf-class="o_attachment #{ editable ? 'o_attachment_editable' : '' } #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name">
|
||||
<div class="o_attachment_wrap">
|
||||
<span t-if="!editable and isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
|
||||
<t t-set="has_preview" t-value="type == 'image' or type == 'video' or attachment.mimetype == 'application/pdf'"/>
|
||||
<t t-set="ext" t-value="attachment.filename.split('.').pop()"/>
|
||||
|
||||
<div t-attf-class="o_image_box float-left #{has_preview ? 'o_attachment_view' : ''}" t-att-data-id="attachment.id">
|
||||
<div t-if="has_preview"
|
||||
class="o_image o_hover"
|
||||
t-att-style="type == 'image' ? 'background-image:url(/web/image/' + attachment.id + '/38x38/?crop=true' : '' "
|
||||
t-att-data-mimetype="attachment.mimetype">
|
||||
</div>
|
||||
<a t-elif="!editable" t-att-href='attachment.url' t-att-title="'Download ' + attachment.name" aria-label="Download">
|
||||
<span class="o_image o_hover" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext"/>
|
||||
</a>
|
||||
<span t-else="" class="o_image" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext" role="img" aria-label="Document not downloadable"/>
|
||||
</div>
|
||||
|
||||
<div class="caption">
|
||||
<span t-if="has_preview or editable" t-attf-class="ml4 #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc='attachment.name'/></span>
|
||||
<a t-else="" class="ml4" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><t t-esc='attachment.name'/></a>
|
||||
</div>
|
||||
<div t-if="editable" class="caption small">
|
||||
<b t-attf-class="ml4 small text-uppercase #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
|
||||
<div class="progress o_attachment_progress_bar">
|
||||
<div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!editable" class="caption small">
|
||||
<b t-if="has_preview" class="ml4 small text-uppercase o_attachment_view" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
|
||||
<a t-else="" class="ml4 small text-uppercase" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><b><t t-esc='ext'/></b></a>
|
||||
<a class="ml4 o_attachment_download float-right" t-att-title="'Download ' + attachment.name" t-att-href='attachment.url'><i t-attf-class="fa fa-download" role="img" aria-label="Download"/></a>
|
||||
</div>
|
||||
<div t-if="editable" class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
|
||||
<div t-if="editable" class="o_attachment_delete" t-att-data-id="attachment.id"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Object} options
|
||||
@param {boolean} [options.loadMoreOnScroll]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.LoadMore">
|
||||
<div class="o_thread_show_more">
|
||||
<t t-if="options.loadMoreOnScroll">
|
||||
<span><i class="fa fa-spinner fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-link">-------- Show older messages --------</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.Thread} thread with typing feature
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.ThreadTypingIcon">
|
||||
<span class="o_mail_thread_typing_icon" t-att-title="thread.getTypingMembersToText()">
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,29 @@
|
||||
odoo.define('im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { registerInstancePatchModel } = require('mail/static/src/model/model_core.js');
|
||||
|
||||
registerInstancePatchModel('mail.messaging_initializer', 'im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object[]} [param0.channel_livechat=[]]
|
||||
*/
|
||||
_initChannels(initMessagingData) {
|
||||
this._super(initMessagingData);
|
||||
const { channel_livechat = [] } = initMessagingData;
|
||||
for (const data of channel_livechat) {
|
||||
this.env.models['mail.thread'].insert(Object.assign(
|
||||
{},
|
||||
this.env.models['mail.thread'].convertData(data),
|
||||
{ isPinned: true }
|
||||
));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
odoo.define('im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { registerInstancePatchModel } = require('mail/static/src/model/model_core.js');
|
||||
|
||||
registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js', {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @param {integer} param0.partner_id
|
||||
*/
|
||||
_handleNotificationChannelTypingStatus(data) {
|
||||
const { channelId, partner_id } = data;
|
||||
const channel = this.env.models['mail.thread'].insert({
|
||||
id: channelId,
|
||||
model: 'mail.channel',
|
||||
});
|
||||
let partnerId;
|
||||
if (partner_id === this.env.messaging.publicPartner.id) {
|
||||
// Some shenanigans that this is a typing notification
|
||||
// from public partner.
|
||||
partnerId = channel.correspondent.id;
|
||||
} else {
|
||||
partnerId = partner_id;
|
||||
}
|
||||
this._super(Object.assign(data, {
|
||||
partner_id: partnerId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
odoo.define('im_livechat/static/src/models/thread/thread.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
registerClassPatchModel,
|
||||
registerInstancePatchModel,
|
||||
} = require('mail/static/src/model/model_core.js');
|
||||
|
||||
registerClassPatchModel('mail.thread', 'im_livechat/static/src/models/thread/thread.js', {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = this._super(data);
|
||||
if ('livechat_visitor' in data) {
|
||||
if (!data2.members) {
|
||||
data2.members = [];
|
||||
}
|
||||
if (!data.livechat_visitor.id) {
|
||||
// Create partner derived from public partner.
|
||||
const partner = this.env.models['mail.partner'].create(
|
||||
Object.assign(
|
||||
this.env.models['mail.partner'].convertData(data.livechat_visitor),
|
||||
{ id: this.env.models['mail.partner'].getNextPublicId() }
|
||||
)
|
||||
);
|
||||
data2.correspondent = [['link', partner]];
|
||||
data2.members.push(['link', partner]);
|
||||
} else {
|
||||
const partnerData = this.env.models['mail.partner'].convertData(data.livechat_visitor);
|
||||
data2.correspondent = [['insert', partnerData]];
|
||||
data2.members.push(['insert', partnerData]);
|
||||
}
|
||||
}
|
||||
return data2;
|
||||
},
|
||||
});
|
||||
|
||||
registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/thread.js', {
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_computeDisplayName() {
|
||||
if (this.channel_type === 'livechat' && this.correspondent) {
|
||||
return this.correspondent.nameOrDisplayName;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
.o_livechat_button {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
right: 6px;
|
||||
min-width: 100px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background-color: rgba(60, 60, 60, 0.6);
|
||||
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, Verdana, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
color: white;
|
||||
text-shadow: rgb(59, 76, 88) 1px 1px 0px;
|
||||
border: 1px solid rgb(80, 80, 80);
|
||||
border-bottom: 0px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.o_thread_window {
|
||||
z-index: 1002; // to go over the navbar
|
||||
.o_thread_date_separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_livechat_rating {
|
||||
/* Livechat Rating : feedback smiley */
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
font-size: 14px;
|
||||
|
||||
.o_livechat_email {
|
||||
font-size: 12px;
|
||||
> div {
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_no_feedback {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_box {
|
||||
margin: 40px 0 30px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_choices {
|
||||
margin: 10px 0;
|
||||
|
||||
> img {
|
||||
width: 65px;
|
||||
opacity: 0.60;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
&:hover, &.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* feedback reason */
|
||||
.o_livechat_rating_reason {
|
||||
margin: 10px 0;
|
||||
display: none; /* hidden by default */
|
||||
|
||||
> textarea {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_rating_reason_button > input {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_operator_avatar {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.o_livechat_no_rating {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="im_livechat.FeedBack">
|
||||
<div class="o_livechat_rating text-center">
|
||||
<div class="o_livechat_rating_box">
|
||||
<div class="o_livechat_rating_feedback_text">
|
||||
Did we correctly answer your question ?
|
||||
</div>
|
||||
<div class="o_livechat_rating_choices">
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_5.png'" alt="Good" data-value="5"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_3.png'" alt="OK" data-value="3"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_1.png'" alt="Bad" data-value="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_rating_reason">
|
||||
<textarea id="reason" placeholder="Explain your note"></textarea>
|
||||
<div class="o_livechat_rating_reason_button">
|
||||
<input type="button" class="btn btn-primary btn-sm o_rating_submit_button" value="Send" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_email">
|
||||
<span class="text-muted">Receive a copy of this conversation</span>
|
||||
<div class="input-group">
|
||||
<input id="o_email" type="text" class="form-control" placeholder="mail@example.com"/>
|
||||
<button type="button" class="o_email_chat_button btn btn-primary rounded-0">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger px-0 o_livechat_email_error" style="display: none;" role="alert">
|
||||
Oops! Something went wrong.<br />Please check your internet connection.<br />
|
||||
<a href="#" class="alert-link">Try again</a>
|
||||
</div>
|
||||
<div class="o_livechat_no_feedback text-muted">
|
||||
<span>Close conversation</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<template>
|
||||
|
||||
<t t-extend="mail.discuss.SidebarChannels">
|
||||
<t t-jquery="t[t-call='mail.discuss.SidebarItems']:last" t-operation="after">
|
||||
<t t-set="type" t-value="'livechat'"/>
|
||||
<t t-set="disableAddThread" t-value="true"/>
|
||||
<t t-call="mail.discuss.SidebarTitle">
|
||||
<t t-set="title">Livechat</t>
|
||||
<t t-set="icon" t-value="fa-comments"/>
|
||||
</t>
|
||||
<t t-call="mail.discuss.SidebarItems"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Mobile templates -->
|
||||
<t t-extend="mail.discuss_mobile">
|
||||
<t t-jquery=".o_mail_mobile_tabs" t-operation="append">
|
||||
<div class="o_mail_mobile_tab" data-type="livechat">
|
||||
<span class="fa fa-comments"/>
|
||||
<span class="o_tab_title">Livechat</span>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</template>
|
||||
@@ -1,74 +0,0 @@
|
||||
odoo.define('im_livechat.discuss_test', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
|
||||
var createDiscuss = mailTestUtils.createDiscuss;
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('Discuss', {
|
||||
beforeEach: function () {
|
||||
// patch _.debounce and _.throttle to be fast and synchronous
|
||||
this.underscoreDebounce = _.debounce;
|
||||
this.underscoreThrottle = _.throttle;
|
||||
_.debounce = _.identity;
|
||||
_.throttle = _.identity;
|
||||
|
||||
this.data = {
|
||||
'mail.message': {
|
||||
fields: {},
|
||||
},
|
||||
};
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
},
|
||||
afterEach: function () {
|
||||
// unpatch _.debounce and _.throttle
|
||||
_.debounce = this.underscoreDebounce;
|
||||
_.throttle = this.underscoreThrottle;
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('pinnned livechat in the sidebar', function (assert) {
|
||||
assert.expect(6);
|
||||
var done = assert.async();
|
||||
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_livechat: [{
|
||||
id: 1,
|
||||
channel_type: "livechat",
|
||||
correspondent_name: "Visitor",
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
createDiscuss({
|
||||
id: 1,
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
}).then(function (discuss) {
|
||||
var $sidebar = discuss.$('.o_mail_discuss_sidebar');
|
||||
assert.strictEqual($sidebar.length, 1,
|
||||
"should have rendered a sidebar");
|
||||
assert.containsOnce($sidebar, '.o_mail_discuss_sidebar_channels',
|
||||
"should display channels in the sidebar");
|
||||
|
||||
var $channels = $sidebar.find('.o_mail_discuss_sidebar_channels');
|
||||
assert.ok($channels.find('.o_mail_sidebar_title h4').text().indexOf('Livechat'),
|
||||
"should have a channel category named 'Livechat'");
|
||||
assert.containsOnce($channels, '.o_mail_discuss_item[data-thread-id=1]',
|
||||
"should have a channel sidebar item with thread ID 1");
|
||||
|
||||
var $livechat = $channels.find('.o_mail_discuss_item[data-thread-id=1]');
|
||||
assert.hasAttrValue($livechat, 'title', "Visitor");
|
||||
assert.strictEqual($livechat.find('.o_thread_name').text().trim(), "Visitor");
|
||||
|
||||
discuss.destroy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
odoo.define('im_livechat.websiteLivechatWindowTest', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ChatWindow = require('im_livechat.WebsiteLivechatWindow');
|
||||
const Livechat = require('im_livechat.model.WebsiteLivechat');
|
||||
const Message = require('im_livechat.model.WebsiteLivechatMessage');
|
||||
|
||||
const testUtils = require('web.test_utils');
|
||||
const Widget = require('web.Widget');
|
||||
|
||||
/**
|
||||
* Important: the rendering of the website livechat window is not exactly the
|
||||
* same as on the website side. This is due to some template extensions from
|
||||
* thread windows in the backend. In particular, the 'expand' button should not
|
||||
* appear on a website livechat window.
|
||||
*/
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('Website Livechat Window', {});
|
||||
|
||||
QUnit.test('basic rendering', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var parent = new Widget();
|
||||
var livechat = new Livechat({
|
||||
parent: parent,
|
||||
data: {
|
||||
id: 5,
|
||||
message_unread_counter: 2,
|
||||
name: 'myLivechat',
|
||||
operator_pid: [1, "YourOperator"],
|
||||
}
|
||||
});
|
||||
|
||||
var messages = [];
|
||||
for (var i = 0; i < 2; i++) {
|
||||
messages.push(new Message(parent, {
|
||||
id: i,
|
||||
body: "<p>test" + i + "</p>"
|
||||
}, {
|
||||
default_username: 'defaultUser',
|
||||
serverUrl: 'serverUrl',
|
||||
}));
|
||||
}
|
||||
|
||||
livechat.setMessages(messages);
|
||||
|
||||
var chatWindow = new ChatWindow(parent, livechat);
|
||||
await testUtils.mock.addMockEnvironment(parent, {});
|
||||
await chatWindow.appendTo($('#qunit-fixture'));
|
||||
chatWindow.render();
|
||||
|
||||
assert.containsOnce(chatWindow, '.o_thread_window_header',
|
||||
"should have a header");
|
||||
assert.strictEqual(chatWindow.$('.o_thread_window_header').text().replace(/\s/g, ""),
|
||||
"myLivechat(2)",
|
||||
"should display the correct livechat name and unread message counter");
|
||||
assert.containsN(chatWindow, '.o_thread_message', 2,
|
||||
"should display two messages");
|
||||
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('LiveChat Tests', {});
|
||||
|
||||
QUnit.test('define several livechat colors', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const parent = new Widget();
|
||||
const livechat = new Livechat({
|
||||
parent,
|
||||
data: {
|
||||
id: 5,
|
||||
message_unread_counter: 2,
|
||||
name: "myLivechat",
|
||||
operator_pid: [1, "YourOperator"],
|
||||
}
|
||||
});
|
||||
livechat.setMessages([
|
||||
new Message(parent, {
|
||||
id: 1,
|
||||
body: "<p>test1</p>"
|
||||
}, {
|
||||
default_username: "defaultUser",
|
||||
serverUrl: "serverUrl",
|
||||
}),
|
||||
new Message(parent, {
|
||||
id: 2,
|
||||
body: "<p>test2</p>"
|
||||
}, {
|
||||
default_username: "defaultUser",
|
||||
serverUrl: "serverUrl",
|
||||
}),
|
||||
]);
|
||||
const chatWindow = new ChatWindow(parent, livechat, {
|
||||
headerBackgroundColor: 'yellow',
|
||||
titleColor: 'green',
|
||||
});
|
||||
await testUtils.mock.addMockEnvironment(parent, {});
|
||||
await chatWindow.appendTo($('#qunit-fixture'));
|
||||
chatWindow.render();
|
||||
assert.strictEqual(
|
||||
chatWindow.$('.o_thread_window_header').css('background-color'),
|
||||
'rgb(255, 255, 0)',
|
||||
"header background color should be yellow");
|
||||
assert.strictEqual(
|
||||
chatWindow.$('.o_thread_window_header').css('color'),
|
||||
'rgb(0, 128, 0)',
|
||||
"header text color should be green");
|
||||
|
||||
parent.destroy();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -52,10 +52,6 @@ class TestGetMailChannel(TransactionCase):
|
||||
channel_operator_ids = [channel_operator[0] for channel_operator in channel_operators]
|
||||
self.assertTrue(all(partner_id in channel_operator_ids for partner_id in self.operators.mapped('partner_id').ids))
|
||||
|
||||
visitor_user_channel = self.livechat_channel._open_livechat_mail_channel('Visitor', user_id=self.visitor_user.id)
|
||||
chat_title = '%s (%s)' % (self.visitor_user.display_name, self.visitor_user.country_id.name)
|
||||
self.assertEqual(visitor_user_channel['correspondent_name'], chat_title, "Chat title should be correct and should contain visitor's country name")
|
||||
|
||||
def _open_livechat_mail_channel(self):
|
||||
mail_channels = []
|
||||
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
-->
|
||||
<template id="assets_backend" name="im_livechat assets backend" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat_backend.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat_channel_form_view.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat_channel_form_controller.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/bugfix/bugfix.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/discuss/discuss.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/notification_list/notification_list.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/thread_preview/thread_preview.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/models/thread/thread.js"></script>
|
||||
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/bugfix/bugfix.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat_history.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat_form.scss"/>
|
||||
</xpath>
|
||||
@@ -17,12 +28,11 @@
|
||||
|
||||
<template id="qunit_suite" name="livechat_tests" inherit_id="web.qunit_suite_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/website_livechat_window.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat_message.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/im_livechat/static/tests/discuss_livechat_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/tests/website_livechat_window_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/bugfix/bugfix_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/discuss/discuss_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/thread_icon/thread_icon_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/legacy/public_livechat.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -160,35 +170,13 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/services/ajax_service.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/services/local_storage_service.js"></script>
|
||||
<!-- Bus, Mail, Livechat -->
|
||||
<!-- thread windows -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/website_livechat_window.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/thread_widget.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
|
||||
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
|
||||
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/im_livechat.js"></script>
|
||||
<!-- Models -->
|
||||
<!-- threads -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/threads/abstract_thread.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js"></script>
|
||||
<!-- messages -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/messages/abstract_message.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/js/models/website_livechat_message.js"></script>
|
||||
<!-- utils -->
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/utils/timer.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/utils/timers.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/models/utils/cc_throttle_function.js"></script>
|
||||
<script type="text/javascript" src="/im_livechat/static/src/legacy/public_livechat.js"></script>
|
||||
<t t-call="web._assets_helpers"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat_bootstrap.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/abstract_thread_window.scss"></link>
|
||||
<link rel="stylesheet" type="text/scss" href="/mail/static/src/scss/thread.scss"></link>
|
||||
<link rel="stylesheet" type="text/scss" href="/im_livechat/static/src/scss/im_livechat.scss"/>
|
||||
</template>
|
||||
|
||||
<!-- the js code to initialize the LiveSupport object -->
|
||||
@@ -206,7 +194,7 @@
|
||||
odoo.define('im_livechat.livesupport', function (require) {
|
||||
<t t-if="info['available']" t-translation="off">
|
||||
var rootWidget = require('root.widget');
|
||||
var im_livechat = require('im_livechat.im_livechat');
|
||||
var im_livechat = require('im_livechat.legacy.im_livechat.im_livechat');
|
||||
var button = new im_livechat.LivechatButton(
|
||||
rootWidget,
|
||||
"<t t-esc="info['server_url']"/>",
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -51,16 +51,69 @@
|
||||
'static/src/xml/activity.xml',
|
||||
'static/src/xml/activity_view.xml',
|
||||
'static/src/xml/composer.xml',
|
||||
'static/src/xml/chatter.xml',
|
||||
'static/src/xml/discuss.xml',
|
||||
'static/src/xml/followers.xml',
|
||||
'static/src/xml/many2one_avatar_user.xml',
|
||||
'static/src/xml/systray.xml',
|
||||
'static/src/xml/out_of_office.xml',
|
||||
'static/src/xml/thread.xml',
|
||||
'static/src/xml/abstract_thread_window.xml',
|
||||
'static/src/xml/thread_window.xml',
|
||||
'static/src/xml/web_kanban_activity.xml',
|
||||
'static/src/xml/text_emojis.xml',
|
||||
|
||||
'static/src/bugfix/bugfix.xml',
|
||||
'static/src/components/activity/activity.xml',
|
||||
'static/src/components/activity_box/activity_box.xml',
|
||||
'static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml',
|
||||
'static/src/components/attachment/attachment.xml',
|
||||
'static/src/components/attachment_box/attachment_box.xml',
|
||||
'static/src/components/attachment_list/attachment_list.xml',
|
||||
'static/src/components/attachment_viewer/attachment_viewer.xml',
|
||||
'static/src/components/autocomplete_input/autocomplete_input.xml',
|
||||
'static/src/components/chat_window/chat_window.xml',
|
||||
'static/src/components/chat_window_header/chat_window_header.xml',
|
||||
'static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml',
|
||||
'static/src/components/chat_window_manager/chat_window_manager.xml',
|
||||
'static/src/components/chatter/chatter.xml',
|
||||
'static/src/components/chatter_container/chatter_container.xml',
|
||||
'static/src/components/chatter_topbar/chatter_topbar.xml',
|
||||
'static/src/components/composer/composer.xml',
|
||||
'static/src/components/composer_text_input/composer_text_input.xml',
|
||||
'static/src/components/dialog/dialog.xml',
|
||||
'static/src/components/dialog_manager/dialog_manager.xml',
|
||||
'static/src/components/discuss/discuss.xml',
|
||||
'static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml',
|
||||
'static/src/components/discuss_sidebar/discuss_sidebar.xml',
|
||||
'static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml',
|
||||
'static/src/components/drop_zone/drop_zone.xml',
|
||||
'static/src/components/editable_text/editable_text.xml',
|
||||
'static/src/components/emojis_popover/emojis_popover.xml',
|
||||
'static/src/components/file_uploader/file_uploader.xml',
|
||||
'static/src/components/follow_button/follow_button.xml',
|
||||
'static/src/components/follower/follower.xml',
|
||||
'static/src/components/follower_list_menu/follower_list_menu.xml',
|
||||
'static/src/components/follower_subtype/follower_subtype.xml',
|
||||
'static/src/components/follower_subtype_list/follower_subtype_list.xml',
|
||||
'static/src/components/mail_template/mail_template.xml',
|
||||
'static/src/components/message/message.xml',
|
||||
'static/src/components/message_author_prefix/message_author_prefix.xml',
|
||||
'static/src/components/message_list/message_list.xml',
|
||||
'static/src/components/message_seen_indicator/message_seen_indicator.xml',
|
||||
'static/src/components/messaging_menu/messaging_menu.xml',
|
||||
'static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml',
|
||||
'static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml',
|
||||
'static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml',
|
||||
'static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml',
|
||||
'static/src/components/notification_group/notification_group.xml',
|
||||
'static/src/components/notification_list/notification_list.xml',
|
||||
'static/src/components/notification_popover/notification_popover.xml',
|
||||
'static/src/components/partner_im_status_icon/partner_im_status_icon.xml',
|
||||
'static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml',
|
||||
'static/src/components/thread_icon/thread_icon.xml',
|
||||
'static/src/components/thread_needaction_preview/thread_needaction_preview.xml',
|
||||
'static/src/components/thread_preview/thread_preview.xml',
|
||||
'static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml',
|
||||
'static/src/components/thread_typing_icon/thread_typing_icon.xml',
|
||||
'static/src/components/thread_viewer/thread_viewer.xml',
|
||||
'static/src/widgets/common.xml',
|
||||
'static/src/widgets/discuss/discuss.xml',
|
||||
'static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml',
|
||||
'static/src/widgets/messaging_menu/messaging_menu.xml',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -272,6 +272,8 @@ class MailController(http.Controller):
|
||||
'is_moderator': request.env.user.is_moderator,
|
||||
'moderation_counter': request.env.user.moderation_counter,
|
||||
'moderation_channel_ids': request.env.user.moderation_channel_ids.ids,
|
||||
'partner_root': request.env.ref('base.partner_root').sudo().mail_partner_format(),
|
||||
'public_partner': request.env.ref('base.public_partner').sudo().mail_partner_format(),
|
||||
}
|
||||
return values
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
<p>Hello <t t-esc='record.name'/></p>
|
||||
<p>You have messages to moderate, please go for the proceedings.</p><br/><br/>
|
||||
<div style="text-align: center;">
|
||||
<a href="/web#action=mail.action_discuss&active_id=mailbox_moderation" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Moderate Messages</a>
|
||||
<a href="/web#action=mail.action_discuss&active_id=mail.box_moderation" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Moderate Messages</a>
|
||||
<br/><br/><br/>
|
||||
</div>
|
||||
<p>Thank you!</p>
|
||||
|
||||
@@ -617,7 +617,7 @@ class MailActivityMixin(models.AbstractModel):
|
||||
Activities come with a new JS widget for the form view. It is integrated in the
|
||||
Chatter widget although it is a separate widget. It displays activities linked
|
||||
to the current record and allow to schedule, edit and mark done activities.
|
||||
Use widget="mail_activity" on activity_ids field in form view to use it.
|
||||
Just include field activity_ids in the div.oe-chatter to use it.
|
||||
|
||||
There is also a kanban widget defined. It defines a small widget to integrate
|
||||
in kanban vignettes. It allow to manage activities directly from the kanban
|
||||
|
||||
@@ -597,6 +597,7 @@ class Channel(models.Model):
|
||||
partner_ids = channel_partners.mapped('partner_id').ids
|
||||
info['members'] = [partner_infos[partner] for partner in partner_ids]
|
||||
info['seen_partners_info'] = [{
|
||||
'id': cp.id,
|
||||
'partner_id': cp.partner_id.id,
|
||||
'fetched_message_id': cp.fetched_message_id.id,
|
||||
'seen_message_id': cp.seen_message_id.id,
|
||||
@@ -731,6 +732,7 @@ class Channel(models.Model):
|
||||
'fetched_message_id': last_message_id,
|
||||
})
|
||||
data = {
|
||||
'id': channel_partner.id,
|
||||
'info': 'channel_seen',
|
||||
'last_message_id': last_message_id,
|
||||
'partner_id': self.env.user.partner_id.id,
|
||||
@@ -760,6 +762,7 @@ class Channel(models.Model):
|
||||
'fetched_message_id': last_message_id,
|
||||
})
|
||||
data = {
|
||||
'id': channel_partner.id,
|
||||
'info': 'channel_fetched',
|
||||
'last_message_id': last_message_id,
|
||||
'partner_id': self.env.user.partner_id.id,
|
||||
@@ -798,20 +801,15 @@ class Channel(models.Model):
|
||||
'custom_channel_name': name,
|
||||
})
|
||||
|
||||
def notify_typing(self, is_typing, is_website_user=False):
|
||||
def notify_typing(self, is_typing):
|
||||
""" Broadcast the typing notification to channel members
|
||||
:param is_typing: (boolean) tells whether the current user is typing or not
|
||||
:param is_website_user: (boolean) tells whether the user that notifies comes
|
||||
from the website-side. This is useful in order to distinguish operator and
|
||||
unlogged users for livechat, because unlogged users have the same
|
||||
partner_id as the admin (default: False).
|
||||
"""
|
||||
notifications = []
|
||||
for channel in self:
|
||||
data = {
|
||||
'info': 'typing_status',
|
||||
'is_typing': is_typing,
|
||||
'is_website_user': is_website_user,
|
||||
'partner_id': self.env.user.partner_id.id,
|
||||
}
|
||||
notifications.append([(self._cr.dbname, 'mail.channel', channel.id), data]) # notify backend users
|
||||
|
||||
@@ -969,7 +969,9 @@ class Message(models.Model):
|
||||
'filename': attachment.name,
|
||||
'name': attachment.name,
|
||||
'mimetype': 'application/octet-stream' if safari and attachment.mimetype and 'video' in attachment.mimetype else attachment.mimetype,
|
||||
'is_main': main_attachment == attachment
|
||||
'is_main': main_attachment == attachment,
|
||||
'res_id': attachment.res_id,
|
||||
'res_model': attachment.res_model,
|
||||
})
|
||||
|
||||
# Tracking values
|
||||
@@ -1111,17 +1113,15 @@ class Message(models.Model):
|
||||
Notifications hold the information about each recipient of a message: if
|
||||
the message was successfully sent or if an exception or bounce occurred.
|
||||
"""
|
||||
return {
|
||||
message.id: {
|
||||
'message_id': message.id,
|
||||
'model_name': message.env['ir.model']._get(message.model).display_name,
|
||||
'res_id': message.res_id,
|
||||
'model': message.model,
|
||||
'last_message_date': message.date,
|
||||
'message_type': message.message_type,
|
||||
'notifications': message.notification_ids._filtered_for_web_client()._notification_format(),
|
||||
} for message in self
|
||||
}
|
||||
return [{
|
||||
'id': message.id,
|
||||
'res_id': message.res_id,
|
||||
'model': message.model,
|
||||
'res_model_name': message.env['ir.model']._get(message.model).display_name,
|
||||
'date': message.date,
|
||||
'message_type': message.message_type,
|
||||
'notifications': message.notification_ids._filtered_for_web_client()._notification_format(),
|
||||
} for message in self]
|
||||
|
||||
def _notify_message_notification_update(self):
|
||||
"""Send bus notifications to update status of notifications in the web
|
||||
|
||||
@@ -95,13 +95,10 @@ class MailNotification(models.Model):
|
||||
def _notification_format(self):
|
||||
"""Returns the current notifications in the format expected by the web
|
||||
client."""
|
||||
return {
|
||||
notif.id: {
|
||||
'notification_id': notif.id,
|
||||
'notification_type': notif.notification_type,
|
||||
'notification_status': notif.notification_status,
|
||||
'failure_type': notif.failure_type,
|
||||
'partner_id': notif.res_partner_id.id,
|
||||
'partner_name': notif.res_partner_id.display_name,
|
||||
} for notif in self
|
||||
}
|
||||
return [{
|
||||
'id': notif.id,
|
||||
'notification_type': notif.notification_type,
|
||||
'notification_status': notif.notification_status,
|
||||
'failure_type': notif.failure_type,
|
||||
'partner_id': [notif.res_partner_id.id, notif.res_partner_id.display_name],
|
||||
} for notif in self]
|
||||
|
||||
@@ -407,21 +407,6 @@ class MailThread(models.AbstractModel):
|
||||
|
||||
return help
|
||||
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
res = super(MailThread, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
if view_type == 'form':
|
||||
doc = etree.XML(res['arch'])
|
||||
for node in doc.xpath("//field[@name='message_ids']"):
|
||||
# the 'Log a note' button is employee only
|
||||
options = ast.literal_eval(node.get('options', '{}'))
|
||||
is_employee = self.env.user.has_group('base.group_user')
|
||||
options['display_log_button'] = is_employee
|
||||
# save options on the node
|
||||
node.set('options', repr(options))
|
||||
res['arch'] = etree.tostring(doc, encoding='unicode')
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Technical methods / wrappers / tools
|
||||
# ------------------------------------------------------
|
||||
|
||||
@@ -54,6 +54,14 @@ class Partner(models.Model):
|
||||
|
||||
return super(Partner, self).find_or_create(email, assert_valid_email=assert_valid_email)
|
||||
|
||||
def mail_partner_format(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"id": self.name_get()[0][0],
|
||||
"display_name": self.name_get()[0][1],
|
||||
"active": self.active,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_needaction_count(self):
|
||||
""" compute the number of needaction of the current user """
|
||||
|
||||
@@ -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('mail/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('mail/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('mail', {}, function () {
|
||||
QUnit.module('bugfix', {}, function () {
|
||||
QUnit.module('bugfix_tests.js', {
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
odoo.define('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useRef, useState, onMounted, onWillUnmount } = owl.hooks;
|
||||
|
||||
/**
|
||||
* This hook handle the visibility of the dropzone based on drag & drop events.
|
||||
* It needs a ref to a dropzone, so you need to specify a t-ref="dropzone" in
|
||||
* the template of your component.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
function useDragVisibleDropZone() {
|
||||
/**
|
||||
* Determine whether the drop zone should be visible or not.
|
||||
* Note that this is an observed value, and primitive types such as
|
||||
* boolean cannot be observed, hence this is an object with boolean
|
||||
* value accessible from `.value`
|
||||
*/
|
||||
const isVisible = useState({ value: false });
|
||||
const dropzoneRef = useRef('dropzone');
|
||||
|
||||
/**
|
||||
* Counts how many drag enter/leave happened globally. This is the only
|
||||
* way to know if a file has been dragged out of the browser window.
|
||||
*/
|
||||
let dragCount = 0;
|
||||
|
||||
// COMPONENTS HOOKS
|
||||
onMounted(() => {
|
||||
document.addEventListener('dragenter', _onDragenterListener, true);
|
||||
document.addEventListener('dragleave', _onDragleaveListener, true);
|
||||
document.addEventListener('drop', _onDropListener, true);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
document.removeEventListener('dragenter', _onDragenterListener, true);
|
||||
document.removeEventListener('dragleave', _onDragleaveListener, true);
|
||||
document.removeEventListener('drop', _onDropListener, true);
|
||||
});
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows the dropzone when entering the browser window, to let the user know
|
||||
* where he can drop its file.
|
||||
* Avoids changing state when entering inner dropzones.
|
||||
*
|
||||
* @private
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
function _onDragenterListener(ev) {
|
||||
if (dragCount === 0) {
|
||||
isVisible.value = true;
|
||||
}
|
||||
dragCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
function _onDragleaveListener(ev) {
|
||||
dragCount--;
|
||||
if (dragCount === 0) {
|
||||
isVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
function _onDropListener(ev) {
|
||||
dragCount = 0;
|
||||
if (dropzoneRef.comp && !dropzoneRef.comp.contains(ev.target)) {
|
||||
isVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
return useDragVisibleDropZone;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
odoo.define('mail/static/src/component_hooks/use_refs/use_refs.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
/**
|
||||
* This hook provides support for dynamic-refs.
|
||||
*
|
||||
* @returns {function} returns object whose keys are t-ref values of active refs.
|
||||
* and values are refs.
|
||||
*/
|
||||
function useRefs() {
|
||||
const component = Component.current;
|
||||
return function () {
|
||||
return component.__owl__.refs || {};
|
||||
};
|
||||
}
|
||||
|
||||
return useRefs;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
odoo.define('mail/static/src/component_hooks/use_store/use_store.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Similar to owl.hooks.useStore but to decide if a new render has to be done it
|
||||
* compares the keys on the result, with an optional level of depth for each
|
||||
* key, given as options `compareDepth`.
|
||||
*
|
||||
* It assumes that the result of the selector is always an object (or array).
|
||||
*
|
||||
* @param {function} selector function passed as selector of original `useStore`
|
||||
* with 1st parameter extended as store state. @see owl.hooks.useStore
|
||||
* @param {object} [options={}] @see owl.hooks.useStore
|
||||
* @param {number|object} [options.compareDepth=0] the comparison depth, either
|
||||
* as number (applies to all keys) or as an object (depth for specific keys)
|
||||
* @returns {Proxy} @see owl.hooks.useStore
|
||||
*/
|
||||
function useStore(selector, options = {}) {
|
||||
const store = options.store || owl.Component.current.env.store;
|
||||
const hashFn = store.observer.revNumber.bind(store.observer);
|
||||
const isEqual = options.isEqual || ((a, b) => a === b);
|
||||
|
||||
/**
|
||||
* Returns a function comparing whether two values are the same, which is just
|
||||
* calling `isEqual` on primitive values and objects, but which also works for
|
||||
* Owl Proxy in a temporal way: the current result of hashFn is compared to the
|
||||
* previous result of hashFn (from the last time the function was called).
|
||||
*
|
||||
* It means that when this function is given Proxy the first call will always
|
||||
* return false, and consecutive calls will not lead to the same result:
|
||||
* it returns true the first time after a change happended inside the Proxy, and
|
||||
* then always returns false until a new change is made.
|
||||
*
|
||||
* @returns {function} function taking two arguments, and comparing them as
|
||||
* explained above.
|
||||
*/
|
||||
function proxyComparator() {
|
||||
/**
|
||||
* It is important to locally save the old `revNumber` of each resulting
|
||||
* value because when the "old" and "new" values are the same proxy it is
|
||||
* impossible to compare them based on their current value (since it was
|
||||
* updated in "both" due to the fact it is a proxy in the first place).
|
||||
*
|
||||
* And if the values are not proxy, `revNumber` will be 0 and the `isEqual`
|
||||
* will be used to compare them.
|
||||
*/
|
||||
let oldRevNumber;
|
||||
|
||||
function compare(a, b) {
|
||||
let ok = true;
|
||||
const newRevNumber = hashFn(b);
|
||||
if (a === b && newRevNumber > 0) {
|
||||
ok = oldRevNumber === newRevNumber;
|
||||
} else {
|
||||
ok = isEqual(a, b);
|
||||
}
|
||||
oldRevNumber = newRevNumber;
|
||||
return ok;
|
||||
}
|
||||
|
||||
return compare;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see proxyComparator, but instead of comparing the given values, it compares
|
||||
* their respective keys, with `compareDepth` level of depth.
|
||||
* 0 = compare key, 1 = also compare subkeys, ...
|
||||
*
|
||||
* This assumes the given values are objects or arrays.
|
||||
*
|
||||
* @param {number|object} compareDepth the comparison depth, either as number
|
||||
* (applies to all keys) or as an object (depth for specific keys)
|
||||
* @returns {function}
|
||||
*/
|
||||
function proxyComparatorDeep(compareDepth = 0) {
|
||||
const comparator = proxyComparator();
|
||||
const comparators = {};
|
||||
|
||||
function compare(a, b) {
|
||||
// If a and b are (the same) proxy, it is already managing the depth
|
||||
// by itself, and a simple comparator can be used.
|
||||
if (a === b && hashFn(b) > 0) {
|
||||
return comparator(a, b);
|
||||
}
|
||||
let ok = true;
|
||||
const newKeys = Object.keys(b);
|
||||
if (!a || (Object.keys(a).length !== newKeys.length)) {
|
||||
ok = false;
|
||||
}
|
||||
for (const key of newKeys) {
|
||||
// the depth can be given either as a number (for all keys) or as
|
||||
// an object (for each key)
|
||||
let depth;
|
||||
if (typeof compareDepth === 'number') {
|
||||
depth = compareDepth;
|
||||
} else {
|
||||
depth = compareDepth[key] || 0;
|
||||
}
|
||||
if (!(key in comparators)) {
|
||||
if (depth > 0) {
|
||||
comparators[key] = proxyComparatorDeep(depth - 1);
|
||||
} else {
|
||||
comparators[key] = proxyComparator();
|
||||
}
|
||||
}
|
||||
// It is important to not break too early, the comparator has to
|
||||
// be called for every key to remember their current states.
|
||||
if (!comparators[key](a ? a[key] : a, b[key])) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
return compare;
|
||||
}
|
||||
|
||||
const extendedSelector = (state, props) => selector(props);
|
||||
return owl.hooks.useStore(extendedSelector, Object.assign({}, options, {
|
||||
isEqual: proxyComparatorDeep(options.compareDepth),
|
||||
}));
|
||||
}
|
||||
|
||||
return useStore;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
odoo.define('mail/static/src/component_hooks/use_store/use_store_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
const {
|
||||
afterNextRender,
|
||||
nextAnimationFrame,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
|
||||
const { Component, QWeb, Store } = owl;
|
||||
const { useGetters } = owl.hooks;
|
||||
const { xml } = owl.tags;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('component_hooks', {}, function () {
|
||||
QUnit.module('use_store', {}, function () {
|
||||
QUnit.module('use_store_tests.js', {
|
||||
beforeEach() {
|
||||
const qweb = new QWeb();
|
||||
this.env = { qweb };
|
||||
},
|
||||
afterEach() {
|
||||
this.env = undefined;
|
||||
this.store = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
QUnit.test("compare keys, no depth, primitives", async function (assert) {
|
||||
assert.expect(8);
|
||||
this.store = new Store({
|
||||
env: this.env,
|
||||
getters: {
|
||||
get({ state }, key) {
|
||||
return state[key];
|
||||
},
|
||||
},
|
||||
state: {
|
||||
obj: {
|
||||
subObj1: 'a',
|
||||
subObj2: 'b',
|
||||
use1: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.env.store = this.store;
|
||||
let count = 0;
|
||||
class MyComponent extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.storeGetters = useGetters();
|
||||
this.storeProps = useStore(props => {
|
||||
const obj = this.storeGetters.get('obj');
|
||||
return {
|
||||
res: obj.use1 ? obj.subObj1 : obj.subObj2,
|
||||
};
|
||||
}, {
|
||||
onUpdate: () => {
|
||||
count++;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.assign(MyComponent, {
|
||||
env: this.env,
|
||||
props: {},
|
||||
template: xml`<div t-esc="storeProps.res"/>`,
|
||||
});
|
||||
|
||||
const fixture = document.querySelector('#qunit-fixture');
|
||||
|
||||
const myComponent = new MyComponent();
|
||||
await myComponent.mount(fixture);
|
||||
assert.strictEqual(count, 0,
|
||||
'should not detect an update initially');
|
||||
assert.strictEqual(fixture.textContent, 'a',
|
||||
'should display the content of subObj1');
|
||||
|
||||
await afterNextRender(() => {
|
||||
this.store.state.obj.use1 = false;
|
||||
});
|
||||
assert.strictEqual(count, 1,
|
||||
'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)');
|
||||
assert.strictEqual(fixture.textContent, 'b',
|
||||
'should display the content of subObj2');
|
||||
|
||||
this.store.state.obj.subObj2 = 'b';
|
||||
// there must be no render here
|
||||
await nextAnimationFrame();
|
||||
assert.strictEqual(count, 1,
|
||||
'should not detect an update because the same primitive value was assigned (subObj2 was already "b")');
|
||||
assert.strictEqual(fixture.textContent, 'b',
|
||||
'should still display the content of subObj2');
|
||||
|
||||
await afterNextRender(() => {
|
||||
this.store.state.obj.subObj2 = 'd';
|
||||
});
|
||||
assert.strictEqual(count, 2,
|
||||
'should detect an update because the selector is returning a different value for subObj2');
|
||||
assert.strictEqual(fixture.textContent, 'd',
|
||||
'should display the new content of subObj2');
|
||||
|
||||
myComponent.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("compare keys, depth 1, proxy", async function (assert) {
|
||||
assert.expect(8);
|
||||
this.store = new Store({
|
||||
env: this.env,
|
||||
getters: {
|
||||
get({ state }, key) {
|
||||
return state[key];
|
||||
},
|
||||
},
|
||||
state: {
|
||||
obj: {
|
||||
subObj1: { a: 'a' },
|
||||
subObj2: { a: 'b' },
|
||||
use1: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.env.store = this.store;
|
||||
let count = 0;
|
||||
class MyComponent extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.storeGetters = useGetters();
|
||||
this.storeProps = useStore(props => {
|
||||
const obj = this.storeGetters.get('obj');
|
||||
return {
|
||||
array: [obj.use1 ? obj.subObj1 : obj.subObj2],
|
||||
};
|
||||
}, {
|
||||
compareDepth: {
|
||||
array: 1,
|
||||
},
|
||||
onUpdate: () => {
|
||||
count++;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.assign(MyComponent, {
|
||||
env: this.env,
|
||||
props: {},
|
||||
template: xml`<div t-esc="storeProps.array[0].a"/>`,
|
||||
});
|
||||
|
||||
const fixture = document.querySelector('#qunit-fixture');
|
||||
|
||||
const myComponent = new MyComponent();
|
||||
await myComponent.mount(fixture);
|
||||
assert.strictEqual(count, 0,
|
||||
'should not detect an update initially');
|
||||
assert.strictEqual(fixture.textContent, 'a',
|
||||
'should display the content of subObj1');
|
||||
|
||||
await afterNextRender(() => {
|
||||
this.store.state.obj.use1 = false;
|
||||
});
|
||||
assert.strictEqual(count, 1,
|
||||
'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)');
|
||||
assert.strictEqual(fixture.textContent, 'b',
|
||||
'should display the content of subObj2');
|
||||
|
||||
this.store.state.obj.subObj1.a = 'c';
|
||||
// there must be no render here
|
||||
await nextAnimationFrame();
|
||||
assert.strictEqual(count, 1,
|
||||
'should not detect an update because subObj1 was changed but only subObj2 is selected');
|
||||
assert.strictEqual(fixture.textContent, 'b',
|
||||
'should still display the content of subObj2');
|
||||
|
||||
await afterNextRender(() => {
|
||||
this.store.state.obj.subObj2.a = 'd';
|
||||
});
|
||||
assert.strictEqual(count, 2,
|
||||
'should detect an update because the value of subObj2 changed');
|
||||
assert.strictEqual(fixture.textContent, 'd',
|
||||
'should display the new content of subObj2');
|
||||
|
||||
myComponent.destroy();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
odoo.define('mail/static/src/components/activity/activity.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'),
|
||||
FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
|
||||
MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'),
|
||||
};
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
|
||||
const {
|
||||
auto_str_to_date,
|
||||
getLangDateFormat,
|
||||
getLangDatetimeFormat,
|
||||
} = require('web.time');
|
||||
|
||||
const { Component, useState } = owl;
|
||||
const { useRef } = owl.hooks;
|
||||
|
||||
class Activity extends Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.state = useState({
|
||||
areDetailsVisible: false,
|
||||
});
|
||||
useStore(props => {
|
||||
const activity = this.env.models['mail.activity'].get(props.activityLocalId);
|
||||
return {
|
||||
activity: activity ? activity.__state : undefined,
|
||||
assigneeNameOrDisplayName: (
|
||||
activity &&
|
||||
activity.assignee &&
|
||||
activity.assignee.nameOrDisplayName()
|
||||
),
|
||||
};
|
||||
});
|
||||
/**
|
||||
* Reference of the file uploader.
|
||||
* Useful to programmatically prompts the browser file uploader.
|
||||
*/
|
||||
this._fileUploaderRef = useRef('fileUploader');
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {mail.activity}
|
||||
*/
|
||||
get activity() {
|
||||
return this.env.models['mail.activity'].get(this.props.activityLocalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get assignedUserText() {
|
||||
return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get delayLabel() {
|
||||
const today = moment().startOf('day');
|
||||
const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
|
||||
// true means no rounding
|
||||
const diff = momentDeadlineDate.diff(today, 'days', true);
|
||||
if (diff === 0) {
|
||||
return this.env._t("Today:");
|
||||
} else if (diff === -1) {
|
||||
return this.env._t("Yesterday:");
|
||||
} else if (diff < 0) {
|
||||
return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff));
|
||||
} else if (diff === 1) {
|
||||
return this.env._t("Tomorrow:");
|
||||
} else {
|
||||
return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get formattedCreateDatetime() {
|
||||
const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate));
|
||||
const datetimeFormat = getLangDatetimeFormat();
|
||||
return momentCreateDate.format(datetimeFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get formattedDeadlineDate() {
|
||||
const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
|
||||
const datetimeFormat = getLangDateFormat();
|
||||
return momentDeadlineDate.format(datetimeFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get MARK_DONE() {
|
||||
return this.env._t("Mark Done");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get summary() {
|
||||
return _.str.sprintf(this.env._t("“%s”"), this.activity.summary);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
* @param {Object} ev.detail
|
||||
* @param {mail.attachment} ev.detail.attachment
|
||||
*/
|
||||
_onAttachmentCreated(ev) {
|
||||
this.activity.markAsDone({ attachments: [ev.detail.attachment] });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickCancel(ev) {
|
||||
ev.preventDefault();
|
||||
this.activity.deleteServerRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickDetailsButton() {
|
||||
this.state.areDetailsVisible = !this.state.areDetailsVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickEdit(ev) {
|
||||
this.activity.edit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickUploadDocument(ev) {
|
||||
this._fileUploaderRef.comp.openBrowserFileUploader();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(Activity, {
|
||||
components,
|
||||
props: {
|
||||
activityLocalId: String,
|
||||
},
|
||||
template: 'mail.Activity',
|
||||
});
|
||||
|
||||
return Activity;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Layout
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_Activity {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_Activity_detailsUserAvatar {
|
||||
margin-inline-end: map-get($spacers, 2);
|
||||
object-fit: cover;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.o_Activity_dueDateText, .o_Activity_summary {
|
||||
margin-inline-end: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_Activity_iconContainer {
|
||||
@include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.o_Activity_info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.o_Activity_sidebar {
|
||||
display: flex;
|
||||
flex: 0 0 36px;
|
||||
margin-right: map-get($spacers, 3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.o_Activity_toolButton {
|
||||
padding-top: map-get($spacers, 0);
|
||||
}
|
||||
|
||||
.o_Activity_tools {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.o_Activity_user {
|
||||
height: 36px;
|
||||
margin-left: map-get($spacers, 2);
|
||||
margin-right: map-get($spacers, 2);
|
||||
position: relative;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.o_Activity_userAvatar {
|
||||
height: map-get($sizes, 100);
|
||||
width: map-get($sizes, 100);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
$o-mail-activity-default-color: gray('300') !default;
|
||||
$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default;
|
||||
$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default;
|
||||
$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default;
|
||||
|
||||
|
||||
.o_Activity_deadlineDateText {
|
||||
&.o-default {
|
||||
color: $o-mail-activity-default-color;
|
||||
}
|
||||
|
||||
&.o-overdue {
|
||||
color: $o-mail-activity-overdue-color;
|
||||
}
|
||||
|
||||
&.o-planned {
|
||||
color: $o-mail-activity-planned-color;
|
||||
}
|
||||
|
||||
&.o-today {
|
||||
color: $o-mail-activity-today-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_Activity_details {
|
||||
color: gray('500');
|
||||
}
|
||||
|
||||
.o_Activity_detailsCreatorAvatar {
|
||||
margin-inline-start: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_Activity_detailsUserAvatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.o_Activity_dueDateText {
|
||||
font-weight: bolder;
|
||||
|
||||
&.o-default {
|
||||
color: $o-mail-activity-default-color;
|
||||
}
|
||||
|
||||
&.o-overdue {
|
||||
color: $o-mail-activity-overdue-color;
|
||||
}
|
||||
|
||||
&.o-planned {
|
||||
color: $o-mail-activity-planned-color;
|
||||
}
|
||||
|
||||
&.o-today {
|
||||
color: $o-mail-activity-today-color;
|
||||
}
|
||||
}
|
||||
|
||||
/* Needed specifity to counter default bootstrap style */
|
||||
a:not([href]):not([tabindex]).o_Activity_detailsButton {
|
||||
background: transparent;
|
||||
opacity: 0.5;
|
||||
color: gray('500');
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: gray('600');
|
||||
}
|
||||
}
|
||||
|
||||
.o_Activity_detailsCreator {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.o_Activity_iconContainer {
|
||||
color: white;
|
||||
border-color: white;
|
||||
border-radius: 100%;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.o_Activity_sidebar {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.o_Activity_summary {
|
||||
font-weight: bolder;
|
||||
color: gray('900');
|
||||
}
|
||||
|
||||
.o_Activity_toolButton {
|
||||
opacity: 0.5;
|
||||
color: gray('500');
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: gray('600');
|
||||
}
|
||||
}
|
||||
|
||||
.o_Activity_userAvatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.o_Activity_userName {
|
||||
color: gray('500');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.Activity" owl="1">
|
||||
<div class="o_Activity">
|
||||
<t t-if="activity">
|
||||
<div class="o_Activity_sidebar">
|
||||
<div class="o_Activity_user">
|
||||
<t t-if="activity.assignee">
|
||||
<img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName()"/>
|
||||
</t>
|
||||
<div class="o_Activity_iconContainer"
|
||||
t-att-class="{
|
||||
'bg-success-full': activity.state === 'planned',
|
||||
'bg-warning-full': activity.state === 'today',
|
||||
'bg-danger-full': activity.state === 'overdue',
|
||||
}"
|
||||
>
|
||||
<i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_Activity_core">
|
||||
<div class="o_Activity_info">
|
||||
<div class="o_Activity_dueDateText"
|
||||
t-att-class="{
|
||||
'o-default': activity.state === 'default',
|
||||
'o-overdue': activity.state === 'overdue',
|
||||
'o-planned': activity.state === 'planned',
|
||||
'o-today': activity.state === 'today',
|
||||
}"
|
||||
>
|
||||
<t t-esc="delayLabel"/>
|
||||
</div>
|
||||
<t t-if="activity.summary">
|
||||
<div class="o_Activity_summary">
|
||||
<t t-esc="summary"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="activity.type">
|
||||
<div class="o_Activity_summary o_Activity_type">
|
||||
<t t-esc="activity.type.displayName"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="activity.assignee">
|
||||
<div class="o_Activity_userName">
|
||||
<t t-esc="assignedUserText"/>
|
||||
</div>
|
||||
</t>
|
||||
<a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button">
|
||||
<i class="fa fa-info-circle" role="img" title="Info"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="o_Activity_details" t-if="state.areDetailsVisible">
|
||||
<dl class="dl-horizontal">
|
||||
<t t-if="activity.type">
|
||||
<dt>Activity type</dt>
|
||||
<dd class="o_Activity_type">
|
||||
<t t-esc="activity.type.displayName"/>
|
||||
</dd>
|
||||
</t>
|
||||
<t t-if="activity.creator">
|
||||
<dt>Created</dt>
|
||||
<dd class="o_Activity_detailsCreation">
|
||||
<t t-esc="formattedCreateDatetime"/>
|
||||
<img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName()" t-att-alt="activity.creator.nameOrDisplayName()"/>
|
||||
<span class="o_Activity_detailsCreator">
|
||||
<t t-esc="activity.creator.nameOrDisplayName()"/>
|
||||
</span>
|
||||
</dd>
|
||||
</t>
|
||||
<t t-if="activity.assignee">
|
||||
<dt>Assigned to</dt>
|
||||
<dd class="o_Activity_detailsAssignation">
|
||||
<img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName()" t-att-alt="activity.assignee.nameOrDisplayName()"/>
|
||||
<t t-esc="activity.assignee.nameOrDisplayName()"/>
|
||||
</dd>
|
||||
</t>
|
||||
<dt>Due on</dt>
|
||||
<dd class="o_Activity_detailsDueDate">
|
||||
<span class="o_Activity_deadlineDateText"
|
||||
t-att-class="{
|
||||
'o-default': activity.state === 'default',
|
||||
'o-overdue': activity.state === 'overdue',
|
||||
'o-planned': activity.state === 'planned',
|
||||
'o-today': activity.state === 'today',
|
||||
}"
|
||||
>
|
||||
<t t-esc="formattedDeadlineDate"/>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="o_Activity_note" t-if="activity.note">
|
||||
<t t-raw="activity.note"/>
|
||||
</div>
|
||||
|
||||
<t t-if="activity.mailTemplates.length > 0">
|
||||
<div class="o_Activity_mailTemplates">
|
||||
<t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId">
|
||||
<MailTemplate
|
||||
class="o_Activity_mailTemplate"
|
||||
activityLocalId="activity.localId"
|
||||
mailTemplateLocalId="mailTemplate.localId"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="activity.canWrite">
|
||||
<div name="tools" class="o_Activity_tools">
|
||||
<t t-if="activity.category !== 'upload_file'">
|
||||
<Popover position="'right'" title="MARK_DONE">
|
||||
<button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE">
|
||||
<i class="fa fa-check"/> Mark Done
|
||||
</button>
|
||||
<t t-set="opened">
|
||||
<ActivityMarkDonePopover activityLocalId="props.activityLocalId"/>
|
||||
</t>
|
||||
</Popover>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument">
|
||||
<i class="fa fa-upload"/> Upload Document
|
||||
</button>
|
||||
<FileUploader
|
||||
attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)"
|
||||
uploadId="activity.res_id"
|
||||
uploadModel="activity.res_model"
|
||||
t-on-o-attachment-created="_onAttachmentCreated"
|
||||
t-ref="fileUploader"
|
||||
/>
|
||||
</t>
|
||||
<button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit">
|
||||
<i class="fa fa-pencil"/> Edit
|
||||
</button>
|
||||
<button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" >
|
||||
<i class="fa fa-times"/> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,981 @@
|
||||
odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
Activity: require('mail/static/src/components/activity/activity.js'),
|
||||
};
|
||||
|
||||
const {
|
||||
afterEach: utilsAfterEach,
|
||||
afterNextRender,
|
||||
beforeEach: utilsBeforeEach,
|
||||
start: utilsStart,
|
||||
} = require('mail/static/src/utils/test_utils.js');
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
|
||||
const Bus = require('web.Bus');
|
||||
const { date_to_str } = require('web.time');
|
||||
|
||||
const { Component, tags: { xml } } = owl;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('activity', {}, function () {
|
||||
QUnit.module('activity_tests.js', {
|
||||
beforeEach() {
|
||||
utilsBeforeEach(this);
|
||||
|
||||
this.createActivityComponent = async function (activity) {
|
||||
const ActivityComponent = components.Activity;
|
||||
ActivityComponent.env = this.env;
|
||||
this.component = new ActivityComponent(null, {
|
||||
activityLocalId: activity.localId,
|
||||
});
|
||||
await this.component.mount(this.widget.el);
|
||||
};
|
||||
|
||||
this.start = async params => {
|
||||
let { env, widget } = await utilsStart(Object.assign({}, params, {
|
||||
data: this.data,
|
||||
}));
|
||||
this.env = env;
|
||||
this.widget = widget;
|
||||
};
|
||||
},
|
||||
afterEach() {
|
||||
utilsAfterEach(this);
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
if (this.widget) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
this.env = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('activity simplest layout', async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
await this.start();
|
||||
const activity = this.env.models['mail.activity'].create();
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_sidebar').length,
|
||||
1,
|
||||
"should have activity sidebar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_core').length,
|
||||
1,
|
||||
"should have activity core"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_user').length,
|
||||
1,
|
||||
"should have activity user"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_info').length,
|
||||
1,
|
||||
"should have activity info"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_note').length,
|
||||
0,
|
||||
"should not have activity note"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details').length,
|
||||
0,
|
||||
"should not have activity details"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_mailTemplates').length,
|
||||
0,
|
||||
"should not have activity mail templates"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_editButton').length,
|
||||
0,
|
||||
"should not have activity Edit button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_cancelButton').length,
|
||||
0,
|
||||
"should not have activity Cancel button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_markDoneButton').length,
|
||||
0,
|
||||
"should not have activity Mark as Done button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_uploadButton').length,
|
||||
0,
|
||||
"should not have activity Upload button"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity with note layout', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await this.start();
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
note: 'There is no good or bad note'
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_note').length,
|
||||
1,
|
||||
"should have activity note"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_note').textContent,
|
||||
"There is no good or bad note",
|
||||
"activity note should be 'There is no good or bad note'"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity info layout when planned after tomorrow', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const fiveDaysFromNow = new Date();
|
||||
fiveDaysFromNow.setDate(today.getDate() + 5);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
dateDeadline: date_to_str(fiveDaysFromNow),
|
||||
state: 'planned',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_dueDateText').length,
|
||||
1,
|
||||
"should have activity delay"
|
||||
);
|
||||
assert.ok(
|
||||
document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
|
||||
"activity delay should have the right color modifier class (planned)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_dueDateText').textContent,
|
||||
"Due in 5 days:",
|
||||
"activity delay should have 'Due in 5 days:' as label"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity info layout when planned tomorrow', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
dateDeadline: date_to_str(tomorrow),
|
||||
state: 'planned',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_dueDateText').length,
|
||||
1,
|
||||
"should have activity delay"
|
||||
);
|
||||
assert.ok(
|
||||
document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
|
||||
"activity delay should have the right color modifier class (planned)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_dueDateText').textContent,
|
||||
'Tomorrow:',
|
||||
"activity delay should have 'Tomorrow:' as label"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity info layout when planned today', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
dateDeadline: date_to_str(today),
|
||||
state: 'today',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_dueDateText').length,
|
||||
1,
|
||||
"should have activity delay"
|
||||
);
|
||||
assert.ok(
|
||||
document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'),
|
||||
"activity delay should have the right color modifier class (today)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_dueDateText').textContent,
|
||||
"Today:",
|
||||
"activity delay should have 'Today:' as label"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity info layout when planned yesterday', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
dateDeadline: date_to_str(yesterday),
|
||||
state: 'overdue',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_dueDateText').length,
|
||||
1,
|
||||
"should have activity delay"
|
||||
);
|
||||
assert.ok(
|
||||
document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
|
||||
"activity delay should have the right color modifier class (overdue)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_dueDateText').textContent,
|
||||
"Yesterday:",
|
||||
"activity delay should have 'Yesterday:' as label"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity info layout when planned before yesterday', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const fiveDaysBeforeNow = new Date();
|
||||
fiveDaysBeforeNow.setDate(today.getDate() - 5);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
dateDeadline: date_to_str(fiveDaysBeforeNow),
|
||||
state: 'overdue',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_dueDateText').length,
|
||||
1,
|
||||
"should have activity delay"
|
||||
);
|
||||
assert.ok(
|
||||
document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
|
||||
"activity delay should have the right color modifier class (overdue)"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_dueDateText').textContent,
|
||||
"5 days overdue:",
|
||||
"activity delay should have '5 days overdue:' as label"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity with a summary layout', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
summary: 'test summary',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_summary').length,
|
||||
1,
|
||||
"should have activity summary"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_type').length,
|
||||
0,
|
||||
"should not have the activity type as summary"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_summary').textContent.trim(),
|
||||
"“test summary”",
|
||||
"should have the specific activity summary in activity summary"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity without summary layout', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await this.start();
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
type: [['insert', { id: 1, displayName: "Fake type" }]],
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_type').length,
|
||||
1,
|
||||
"activity details should have an activity type section"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_type').textContent.trim(),
|
||||
"Fake type",
|
||||
"activity details should have the activity type display name in type section"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_summary.o_Activity_type').length,
|
||||
1,
|
||||
"should have activity type as summary"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length,
|
||||
0,
|
||||
"should not have a specific summary"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity details toggle', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
|
||||
dateCreate: date_to_str(today),
|
||||
dateDeadline: date_to_str(tomorrow),
|
||||
state: 'planned',
|
||||
type: [['insert', { id: 1, displayName: "Fake type" }]],
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details').length,
|
||||
0,
|
||||
"activity details should not be visible by default"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsButton').length,
|
||||
1,
|
||||
"activity should have a details button"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_Activity_detailsButton').click()
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details').length,
|
||||
1,
|
||||
"activity details should be visible after clicking on details button"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_Activity_detailsButton').click()
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details').length,
|
||||
0,
|
||||
"activity details should no longer be visible after clicking again on details button"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity details layout', async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
assignee: [['insert', { id: 10, partnerDisplayName: "Pauvre pomme" }]],
|
||||
creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
|
||||
dateCreate: date_to_str(today),
|
||||
dateDeadline: date_to_str(tomorrow),
|
||||
state: 'planned',
|
||||
type: [['insert', { id: 1, displayName: "Fake type" }]],
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_userAvatar').length,
|
||||
1,
|
||||
"should have activity user avatar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsButton').length,
|
||||
1,
|
||||
"activity should have a details button"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_Activity_detailsButton').click()
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details').length,
|
||||
1,
|
||||
"activity details should be visible after clicking on details button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_details .o_Activity_type').length,
|
||||
1,
|
||||
"activity details should have type"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_details .o_Activity_type').textContent,
|
||||
"Fake type",
|
||||
"activity details type should be 'Fake type'"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsCreation').length,
|
||||
1,
|
||||
"activity details should have creation date "
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsCreator').length,
|
||||
1,
|
||||
"activity details should have creator"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsAssignation').length,
|
||||
1,
|
||||
"activity details should have assignation information"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'),
|
||||
0,
|
||||
"activity details assignation information should contain creator display name"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length,
|
||||
1,
|
||||
"activity details should have user avatar"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity with mail template layout', async function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
await this.start();
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_sidebar').length,
|
||||
1,
|
||||
"should have activity sidebar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_mailTemplates').length,
|
||||
1,
|
||||
"should have activity mail templates"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_mailTemplate').length,
|
||||
1,
|
||||
"should have activity mail template"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_MailTemplate_name').length,
|
||||
1,
|
||||
"should have activity mail template name"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_MailTemplate_name').textContent,
|
||||
"Dummy mail template",
|
||||
"should have activity mail template name"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_MailTemplate_preview').length,
|
||||
1,
|
||||
"should have activity mail template name preview button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_MailTemplate_send').length,
|
||||
1,
|
||||
"should have activity mail template name send button"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity with mail template: preview mail', async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
const bus = new Bus();
|
||||
bus.on('do-action', null, payload => {
|
||||
assert.step('do_action');
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_res_id,
|
||||
42,
|
||||
'Action should have the activity res id as default res id in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_model,
|
||||
'res.partner',
|
||||
'Action should have the activity res model as default model in context'
|
||||
);
|
||||
assert.ok(
|
||||
payload.action.context.default_use_template,
|
||||
'Action should have true as default use_template in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_template_id,
|
||||
1,
|
||||
'Action should have the selected mail template id as default template id in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.type,
|
||||
"ir.actions.act_window",
|
||||
'Action should be of type "ir.actions.act_window"'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_model,
|
||||
"mail.compose.message",
|
||||
'Action should have "mail.compose.message" as res_model'
|
||||
);
|
||||
});
|
||||
|
||||
await this.start({ env: { bus } });
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
mailTemplates: [['insert', {
|
||||
id: 1,
|
||||
name: "Dummy mail template",
|
||||
}]],
|
||||
res_id: 42,
|
||||
res_model: 'res.partner',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_MailTemplate_preview').length,
|
||||
1,
|
||||
"should have activity mail template name preview button"
|
||||
);
|
||||
|
||||
document.querySelector('.o_MailTemplate_preview').click();
|
||||
assert.verifySteps(
|
||||
['do_action'],
|
||||
"should have called 'compose email' action correctly"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity with mail template: send mail', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
await this.start({
|
||||
async mockRPC(route, args) {
|
||||
if (args.method === 'activity_send_mail') {
|
||||
assert.step('activity_send_mail');
|
||||
assert.strictEqual(args.args[0].length, 1);
|
||||
assert.strictEqual(args.args[0][0], 42);
|
||||
assert.strictEqual(args.args[1], 1);
|
||||
return;
|
||||
} else {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
mailTemplates: [['insert', {
|
||||
id: 1,
|
||||
name: "Dummy mail template",
|
||||
}]],
|
||||
res_id: 42,
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_MailTemplate_send').length,
|
||||
1,
|
||||
"should have activity mail template name send button"
|
||||
);
|
||||
|
||||
document.querySelector('.o_MailTemplate_send').click();
|
||||
assert.verifySteps(
|
||||
['activity_send_mail'],
|
||||
"should have called activity_send_mail rpc"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity upload document is available', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
canWrite: true,
|
||||
category: 'upload_file',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_uploadButton').length,
|
||||
1,
|
||||
"should have activity upload button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_FileUploader').length,
|
||||
1,
|
||||
"should have a file uploader"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity click on mark as done', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
await this.start();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
canWrite: true,
|
||||
category: 'not_upload_file',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_markDoneButton').length,
|
||||
1,
|
||||
"should have activity Mark as Done button"
|
||||
);
|
||||
|
||||
await afterNextRender(() => {
|
||||
document.querySelector('.o_Activity_markDoneButton').click();
|
||||
});
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_ActivityMarkDonePopover').length,
|
||||
1,
|
||||
"should have opened the mark done popover"
|
||||
);
|
||||
|
||||
await afterNextRender(() => {
|
||||
document.querySelector('.o_Activity_markDoneButton').click();
|
||||
});
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_ActivityMarkDonePopover').length,
|
||||
0,
|
||||
"should have closed the mark done popover"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity click on edit', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
const bus = new Bus();
|
||||
bus.on('do-action', null, payload => {
|
||||
assert.step('do_action');
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_res_id,
|
||||
42,
|
||||
'Action should have the activity res id as default res id in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_res_model,
|
||||
'res.partner',
|
||||
'Action should have the activity res model as default res model in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.type,
|
||||
"ir.actions.act_window",
|
||||
'Action should be of type "ir.actions.act_window"'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_model,
|
||||
"mail.activity",
|
||||
'Action should have "mail.activity" as res_model'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_id,
|
||||
12,
|
||||
'Action should have activity id as res_id'
|
||||
);
|
||||
});
|
||||
|
||||
await this.start({ env: { bus } });
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
canWrite: true,
|
||||
id: 12,
|
||||
mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
|
||||
res_id: 42,
|
||||
res_model: 'res.partner',
|
||||
});
|
||||
await this.createActivityComponent(activity);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_editButton').length,
|
||||
1,
|
||||
"should have activity edit button"
|
||||
);
|
||||
|
||||
document.querySelector('.o_Activity_editButton').click();
|
||||
assert.verifySteps(
|
||||
['do_action'],
|
||||
"should have called 'schedule activity' action correctly"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity edition', async function (assert) {
|
||||
assert.expect(14);
|
||||
|
||||
this.data['mail.activity'].records = [{
|
||||
can_write: true,
|
||||
icon: 'fa-times',
|
||||
id: 12,
|
||||
res_id: 42,
|
||||
res_model: 'res.partner',
|
||||
}];
|
||||
|
||||
const bus = new Bus();
|
||||
bus.on('do-action', null, payload => {
|
||||
assert.step('do_action');
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_res_id,
|
||||
42,
|
||||
'Action should have the activity res id as default res id in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.context.default_res_model,
|
||||
'res.partner',
|
||||
'Action should have the activity res model as default res model in context'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.type,
|
||||
'ir.actions.act_window',
|
||||
'Action should be of type "ir.actions.act_window"'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_model,
|
||||
'mail.activity',
|
||||
'Action should have "mail.activity" as res_model'
|
||||
);
|
||||
assert.strictEqual(
|
||||
payload.action.res_id,
|
||||
12,
|
||||
'Action should have activity id as res_id'
|
||||
);
|
||||
this.data['mail.activity'].records[0].icon = 'fa-check';
|
||||
payload.options.on_close();
|
||||
});
|
||||
|
||||
await this.start({ env: { bus } });
|
||||
const activity = this.env.models['mail.activity'].create(
|
||||
this.env.models['mail.activity'].convertData(
|
||||
this.data['mail.activity'].records[0]
|
||||
)
|
||||
);
|
||||
await this.createActivityComponent(activity);
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_Activity',
|
||||
"should have activity component"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_Activity_editButton',
|
||||
"should have activity edit button"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_Activity_icon',
|
||||
"should have activity icon"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_Activity_icon.fa-times',
|
||||
"should have initial activity icon"
|
||||
);
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_Activity_icon.fa-check',
|
||||
"should not have new activity icon when not edited yet"
|
||||
);
|
||||
|
||||
await afterNextRender(() => {
|
||||
document.querySelector('.o_Activity_editButton').click();
|
||||
});
|
||||
assert.verifySteps(
|
||||
['do_action'],
|
||||
"should have called 'schedule activity' action correctly"
|
||||
);
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_Activity_icon.fa-times',
|
||||
"should no more have initial activity icon once edited"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_Activity_icon.fa-check',
|
||||
"should now have new activity icon once edited"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('activity click on cancel', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
await this.start({
|
||||
async mockRPC(route, args) {
|
||||
if (route === '/web/dataset/call_kw/mail.activity/unlink') {
|
||||
assert.step('unlink');
|
||||
assert.strictEqual(args.args[0].length, 1);
|
||||
assert.strictEqual(args.args[0][0], 12);
|
||||
return;
|
||||
} else {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
const activity = this.env.models['mail.activity'].create({
|
||||
canWrite: true,
|
||||
id: 12,
|
||||
mailTemplates: [['insert', {
|
||||
id: 1,
|
||||
name: "Dummy mail template",
|
||||
}]],
|
||||
res_id: 42,
|
||||
res_model: 'res.partner',
|
||||
});
|
||||
|
||||
// Create a parent component to surround the Activity component in order to be able
|
||||
// to check that activity component has been destroyed
|
||||
class ParentComponent extends Component {
|
||||
constructor(...args) {
|
||||
super(... args);
|
||||
useStore(props => {
|
||||
const activity = this.env.models['mail.activity'].get(props.activityLocalId);
|
||||
return {
|
||||
activity: activity ? activity.__state : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {mail.activity}
|
||||
*/
|
||||
get activity() {
|
||||
return this.env.models['mail.activity'].get(this.props.activityLocalId);
|
||||
}
|
||||
}
|
||||
ParentComponent.env = this.env;
|
||||
Object.assign(ParentComponent, {
|
||||
components,
|
||||
props: { activityLocalId: String },
|
||||
template: xml`
|
||||
<div>
|
||||
<p>parent</p>
|
||||
<t t-if="activity">
|
||||
<Activity activityLocalId="activity.localId"/>
|
||||
</t>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
this.component = new ParentComponent(null, { activityLocalId: activity.localId });
|
||||
await this.component.mount(this.widget.el);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
1,
|
||||
"should have activity component"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity_cancelButton').length,
|
||||
1,
|
||||
"should have activity cancel button"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_Activity_cancelButton').click()
|
||||
);
|
||||
assert.verifySteps(
|
||||
['unlink'],
|
||||
"should have called unlink rpc after clicking on cancel"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelectorAll('.o_Activity').length,
|
||||
0,
|
||||
"should no longer display activity after clicking on cancel"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const components = {
|
||||
Activity: require('mail/static/src/components/activity/activity.js'),
|
||||
};
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
class ActivityBox extends Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
useStore(props => {
|
||||
const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
|
||||
return {
|
||||
chatter: chatter ? chatter.__state : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Chatter}
|
||||
*/
|
||||
get chatter() {
|
||||
return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickTitle() {
|
||||
this.chatter.toggleActivityBoxVisibility();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ActivityBox, {
|
||||
components,
|
||||
props: {
|
||||
chatterLocalId: String,
|
||||
},
|
||||
template: 'mail.ActivityBox',
|
||||
});
|
||||
|
||||
return ActivityBox;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Layout
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ActivityBox_title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin-top: map-get($spacers, 4);
|
||||
margin-bottom: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
.o_ActivityBox_titleBadge {
|
||||
padding: map-get($spacers, 0) map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_ActivityBox_titleLine {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.o_ActivityBox_titleText {
|
||||
padding: map-get($spacers, 0) map-get($spacers, 3);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ActivityBox_title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.o_ActivityBox_titleBadge {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.o_ActivityBox_titleLine {
|
||||
border-color: gray('400');
|
||||
border-style: dashed;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ActivityBox" owl="1">
|
||||
<div class="o_ActivityBox">
|
||||
<t t-if="chatter">
|
||||
<a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle">
|
||||
<hr class="o_ActivityBox_titleLine" />
|
||||
<span class="o_ActivityBox_titleText">
|
||||
<i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-right' : 'fa-caret-down'"/>
|
||||
Planned activities
|
||||
</span>
|
||||
<t t-if="!chatter.isActivityBoxVisible">
|
||||
<span class="o_ActivityBox_titleBadges">
|
||||
<t t-if="chatter.overdueActivities.length > 0">
|
||||
<span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger">
|
||||
<t t-esc="chatter.overdueActivities.length"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="chatter.todayActivities.length > 0">
|
||||
<span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning">
|
||||
<t t-esc="chatter.todayActivities.length"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="chatter.futureActivities.length > 0">
|
||||
<span class="o_ActivityBox_titleBadge badge rounded-circle badge-success">
|
||||
<t t-esc="chatter.futureActivities.length"/>
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
<hr class="o_ActivityBox_titleLine" />
|
||||
</a>
|
||||
<t t-if="chatter.isActivityBoxVisible">
|
||||
<div class="o_ActivityList">
|
||||
<t t-foreach="chatter.activities" t-as="activity" t-key="activity.localId">
|
||||
<Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) {
|
||||
'use strict';
|
||||
|
||||
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
|
||||
|
||||
const { Component } = owl;
|
||||
const { useRef } = owl.hooks;
|
||||
|
||||
class ActivityMarkDonePopover extends Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
useStore(props => {
|
||||
const activity = this.env.models['mail.activity'].get(props.activityLocalId);
|
||||
return {
|
||||
activity: activity ? activity.__state : undefined,
|
||||
};
|
||||
});
|
||||
this._feedbackTextareaRef = useRef('feedbackTextarea');
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {mail.activity}
|
||||
*/
|
||||
get activity() {
|
||||
return this.env.models['mail.activity'].get(this.props.activityLocalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get DONE_AND_SCHEDULE_NEXT() {
|
||||
return this.env._t("Done & Schedule Next");
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickDiscard() {
|
||||
this.trigger('o-popover-close');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickDone() {
|
||||
this.activity.markAsDone({
|
||||
feedback: this._feedbackTextareaRef.el.value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickDoneAndScheduleNext() {
|
||||
this.activity.markAsDoneAndScheduleNext({
|
||||
feedback: this._feedbackTextareaRef.el.value,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ActivityMarkDonePopover, {
|
||||
props: {
|
||||
activityLocalId: String,
|
||||
},
|
||||
template: 'mail.ActivityMarkDonePopover',
|
||||
});
|
||||
|
||||
return ActivityMarkDonePopover;
|
||||
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Layout
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ActivityMarkDonePopover {
|
||||
align-content: space-between;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.o_ActivityMarkDonePopover_buttons {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
margin-top: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_ActivityMarkDonePopover_feedback {
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ActivityMarkDonePopover" owl="1">
|
||||
<div class="o_ActivityMarkDonePopover">
|
||||
<t t-if="activity">
|
||||
<textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-ref="feedbackTextarea"/>
|
||||
<div class="o_ActivityMarkDonePopover_buttons">
|
||||
<button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/>
|
||||
<t t-if="!activity.force_next">
|
||||
<button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone">
|
||||
Done
|
||||
</button>
|
||||
<button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard">
|
||||
Discard
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user