[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:
Sébastien Theys
2020-06-15 18:12:21 +00:00
co-authored by Alexandre Kühn Julien Giannone Lucas Perais Xavier Dubuc
parent 0a82848606
commit 3fea5b2136
564 changed files with 56243 additions and 33378 deletions
@@ -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>
+3 -3
View File
@@ -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();
});
});
});
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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>
+6 -6
View File
@@ -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>
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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>
+14 -5
View File
@@ -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}"`);
}
+2 -2
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+6 -6
View File
@@ -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>
+4
View File
@@ -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', {
});
});
});
});
@@ -0,0 +1,12 @@
// -----------------------------------------------------------------------------
// Style
// -----------------------------------------------------------------------------
.o_PartnerImStatusIcon_icon {
&.o-leave-online {
color: $o-enterprise-primary-color;
}
&.o-leave-offline {
color: theme-color('warning');
}
}
@@ -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>
+3 -3
View File
@@ -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>
+5 -1
View File
@@ -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,
+2 -5
View File
@@ -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):
+21 -1
View File
@@ -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"
);
});
});
});
});
});
@@ -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);
}
});
});
@@ -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}&amp;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 }
));
}
},
});
});
@@ -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']"/>",
+3 -3
View File
@@ -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>
+59 -6
View File
@@ -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',
],
}
+2
View File
@@ -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
+1 -1
View File
@@ -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&amp;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&amp;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>
+1 -1
View File
@@ -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
+4 -6
View File
@@ -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
+12 -12
View File
@@ -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
+7 -10
View File
@@ -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]
-15
View File
@@ -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
# ------------------------------------------------------
+8
View File
@@ -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 """
+10
View File
@@ -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.
*/
+11
View File
@@ -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', {
});
});
});
});
@@ -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>
@@ -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;
});
@@ -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;
}
@@ -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