[IMP] mail, im_livechat: improve Discuss sidebar

The commit is to refactor the discuss sidebar

 - threads are now organized in categories based on the thread type e.g. chat, channel
 - categories can be folded or unfolded by clicking the category title
 - for active thread, even if the category is folded, it remains under the category title
 - for channel category, a new cog button is added to view all channels
 - the active indicator bar is removed. The active item now is highlighted with a different background color
 - thread avatar is used for livechat, chat and channel
 - for livechat and chat, threads are now sorted by last activity time (pin or message exchange)

closes odoo/odoo#70986

Task-id: 2440073
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Qiuyu (QHO)
2021-08-25 17:00:06 +00:00
committed by Sébastien Theys
parent 9d13bb4534
commit 463af6f61b
60 changed files with 3803 additions and 1207 deletions
+4 -2
View File
@@ -47,11 +47,11 @@ Help your customers with this chat, and analyse their feedback.
'im_livechat/static/src/js/im_livechat_channel_form_view.js',
'im_livechat/static/src/js/im_livechat_channel_form_controller.js',
'im_livechat/static/src/components/discuss/discuss.js',
'im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js',
'im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js',
'im_livechat/static/src/components/notification_list/notification_list.js',
'im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js',
'im_livechat/static/src/components/thread_preview/thread_preview.js',
'im_livechat/static/src/models/discuss/discuss.js',
'im_livechat/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js',
'im_livechat/static/src/models/chat_window/chat_window.js',
'im_livechat/static/src/models/messaging_initializer/messaging_initializer.js',
'im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js',
@@ -61,6 +61,8 @@ Help your customers with this chat, and analyse their feedback.
'im_livechat/static/src/scss/im_livechat_form.scss',
],
'web.qunit_suite_tests': [
'im_livechat/static/src/components/discuss_sidebar_category/tests/discuss_sidebar_category_tests.js',
'im_livechat/static/src/components/discuss_sidebar_category_item/tests/discuss_sidebar_category_item_tests.js',
'im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js',
'im_livechat/static/src/components/composer/composer_tests.js',
'im_livechat/static/src/components/discuss/discuss_tests.js',
+1
View File
@@ -5,5 +5,6 @@ from . import res_partner
from . import im_livechat_channel
from . import mail_channel
from . import mail_channel_partner
from . import res_users_settings
from . import rating
from . import digest
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsersSettings(models.Model):
_inherit = 'res.users.settings'
is_discuss_sidebar_category_livechat_open = fields.Boolean("Is category livechat open", default=True)
@@ -8,6 +8,8 @@ import {
start,
} from '@mail/utils/test_utils';
import { datetime_to_str } from 'web.time';
QUnit.module('im_livechat', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss', {}, function () {
@@ -44,18 +46,18 @@ QUnit.test('livechat in the sidebar: basic rendering', async function (assert) {
assert.containsOnce(document.body, '.o_Discuss_sidebar',
"should have a sidebar section"
);
const groupLivechat = document.querySelector('.o_DiscussSidebar_groupLivechat');
const groupLivechat = document.querySelector('.o_DiscussSidebar_categoryLivechat');
assert.ok(groupLivechat,
"should have a channel group livechat"
);
const grouptitle = groupLivechat.querySelector('.o_DiscussSidebar_groupTitle');
const titleText = groupLivechat.querySelector('.o_DiscussSidebarCategory_titleText');
assert.strictEqual(
grouptitle.textContent.trim(),
titleText.textContent.trim(),
"Livechat",
"should have a channel group named 'Livechat'"
);
const livechat = groupLivechat.querySelector(`
.o_DiscussSidebarItem[data-thread-local-id="${
.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
@@ -95,10 +97,10 @@ QUnit.test('livechat in the sidebar: existing user with country', async function
await this.start();
assert.containsOnce(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should have a channel group livechat in the side bar"
);
const livechat = document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
const livechat = document.querySelector('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem');
assert.ok(
livechat,
"should have a livechat in sidebar"
@@ -124,7 +126,7 @@ QUnit.test('do not add livechat in the sidebar on visitor opening his chat', asy
await this.start();
assert.containsNone(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should not have any livechat in the sidebar initially"
);
@@ -141,7 +143,7 @@ QUnit.test('do not add livechat in the sidebar on visitor opening his chat', asy
await nextAnimationFrame();
assert.containsNone(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should still not have any livechat in the sidebar after visitor opened his chat"
);
});
@@ -168,7 +170,7 @@ QUnit.test('do not add livechat in the sidebar on visitor typing', async functio
await this.start();
assert.containsNone(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should not have any livechat in the sidebar initially"
);
@@ -187,7 +189,7 @@ QUnit.test('do not add livechat in the sidebar on visitor typing', async functio
await nextAnimationFrame();
assert.containsNone(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should still not have any livechat in the sidebar after visitor started typing"
);
});
@@ -221,7 +223,7 @@ QUnit.test('add livechat in the sidebar on visitor sending first message', async
await this.start();
assert.containsNone(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should not have any livechat in the sidebar initially"
);
@@ -239,33 +241,24 @@ QUnit.test('add livechat in the sidebar on visitor sending first message', async
}));
assert.containsOnce(
document.body,
'.o_DiscussSidebar_groupLivechat',
'.o_DiscussSidebar_categoryLivechat',
"should have a channel group livechat in the side bar after receiving first message"
);
assert.containsOnce(
document.body,
'.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item',
'.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem',
"should have a livechat in the sidebar after receiving first message"
);
assert.strictEqual(
document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item').textContent,
document.querySelector('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem .o_DiscussSidebarCategoryItem_name').textContent,
"Visitor (Belgium)",
"should have visitor name and country as livechat name"
);
});
QUnit.test('livechats are sorted by last message date in the sidebar: most recent at the top', async function (assert) {
/**
* For simplicity the code that is covered in this test is considering
* messages to be more/less recent than others based on their ids instead of
* their actual creation date.
*/
assert.expect(7);
QUnit.test('livechats are sorted by last activity time in the sidebar: most recent at the top', async function (assert) {
assert.expect(6);
this.data['mail.message'].records.push(
{ id: 11, model: "mail.channel", res_id: 11 }, // least recent message due to smaller id
{ id: 12, model: "mail.channel", res_id: 12 }, // most recent message due to higher id
);
this.data['mail.channel'].records.push(
{
anonymous_name: "Visitor 11",
@@ -273,6 +266,7 @@ QUnit.test('livechats are sorted by last message date in the sidebar: most recen
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
last_interest_dt: datetime_to_str(new Date(2021, 0, 1)), // less recent one
},
{
anonymous_name: "Visitor 12",
@@ -280,6 +274,7 @@ QUnit.test('livechats are sorted by last message date in the sidebar: most recen
id: 12,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
last_interest_dt: datetime_to_str(new Date(2021, 0, 2)), // more recent one
},
);
await this.start();
@@ -291,122 +286,43 @@ QUnit.test('livechats are sorted by last message date in the sidebar: most recen
id: 12,
model: 'mail.channel',
});
assert.containsOnce(
document.body,
'.o_DiscussSidebar_groupLivechat',
"should have a channel group livechat"
);
const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem');
assert.strictEqual(
initialLivechats.length,
2,
"should have 2 livechats in the sidebar"
"should have 2 livechat items"
);
assert.strictEqual(
initialLivechats[0].dataset.threadLocalId,
livechat12.localId,
"first livechat should be the one with the most recent message"
"first livechat should be the one with the more recent last activity time"
);
assert.strictEqual(
initialLivechats[1].dataset.threadLocalId,
livechat11.localId,
"second livechat should be the one with the least recent message"
"second livechat should be the one with the less recent last activity time"
);
// post a new message on the last channel
await afterNextRender(() => initialLivechats[1].click());
await afterNextRender(() => document.execCommand('insertText', false, "Blabla"));
await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
const livechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
const newLivechats = document.querySelectorAll('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem');
assert.strictEqual(
livechats.length,
newLivechats.length,
2,
"should still have 2 livechats in the sidebar after posting a new message"
"should have 2 livechat items"
);
assert.strictEqual(
livechats[0].dataset.threadLocalId,
newLivechats[0].dataset.threadLocalId,
livechat11.localId,
"first livechat should now be the one on which the new message was posted"
"first livechat should be the one with the more recent last activity time"
);
assert.strictEqual(
livechats[1].dataset.threadLocalId,
newLivechats[1].dataset.threadLocalId,
livechat12.localId,
"second livechat should now be the one on which the message was not posted"
);
});
QUnit.test('livechats with no messages are sorted by creation date in the sidebar: most recent at the top', async function (assert) {
/**
* For simplicity the code that is covered in this test is considering
* channels to be more/less recent than others based on their ids instead of
* their actual creation date.
*/
assert.expect(5);
this.data['mail.message'].records.push(
{ id: 13, model: "mail.channel", res_id: 13 },
);
this.data['mail.channel'].records.push(
{
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11, // least recent channel due to smallest id
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
},
{
anonymous_name: "Visitor 12",
channel_type: 'livechat',
id: 12, // most recent channel that does not have a message
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
},
{
anonymous_name: "Visitor 13",
channel_type: 'livechat',
id: 13, // most recent channel (but it has a message)
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
},
);
await this.start();
const livechat11 = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
});
const livechat12 = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 12,
model: 'mail.channel',
});
const livechat13 = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 13,
model: 'mail.channel',
});
assert.containsOnce(
document.body,
'.o_DiscussSidebar_groupLivechat',
"should have a channel group livechat"
);
const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
assert.strictEqual(
initialLivechats.length,
3,
"should have 3 livechats in the sidebar"
);
assert.strictEqual(
initialLivechats[0].dataset.threadLocalId,
livechat12.localId,
"first livechat should be the most recent channel without message"
);
assert.strictEqual(
initialLivechats[1].dataset.threadLocalId,
livechat11.localId,
"second livechat should be the second most recent channel without message"
);
assert.strictEqual(
initialLivechats[2].dataset.threadLocalId,
livechat13.localId,
"third livechat should be the channel with a message"
"second livechat should be the one with the less recent last activity time"
);
});
@@ -1,51 +0,0 @@
/** @odoo-module **/
import { DiscussSidebar } from '@mail/components/discuss_sidebar/discuss_sidebar';
import { patch } from 'web.utils';
const components = { DiscussSidebar };
patch(components.DiscussSidebar.prototype, '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.messaging.models['mail.thread']
.all(thread =>
thread.channel_type === 'livechat' &&
thread.isPinned &&
thread.model === 'mail.channel'
).sort((c1, c2) => {
// sort by: last message id (desc), id (desc)
if (c1.lastMessage && c2.lastMessage) {
return c2.lastMessage.id - c1.lastMessage.id;
}
// a channel without a last message is assumed to be a new
// channel just created with the intent of posting a new
// message on it, in which case it should be moved up.
if (!c1.lastMessage) {
return -1;
}
if (!c2.lastMessage) {
return 1;
}
return c2.id - c1.id;
});
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);
});
},
});
@@ -1,24 +1,13 @@
<?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>
<xpath expr="//DiscussSidebarCategory[contains(@class, 'o_DiscussSidebar_categoryChat')]" position="before">
<t t-set="categoryLivechat" t-value="messaging.discuss.categoryLivechat"/>
<t t-if="categoryLivechat and categoryLivechat.selectedChannels.length">
<DiscussSidebarCategory
class="o_DiscussSidebar_category o_DiscussSidebar_categoryLivechat"
categoryLocalId="categoryLivechat.localId"
/>
</t>
</xpath>
</t>
@@ -0,0 +1,490 @@
/** @odoo-module **/
import {
afterEach,
afterNextRender,
beforeEach,
start,
} from '@mail/utils/test_utils';
QUnit.module('im_livechat', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss_sidebar_category', {}, function () {
QUnit.module('discuss_sidebar_category_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('livechat - counter: should not have a counter if the category is unfolded and without unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
assert.containsNone(
document.body,
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
"should not have a counter if the category is unfolded and without unread messages",
);
});
QUnit.test('livechat - counter: should not have a counter if the category is unfolded and with unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
message_unread_counter: 10,
});
await this.start();
assert.containsNone(
document.body,
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
"should not have a counter if the category is unfolded and with unread messages",
);
});
QUnit.test('livechat - counter: should not have a counter if category is folded and without unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: false,
});
await this.start();
assert.containsNone(
document.body,
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
"should not have a counter if the category is folded and without unread messages"
);
});
QUnit.test('livechat - counter: should have correct value of unread threads if category is folded and with unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
message_unread_counter: 10,
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: false,
});
await this.start();
assert.strictEqual(
document.querySelector(`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`).textContent,
"1",
"should have correct value of unread threads if category is folded and with unread messages"
);
});
QUnit.test('livechat - states: close manually by clicking the title', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: true,
});
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
// fold the livechat category
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
"Category livechat should be closed and the content should be invisible"
);
});
QUnit.test('livechat - states: open manually by clicking the title', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: false,
});
await this.start();
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
// open the livechat category
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
"Category livechat should be closed and the content should be invisible"
);
});
QUnit.test('livechat - states: close should update the value on the server', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: true,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_livechat_open,
true,
"the value in server side should be true"
);
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_livechat_open,
false,
"the value in server side should be false"
);
});
QUnit.test('livechat - states: open should update the value on the server', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: false,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_livechat_open,
false,
"the value in server side should be false"
);
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_livechat_open,
true,
"the value in server side should be true"
);
});
QUnit.test('livechat - states: close from the bus', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_livechat_open: false,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
"Category livechat should be closed and the content should be invisible"
);
});
QUnit.test('livechat - states: open from the bus', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_livechat_open: false,
});
await this.start();
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_livechat_open: true,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
"Category livechat should be closed and the content should be invisible"
);
});
QUnit.test('livechat - states: category item should be invisible if the catgory is closed', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
"inactive item should be invisible if the category is folded"
);
});
QUnit.test('livechat - states: the active category item should be visble even if the category is closed', async function (assert) {
assert.expect(3);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.publicPartnerId],
});
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`
);
const livechat = document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`);
await afterNextRender(() => {
livechat.click();
});
assert.ok(livechat.classList.contains('o-active'));
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
this.messaging.discuss.categoryLivechat.localId}"]
.o_DiscussSidebarCategory_title
`).click()
);
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]`,
'the active livechat item should remain even if the category is folded'
);
});
});
});
});
@@ -0,0 +1,100 @@
/** @odoo-module **/
import {
afterEach,
beforeEach,
start,
} from '@mail/utils/test_utils';
QUnit.module('im_livechat', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss_sidebar_category_item', {}, function () {
QUnit.module('discuss_sidebar_category_item_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('livechat - avatar: should have a smiley face avatar for an anonymous livechat item', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
anonymous_name: "Visitor 11",
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, this.data.currentPartnerId],
});
await this.start();
const livechatItem = document.querySelector(`
.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]
`);
assert.containsOnce(
livechatItem,
`.o_DiscussSidebarCategoryItem_image`,
"should have an avatar"
);
assert.strictEqual(
livechatItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
'/mail/static/src/img/smiley/avatar.jpg',
'should have the smiley face as the avatar for anonymous users'
);
});
QUnit.test('livechat - avatar: should have a partner profile picture for a livechat item linked with a partner', async function (assert) {
assert.expect(2);
this.data['res.partner'].records.push({
id: 10,
name: "Jean",
});
this.data['mail.channel'].records.push({
channel_type: 'livechat',
id: 11,
livechat_operator_id: this.data.currentPartnerId,
members: [this.data.currentPartnerId, 10],
});
await this.start();
const livechatItem = document.querySelector(`
.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 11,
model: 'mail.channel',
}).localId
}"]
`);
assert.containsOnce(
livechatItem,
`.o_DiscussSidebarCategoryItem_image`,
"should have an avatar"
);
assert.strictEqual(
livechatItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
'/web/image/res.partner/10/avatar_128',
'should have the partner profile picture as the avatar for partners'
);
});
});
});
});
@@ -1,23 +0,0 @@
/** @odoo-module **/
import { DiscussSidebarItem } from '@mail/components/discuss_sidebar_item/discuss_sidebar_item';
import { patch } from 'web.utils';
const components = { DiscussSidebarItem };
patch(components.DiscussSidebarItem.prototype, '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,26 @@
/** @odoo-module **/
import { registerFieldPatchModel, registerInstancePatchModel } from '@mail/model/model_core';
import { one2one } from '@mail/model/model_field';
registerInstancePatchModel('mail.discuss', 'im_livechat/static/src/models/discuss/discuss.js', {
/**
* @override
*/
onInputQuickSearch(value) {
if (!this.sidebarQuickSearchValue) {
this.categoryLivechat.open();
}
return this._super(value);
},
});
registerFieldPatchModel('mail.discuss', 'im_livechat/static/src/models/discuss/discuss.js', {
/**
* Discuss sidebar category for `livechat` channel threads.
*/
categoryLivechat: one2one('mail.discuss_sidebar_category', {
isCausal: true,
}),
});
@@ -0,0 +1,54 @@
/** @odoo-module **/
import { registerInstancePatchModel } from '@mail/model/model_core';
registerInstancePatchModel('mail.discuss_sidebar_category_item', 'im_livechat/static/src/models/discuss_sidebar_category_item/discuss_sidebar_category_item.js', {
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @override
*/
_computeAvatarUrl() {
if (this.channelType === 'livechat') {
if (this.channel.correspondent && this.channel.correspondent.id > 0) {
return this.channel.correspondent.avatarUrl;
}
return '/mail/static/src/img/smiley/avatar.jpg';
}
return this._super();
},
/**
* @override
*/
_computeCounter() {
if (this.channelType === 'livechat') {
return this.channel.localMessageUnreadCounter;
}
return this._super();
},
/**
* @override
*/
_computeHasUnpinCommand() {
if (this.channelType === 'livechat') {
return !this.channel.localMessageUnreadCounter;
}
return this._super();
},
/**
* @override
*/
_computeHasThreadIcon() {
if (this.channelType === 'livechat') {
return false;
}
return this._super();
},
});
@@ -1,11 +1,31 @@
/** @odoo-module **/
import { registerInstancePatchModel } from '@mail/model/model_core';
import { insert } from '@mail/model/model_field_command';
import { create, insert } from '@mail/model/model_field_command';
registerInstancePatchModel('mail.messaging_initializer', 'im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', {
/**
* @override
* @param {Object} resUsersSettings
* @param {boolean} resUsersSettings.is_discuss_sidebar_category_livechat_open
*/
_initResUsersSettings({ is_discuss_sidebar_category_livechat_open }) {
this.messaging.discuss.update({
categoryLivechat: create({
counterComputeMethod: 'unread',
isServerOpen: is_discuss_sidebar_category_livechat_open,
name: this.env._t("Livechat"),
serverStateKey: 'is_discuss_sidebar_category_livechat_open',
sortComputeMethod: 'last_action',
supportedChannelTypes: ['livechat'],
}),
});
this._super(...arguments);
},
/**
* @override
* @param {Object[]} [param0.channel_livechat=[]]
*/
_initCommands() {
this._super();
@@ -8,6 +8,20 @@ registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/s
// Private
//----------------------------------------------------------------------
/**
* @override
* @param {object} settings
* @param {boolean} [settings.is_discuss_sidebar_category_livechat_open]
*/
_handleNotificationResUsersSettings(settings) {
if ('is_discuss_sidebar_category_livechat_open' in settings) {
this.messaging.discuss.categoryLivechat.update({
isServerOpen: settings.is_discuss_sidebar_category_livechat_open,
});
}
this._super(settings);
},
/**
* @override
*/
@@ -29,6 +29,9 @@ patch(MockModels, 'im_livechat/static/tests/helpers/mock_models.js', {
livechat_channel_id: { string: "Channel", type: 'many2one', relation: 'im_livechat.channel' },
livechat_operator_id: { string: "Operator", type: 'many2one', relation: 'res.partner' },
});
Object.assign(data['res.users.settings'].fields, {
is_discuss_sidebar_category_livechat_open: { string: "Is Discuss Sidebar Category Livechat Open?", type: 'boolean', default: true },
});
Object.assign(data['res.users'].fields, {
livechat_username: { string: 'Livechat Username', type: 'string' },
});
+1
View File
@@ -37,6 +37,7 @@
'security/ir.model.access.csv',
'views/mail_alias_views.xml',
'views/res_users_views.xml',
'views/res_users_settings_views.xml',
'views/mail_template_views.xml',
'views/ir_actions_server_views.xml',
'views/ir_model_views.xml',
+1
View File
@@ -30,6 +30,7 @@ from . import mail_template
from . import mail_channel_partner
from . import mail_channel
from . import mail_shortcode
from . import res_users_settings
# odoo models
from . import ir_action_act_window
+24 -2
View File
@@ -8,6 +8,7 @@ from uuid import uuid4
from odoo import _, api, fields, models, modules, tools, Command
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
_logger = logging.getLogger(__name__)
@@ -421,6 +422,20 @@ class Channel(models.Model):
message_format_values = message.message_format()[0]
bus_notifications = self._channel_message_notifications(message, message_format_values)
self.env['bus.bus'].sudo().sendmany(bus_notifications)
# Last interest is updated for a chat when posting a message.
# So a notification is needed to update UI.
if self.is_chat:
notifications = []
for channel_partners in self.channel_last_seen_partner_ids:
notif = {
'type': 'mail.channel_last_interest_dt_changed',
'payload': {
'id': self.id,
'last_interest_dt': channel_partners.last_interest_dt,
}
}
notifications.append([(self._cr.dbname, 'res.partner', channel_partners.partner_id.id), notif])
self.env['bus.bus'].sendmany(notifications)
return rdata
def _message_receive_bounce(self, email, partner):
@@ -432,7 +447,10 @@ class Channel(models.Model):
@api.returns('mail.message', lambda value: value.id)
def message_post(self, *, message_type='notification', **kwargs):
self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({'is_pinned': True})
self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({
'is_pinned': True,
'last_interest_dt': fields.Datetime.now(),
})
# mail_post_autofollow=False is necessary to prevent adding followers
# when using mentions in channels. Followers should not be added to
@@ -549,6 +567,7 @@ class Channel(models.Model):
info['seen_message_id'] = partner_channel.seen_message_id.id
info['custom_channel_name'] = partner_channel.custom_channel_name
info['is_pinned'] = partner_channel.is_pinned
info['last_interest_dt'] = partner_channel.last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
# add members infos
if channel.channel_type != 'channel':
# avoid sending potentially a lot of members for big channels
@@ -616,7 +635,10 @@ class Channel(models.Model):
channel = self.browse(result[0].get('channel_id'))
# pin up the channel for the current partner
if pin:
self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]).write({'is_pinned': True})
self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]).write({
'is_pinned': True,
'last_interest_dt': fields.Datetime.now(),
})
channel._broadcast(self.env.user.partner_id.ids)
else:
# create a new one
@@ -23,6 +23,7 @@ class ChannelPartner(models.Model):
fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open')
is_minimized = fields.Boolean("Conversation is minimized")
is_pinned = fields.Boolean("Is pinned on the interface", default=True)
last_interest_dt = fields.Datetime("Last Interest", default=fields.Datetime.now, help="Contains the date and time of the last interesting event that happened in this channel for this partner. This includes: creating, joining, pinning, and new message posted.")
@api.model_create_multi
def create(self, vals_list):
+2
View File
@@ -25,6 +25,7 @@ class Users(models.Model):
help="Policy on how to handle Chatter notifications:\n"
"- Handle by Emails: notifications are sent to your email address\n"
"- Handle in Odoo: notifications appear in your Odoo Inbox")
res_users_settings_ids = fields.One2many('res.users.settings', 'user_id')
# ------------------------------------------------------------
# CRUD
@@ -95,6 +96,7 @@ class Users(models.Model):
'channels': self.partner_id._get_channels_as_member().channel_info(),
'current_partner': self.partner_id.mail_partner_format().get(self.partner_id),
'current_user_id': self.id,
'current_user_settings': self.env['res.users.settings']._find_or_create_for_user(self)._res_users_settings_format(),
'mail_failures': self.partner_id._message_fetch_failed(),
'menu_id': self.env['ir.model.data']._xmlid_to_res_id('mail.menu_root_discuss'),
'needaction_inbox_counter': self.partner_id._get_needaction_count(),
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResUsersSettings(models.Model):
_name = 'res.users.settings'
_description = 'User Settings'
user_id = fields.Many2one('res.users', string="User", required=True, readonly=True, ondelete='cascade')
is_discuss_sidebar_category_channel_open = fields.Boolean(string="Is discuss sidebar category channel open?", default=True)
is_discuss_sidebar_category_chat_open = fields.Boolean(string="Is discuss sidebar category chat open?", default=True)
_sql_constraints = [
('unique_user_id', 'UNIQUE(user_id)', 'One user should only have one mail user settings.')
]
@api.model
def _find_or_create_for_user(self, user):
settings = user.res_users_settings_ids
if not settings:
settings = self.create({'user_id': user.id})
return settings
def _res_users_settings_format(self):
self.ensure_one()
return self._read_format(fnames=[name for name, field in self._fields.items() if name == 'id' or not field.automatic])[0]
def set_res_users_settings(self, new_settings):
self.ensure_one()
changed_settings = {}
for setting in new_settings.keys():
if setting in self._fields and new_settings[setting] != self[setting]:
changed_settings[setting] = new_settings[setting]
self.write(changed_settings)
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.user_id.partner_id.id), {
'type': 'res.users_settings_changed',
'payload': changed_settings,
})
+2
View File
@@ -45,4 +45,6 @@ access_mail_resend_message,access.mail.resend.message,model_mail_resend_message,
access_mail_resend_partner,access.mail.resend.partner,model_mail_resend_partner,base.group_user,1,1,1,0
access_mail_template_preview,access.mail.template.preview,model_mail_template_preview,base.group_user,1,1,1,0
access_mail_blacklist_remove_system,acesss.mail.blacklist.remove.system,model_mail_blacklist_remove,base.group_system,1,1,1,1
access_res_users_settings_all,res.users.settings,model_res_users_settings,,0,0,0,0
access_res_users_settings_user,res.users.settings,model_res_users_settings,base.group_user,1,1,1,1
ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_actions_report,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
45 access_mail_resend_partner access.mail.resend.partner model_mail_resend_partner base.group_user 1 1 1 0
46 access_mail_template_preview access.mail.template.preview model_mail_template_preview base.group_user 1 1 1 0
47 access_mail_blacklist_remove_system acesss.mail.blacklist.remove.system model_mail_blacklist_remove base.group_system 1 1 1 1
48 access_res_users_settings_all res.users.settings model_res_users_settings 0 0 0 0
49 access_res_users_settings_user res.users.settings model_res_users_settings base.group_user 1 1 1 1
50 ir_actions_report_access_user ir.actions.report.access.user base.model_ir_actions_report base.group_user 1 0 0 0
+22
View File
@@ -65,4 +65,26 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="res_users_settings_rule_admin" model="ir.rule">
<field name="name">Administrators can access all User Settings.</field>
<field name="model_id" ref="model_res_users_settings"/>
<field name="groups" eval="[Command.link(ref('base.group_system'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="res_users_settings_rule_user" model="ir.rule">
<field name="name">res.users.settings: access their own entries</field>
<field name="model_id" ref="model_res_users_settings"/>
<field name="groups" eval="[Command.link(ref('base.group_user'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>
@@ -13,7 +13,7 @@
</t>
<t t-else="">
<t t-if="!messaging.device.isMobile">
<DiscussSidebar class="o_Discuss_sidebar"/>
<DiscussSidebar class="o_Discuss_sidebar bg-light border-right"/>
</t>
<t t-if="messaging.device.isMobile" t-call="mail.Discuss.content"/>
<t t-else="">
@@ -43,7 +43,7 @@ QUnit.test('sidebar: pinned channel 1: init with one pinned channel', async func
);
assert.containsOnce(
document.body,
`.o_DiscussSidebarItem[data-thread-local-id="${
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
@@ -66,7 +66,7 @@ QUnit.test('sidebar: pinned channel 2: open pinned channel', async function (ass
model: 'mail.channel',
});
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
threadGeneral.localId
}"]`).click()
);
@@ -107,13 +107,13 @@ QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async
model: 'mail.channel',
});
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
threadGeneral.localId
}"]`).click()
);
assert.verifySteps([], "neither channel_fold nor action_unfollow are called yet");
await afterNextRender(() =>
document.querySelector('.o_DiscussSidebarItem_commandLeave').click()
document.querySelector('.o_DiscussSidebarCategoryItem_commandLeave').click()
);
assert.verifySteps(
[
@@ -124,7 +124,7 @@ QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async
);
assert.containsNone(
document.body,
`.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${threadGeneral.localId}"]`,
"The channel must have been removed from discuss sidebar"
);
assert.containsOnce(
@@ -153,12 +153,12 @@ QUnit.test('sidebar: unpin channel from bus', async function (assert) {
);
assert.containsOnce(
document.body,
`.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${threadGeneral.localId}"]`,
"1 channel is present in discuss sidebar and it is 'general'"
);
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
threadGeneral.localId
}"]`).click()
);
@@ -191,7 +191,7 @@ QUnit.test('sidebar: unpin channel from bus', async function (assert) {
);
assert.containsNone(
document.body,
`.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${threadGeneral.localId}"]`,
"The channel must have been removed from discuss sidebar"
);
});
@@ -217,12 +217,12 @@ QUnit.test('[technical] sidebar: channel group_based_subscription: mandatorily p
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${threadGeneral.localId}"]`,
"The channel #General is in discuss sidebar"
);
assert.containsNone(
document.body,
'o_DiscussSidebarItem_commandLeave',
'o_DiscussSidebarCategoryItem_commandLeave',
"The group_based_subscription channel is not unpinnable"
);
});
@@ -52,13 +52,13 @@ QUnit.test('sidebar find shows channels matching search term', async function (a
},
});
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click()
document.querySelector(`.o_DiscussSidebarCategory_commandAdd`).click()
);
document.querySelector(`.o_DiscussSidebar_itemNew`).focus();
document.querySelector(`.o_DiscussSidebarCategory_newItem`).focus();
document.execCommand('insertText', false, "test");
document.querySelector(`.o_DiscussSidebar_itemNew`)
document.querySelector(`.o_DiscussSidebarCategory_newItem`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_DiscussSidebar_itemNew`)
document.querySelector(`.o_DiscussSidebarCategory_newItem`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
await searchReadDef;
@@ -104,13 +104,13 @@ QUnit.test('sidebar find shows channels matching search term even when user is m
},
});
await afterNextRender(() =>
document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click()
document.querySelector(`.o_DiscussSidebarCategory_commandAdd`).click()
);
document.querySelector(`.o_DiscussSidebar_itemNew`).focus();
document.querySelector(`.o_DiscussSidebarCategory_newItem`).focus();
document.execCommand('insertText', false, "test");
document.querySelector(`.o_DiscussSidebar_itemNew`)
document.querySelector(`.o_DiscussSidebarCategory_newItem`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_DiscussSidebar_itemNew`)
document.querySelector(`.o_DiscussSidebarCategory_newItem`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
await searchReadDef;
@@ -145,7 +145,7 @@ QUnit.test('sidebar channels should be ordered case insensitive alphabetically',
{ id: 22, name: "Xyz" }
);
await this.start();
const results = document.querySelectorAll('.o_DiscussSidebar_groupChannel .o_DiscussSidebarItem_name');
const results = document.querySelectorAll('.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategoryItem_name');
assert.deepEqual(
[results[0].textContent, results[1].textContent, results[2].textContent, results[3].textContent],
["abc", "Abc", "Xyz", "Xyz"],
File diff suppressed because it is too large Load Diff
@@ -19,12 +19,6 @@ export class DiscussSidebar extends Component {
* chats based on this input content.
*/
this._quickSearchInputRef = useRef('quickSearchInput');
// bind since passed as props
this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this);
this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this);
this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this);
this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this);
}
//--------------------------------------------------------------------------
@@ -38,94 +32,6 @@ export class DiscussSidebar extends Component {
return this.messaging && this.messaging.discuss;
}
/**
* @returns {string}
*/
get FIND_OR_CREATE_CHANNEL() {
return this.env._t("Find or create a channel...");
}
/**
* @returns {mail.thread[]}
*/
get orderedMailboxes() {
return this.messaging.models['mail.thread']
.all(thread => thread.isPinned && thread.model === 'mail.box')
.sort((mailbox1, mailbox2) => {
if (mailbox1 === this.messaging.inbox) {
return -1;
}
if (mailbox2 === this.messaging.inbox) {
return 1;
}
if (mailbox1 === this.messaging.starred) {
return -1;
}
if (mailbox2 === this.messaging.starred) {
return 1;
}
const mailbox1Name = mailbox1.displayName;
const mailbox2Name = mailbox2.displayName;
mailbox1Name < mailbox2Name ? -1 : 1;
});
}
/**
* Return the list of chats that match the quick search value input.
*
* @returns {mail.thread[]}
*/
get quickSearchPinnedAndOrderedChats() {
const allOrderedAndPinnedChats = this.messaging.models['mail.thread']
.all(thread =>
thread.channel_type === 'chat' &&
thread.isPinned &&
thread.model === 'mail.channel'
)
.sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
if (!this.discuss.sidebarQuickSearchValue) {
return allOrderedAndPinnedChats;
}
const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
return allOrderedAndPinnedChats.filter(chat => {
const nameVal = chat.displayName.toLowerCase();
return nameVal.includes(qsVal);
});
}
/**
* Return the list of channels that match the quick search value input.
*
* @returns {mail.thread[]}
*/
get quickSearchOrderedAndPinnedMultiUserChannels() {
const allOrderedAndPinnedMultiUserChannels = this.messaging.models['mail.thread']
.all(thread =>
thread.channel_type === 'channel' &&
thread.isPinned &&
thread.model === 'mail.channel'
)
.sort((c1, c2) => {
if (c1.displayName && !c2.displayName) {
return -1;
} else if (!c1.displayName && c2.displayName) {
return 1;
} else if (c1.displayName && c2.displayName && c1.displayName !== c2.displayName) {
return c1.displayName.toLowerCase() < c2.displayName.toLowerCase() ? -1 : 1;
} else {
return c1.id - c2.id;
}
});
if (!this.discuss.sidebarQuickSearchValue) {
return allOrderedAndPinnedMultiUserChannels;
}
const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
return allOrderedAndPinnedMultiUserChannels.filter(channel => {
const nameVal = channel.displayName.toLowerCase();
return nameVal.includes(qsVal);
});
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -146,110 +52,13 @@ export class DiscussSidebar extends Component {
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev
* @param {Object} ui
* @param {Object} ui.item
* @param {integer} ui.item.id
*/
_onAddChannelAutocompleteSelect(ev, ui) {
this.discuss.handleAddChannelAutocompleteSelect(ev, ui);
}
/**
* @private
* @param {Object} req
* @param {string} req.term
* @param {function} res
*/
_onAddChannelAutocompleteSource(req, res) {
this.discuss.handleAddChannelAutocompleteSource(req, res);
}
/**
* @private
* @param {Event} ev
* @param {Object} ui
* @param {Object} ui.item
* @param {integer} ui.item.id
*/
_onAddChatAutocompleteSelect(ev, ui) {
this.discuss.handleAddChatAutocompleteSelect(ev, ui);
}
/**
* @private
* @param {Object} req
* @param {string} req.term
* @param {function} res
*/
_onAddChatAutocompleteSource(req, res) {
this.discuss.handleAddChatAutocompleteSource(req, res);
}
/**
* Called when clicking on add channel icon.
*
* @private
* @param {MouseEvent} ev
*/
_onClickChannelAdd(ev) {
ev.stopPropagation();
this.discuss.update({ isAddingChannel: true });
}
/**
* Called when clicking on channel title.
*
* @private
* @param {MouseEvent} ev
*/
_onClickChannelTitle(ev) {
ev.stopPropagation();
return this.env.bus.trigger('do-action', {
action: {
name: this.env._t("Public Channels"),
type: 'ir.actions.act_window',
res_model: 'mail.channel',
views: [[false, 'kanban'], [false, 'form']],
domain: [['public', '!=', 'private']]
},
});
}
/**
* Called when clicking on add chat icon.
*
* @private
* @param {MouseEvent} ev
*/
_onClickChatAdd(ev) {
ev.stopPropagation();
this.discuss.update({ isAddingChat: true });
}
/**
* @private
* @param {CustomEvent} ev
*/
_onHideAddingItem(ev) {
ev.stopPropagation();
if (!this.discuss) {
return;
}
this.discuss.clearIsAddingItem();
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onInputQuickSearch(ev) {
ev.stopPropagation();
this.discuss.update({
sidebarQuickSearchValue: this._quickSearchInputRef.el.value,
});
this.discuss.onInputQuickSearch(this._quickSearchInputRef.el.value);
}
}
@@ -12,42 +12,12 @@
}
}
.o_DiscussSidebar_group {
.o_DiscussSidebar_category {
display: flex;
flex-flow: column;
flex: 0 0 auto;
}
.o_DiscussSidebar_groupHeader {
display: flex;
align-items: center;
margin: 5px 0;
}
.o_DiscussSidebar_groupHeaderItem {
margin-left: 3px;
margin-right: 3px;
&:first-child {
margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right;
}
&:last-child {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebar_itemNew {
display: flex;
justify-content: center;
}
.o_DiscussSidebar_itemNewInput {
flex: 1 1 auto;
margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px;
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
.o_DiscussSidebar_quickSearch {
border-radius: 10px;
margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px;
@@ -62,49 +32,11 @@
// Style
// ------------------------------------------------------------------
.o_DiscussSidebar {
background-color: gray('900');
color: gray('300');
}
.o_DiscussSidebar_groupHeader {
font-size: $font-size-sm;
text-transform: uppercase;
font-weight: bolder;
}
.o_DiscussSidebar_groupHeaderItemAdd {
cursor: pointer;
&:not(:hover) {
color: gray('600');
}
}
.o_DiscussSidebar_groupTitle {
&:not(.o-clickable) {
color: gray('600');
}
&.o-clickable {
cursor: pointer;
&:not(:hover) {
color: gray('600');
}
}
}
.o_DiscussSidebar_itemNewInput {
outline: none;
}
.o_DiscussSidebar_quickSearch {
border: none;
outline: none;
}
.o_DiscussSidebar_separator {
background-color: gray('600');
background-color: $border-color;
}
@@ -2,81 +2,24 @@
<templates xml:space="preserve">
<t t-name="mail.DiscussSidebar" owl="1">
<div class="o_DiscussSidebar">
<t t-if="discuss" name="root">
<div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox">
<t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId">
<DiscussSidebarItem
class="o_DiscussSidebar_item"
threadLocalId="mailbox.localId"
/>
</t>
</div>
<hr class="o_DiscussSidebar_separator"/>
<t t-if="messaging.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19">
<input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/>
</t>
<div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel">
<div class="o_DiscussSidebar_groupHeader">
<div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle">
Channels
</div>
<div class="o-autogrow"/>
<div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/>
</div>
<div class="o_DiscussSidebar_list">
<t t-if="discuss.isAddingChannel">
<div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
<AutocompleteInput
class="o_DiscussSidebar_itemNewInput"
customClass="'o_DiscussSidebar_newChannelAutocompleteSuggestions'"
isFocusOnMount="true"
isHtml="true"
placeholder="FIND_OR_CREATE_CHANNEL"
select="_onAddChannelAutocompleteSelect"
source="_onAddChannelAutocompleteSource"
t-on-o-hide="_onHideAddingItem"
/>
</div>
</t>
<t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId">
<DiscussSidebarItem
class="o_DiscussSidebar_item"
threadLocalId="channel.localId"
/>
</t>
</div>
</div>
<div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat">
<div class="o_DiscussSidebar_groupHeader">
<div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle">
Direct Messages
</div>
<div class="o-autogrow"/>
<div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/>
</div>
<div class="o_DiscussSidebar_list">
<t t-if="discuss.isAddingChat">
<div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
<AutocompleteInput
class="o_DiscussSidebar_itemNewInput"
isFocusOnMount="true"
placeholder="'Find or start a conversation...'"
select="_onAddChatAutocompleteSelect"
source="_onAddChatAutocompleteSource"
t-on-o-hide="_onHideAddingItem"
/>
</div>
</t>
<t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId">
<DiscussSidebarItem
class="o_DiscussSidebar_item"
threadLocalId="chat.localId"
/>
</t>
</div>
</div>
<div name="root" class="o_DiscussSidebar">
<div class="o_DiscussSidebar_category o_DiscussSidebar_categoryMailbox">
<DiscussSidebarMailbox threadLocalId="messaging.inbox.localId"/>
<DiscussSidebarMailbox threadLocalId="messaging.starred.localId"/>
<DiscussSidebarMailbox threadLocalId="messaging.history.localId"/>
</div>
<hr class="o_DiscussSidebar_separator"/>
<t t-if="messaging.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19">
<input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/>
</t>
<DiscussSidebarCategory
class="o_DiscussSidebar_category o_DiscussSidebar_categoryChannel"
categoryLocalId="discuss.categoryChannel.localId"
/>
<DiscussSidebarCategory
class="o_DiscussSidebar_category o_DiscussSidebar_categoryChat"
categoryLocalId="discuss.categoryChat.localId"
/>
</div>
</t>
@@ -0,0 +1,28 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class DiscussSidebarCategory extends Component {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {mail.discuss_sidebar_category}
*/
get category() {
return this.messaging.models['mail.discuss_sidebar_category'].get(this.props.categoryLocalId);
}
}
Object.assign(DiscussSidebarCategory, {
props: {
categoryLocalId: String,
},
template: 'mail.DiscussSidebarCategory',
});
registerMessagingComponent(DiscussSidebarCategory);
@@ -0,0 +1,101 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_DiscussSidebarCategory_command {
margin-left: $o-mail-discuss-sidebar-category-item-margin;
margin-right:$o-mail-discuss-sidebar-category-item-margin;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
.o_DiscussSidebarCategory_commands {
display: flex;
}
.o_DiscussSidebarCategory_header {
display: flex;
align-items: center;
margin: 5px 0;
}
.o_DiscussSidebarCategory_headerItem {
margin-left: $o-mail-discuss-sidebar-category-item-margin;
margin-right: $o-mail-discuss-sidebar-category-item-margin;
&:last-child {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarCategory_itemNewInput {
flex: 1 1 auto;
margin-left: $o-mail-discuss-sidebar-category-item-avatar-left-margin;
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
.o_DiscussSidebarCategory_newItem {
display: flex;
justify-content: center;
padding-bottom: map-get($spacers, 2);
}
.o_DiscussSidebarCategory_title {
display: flex;
align-items: baseline;
}
.o_DiscussSidebarCategory_titleIcon {
width: $o-mail-discuss-sidebar-category-title-icon-size;
height: $o-mail-discuss-sidebar-category-title-icon-size;
}
// ------------------------------------------------------------------
// style
// ------------------------------------------------------------------
.o_DiscussSidebarCategory_command {
cursor: pointer;
&:not(:hover) {
color: gray('600');
}
}
.o_DiscussSidebarCategory_counter {
background-color: $o-brand-primary;
color: gray('300');
}
.o_DiscussSidebarCategory_headerItem {
user-select: none;
}
.o_DiscussSidebarCategory_itemNewInput {
outline: none;
}
.o_DiscussSidebarCategory_title {
cursor: pointer;
&:not(:hover)
{
color: gray('600');
}
}
.o_DiscussSidebarCategory_titleIcon {
font-size: 0.75em;
}
.o_DiscussSidebarCategory_titleText {
font-size: $font-size-sm;
text-transform: uppercase;
font-weight: bolder;
}
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.DiscussSidebarCategory" owl="1">
<div class="o_DiscussSidebarCategory" t-att-data-category-local-id="category.localId" t-att-data-category-type="category.type">
<div class="o_DiscussSidebarCategory_header">
<div class="o_DiscussSidebarCategory_title o_DiscussSidebarCategory_headerItem" t-on-click="category.onClick">
<i class="o_DiscussSidebarCategory_titleIcon" t-att-class="category.isOpen ? 'fa fa-chevron-down' : 'fa fa-chevron-right'"/>
<span class="o_DiscussSidebarCategory_titleText"><t t-esc="category.name"/></span>
</div>
<div class="o-autogrow o_DiscussSidebarCategory_headerItem"/>
<div class="o_DiscussSidebarCategory_commands o_DiscussSidebarCategory_headerItem">
<t t-if="category.hasViewCommand">
<i class="o_DiscussSidebarCategory_command o_DiscussSidebarCategory_commandView fa fa-cog" title="View or join channels" t-on-click="category.onClickCommandView" role="img"/>
</t>
<t t-if="category.hasAddCommand and category.isOpen">
<i class="o_DiscussSidebarCategory_command o_DiscussSidebarCategory_commandAdd fa fa-plus" t-on-click="category.onClickCommandAdd" t-att-title="category.commandAddTitleText" role="img"/>
</t>
</div>
<t t-if="!category.isOpen and category.counter > 0">
<div class="o_DiscussSidebarCategory_counter o_DiscussSidebarCategory_headerItem badge badge-pill">
<t t-esc="category.counter"/>
</div>
</t>
</div>
<div class="o_DiscussSidebarCategory_content">
<t t-if="category.isOpen">
<t t-if="category.isAddingItem">
<div class="o_DiscussSidebarCategory_newItem">
<AutocompleteInput
class="o_DiscussSidebarCategory_itemNewInput"
customClass="category === messaging.discuss.categoryChannel ? 'o_DiscussSidebarCategory_newChannelAutocompleteSuggestions' : False"
isFocusOnMount="true"
isHtml="category === messaging.discuss.categoryChannel ? true : False"
placeholder="category.newItemPlaceholderText"
select="category.onAddItemAutocompleteSelect"
source="category.onAddItemAutocompleteSource"
t-on-o-hide="category.onHideAddingItem"
/>
</div>
</t>
<t t-foreach="category.categoryItems" t-as="item" t-key="item.localId">
<DiscussSidebarCategoryItem
class="o_DiscussSidebarCategory_item"
categoryItemLocalId="item.localId"
/>
</t>
</t>
<t t-if="!category.isOpen and category.activeItem">
<DiscussSidebarCategoryItem
class="o_DiscussSidebarCategory_item"
categoryItemLocalId="category.activeItem.localId"
/>
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,834 @@
/** @odoo-module **/
import {
afterEach,
afterNextRender,
beforeEach,
start,
} from '@mail/utils/test_utils';
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss_sidebar_category', {}, function () {
QUnit.module('discuss_sidebar_category_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('channel - counter: should not have a counter if the category is unfolded and without needaction messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is unfolded and without unread messages"
);
});
QUnit.test('channel - counter: should not have a counter if the category is unfolded and with needaction messagens', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push(
{ id: 20 },
{ id: 30 },
);
this.data['mail.message'].records.push({
body: "message 1",
id: 100,
model: "mail.channel",
res_id: 20,
}, {
body: "message_2",
id: 200,
model: "mail.channel",
res_id: 30,
});
this.data['mail.notification'].records.push({
mail_message_id: 100,
res_partner_id: this.data.currentPartnerId,
}, {
mail_message_id: 200,
res_partner_id: this.data.currentPartnerId,
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is unfolded and with needaction messages",
);
});
QUnit.test('channel - counter: should not have a counter if category is folded and without needaction messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is folded and without unread messages"
);
});
QUnit.test('channel - counter: should have correct value of needaction threads if category is folded and with needaction messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push(
{ id: 20 },
{ id: 30 },
);
this.data['mail.message'].records.push({
body: "message 1",
id: 100,
model: "mail.channel",
res_id: 20,
}, {
body: "message_2",
id: 200,
model: "mail.channel",
res_id: 30,
});
this.data['mail.notification'].records.push({
mail_message_id: 100,
res_partner_id: this.data.currentPartnerId,
}, {
mail_message_id: 200,
res_partner_id: this.data.currentPartnerId,
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
assert.strictEqual(
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_counter`).textContent,
"2",
"should have correct value of needaction threads if category is folded and with needaction messages"
);
});
QUnit.test('channel - command: should have view command when category is unfolded', async function (assert) {
assert.expect(1);
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandView`).length,
1,
"should have view command when channel category is open"
);
});
QUnit.test('channel - command: should have view command when category is folded', async function (assert) {
assert.expect(1);
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandView`).length,
1,
"should have view command when channel category is closed"
);
});
QUnit.test('channel - command: should have add command when category is unfolded', async function (assert) {
assert.expect(1);
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandAdd`).length,
1,
"should have add command when channel category is open"
);
});
QUnit.test('channel - command: should not have add command when category is folded', async function (assert) {
assert.expect(1);
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandAdd`).length,
0,
"should not have add command when channel category is closed"
);
});
QUnit.test('channel - states: close manually by clicking the title', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: true,
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
"Category channel should be closed and the content should be invisible"
);
});
QUnit.test('channel - states: open manually by clicking the title', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
"Category channel should be open and the content should be visible"
);
});
QUnit.test('channel - states: close should update the value on the server', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: true,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_channel_open,
true,
"the vaule in server side should be true"
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_channel_open,
false,
"the vaule in server side should be false"
);
});
QUnit.test('channel - states: open should update the value on the server', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_channel_open,
false,
"the vaule in server side should be false"
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_channel_open,
true,
"the vaule in server side should be false"
);
});
QUnit.test('channel - states: close from the bus', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: true,
});
await this.start();
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_channel_open: false,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
"Category channel should be closed and the content should be invisible"
);
});
QUnit.test('channel - states: open from the bus', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_channel_open: false,
});
await this.start();
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_channel_open: true,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
"Category channel should be open and the content should be visible"
);
});
QUnit.test('channel - states: the active category item should be visble even if the category is closed', async function (assert) {
assert.expect(4);
this.data['mail.channel'].records.push({ id: 20 });
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`
);
const channel = document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`);
await afterNextRender(() => {
channel.click();
});
assert.ok(channel.classList.contains('o-active'));
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_title`).click();
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
'the active channel item should remain even if the category is folded'
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebarMailbox[data-thread-local-id="${
this.messaging.inbox.localId
}"]`).click();
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]`,
"inactive item should be invisible if the category is folded"
);
});
QUnit.test('chat - counter: should not have a counter if the category is unfolded and without unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is unfolded and without unread messages",
);
});
QUnit.test('chat - counter: should not have a counter if the category is unfolded and with unread messagens', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 10,
public: 'private',
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is unfolded and with unread messages",
);
});
QUnit.test('chat - counter: should not have a counter if category is folded and without unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_counter`).length,
0,
"should not have a counter if the category is folded and without unread messages"
);
});
QUnit.test('chat - counter: should have correct value of unread threads if category is folded and with unread messages', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 10,
public: 'private',
}, {
channel_type: 'chat',
id: 20,
message_unread_counter: 20,
public: 'private',
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
assert.strictEqual(
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_counter`).textContent,
"2",
"should have correct value of unread threads if category is folded and with unread messages"
);
});
QUnit.test('chat - command: should have add command when category is unfolded', async function (assert) {
assert.expect(1);
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandAdd`).length,
1,
"should have add command when chat category is open"
);
});
QUnit.test('chat - command: should not have add command when category is folded', async function (assert) {
assert.expect(1);
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: false,
});
await this.start();
assert.strictEqual(
document.querySelectorAll(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_header .o_DiscussSidebarCategory_commandAdd`).length,
0,
"should not have add command when chat category is closed"
);
});
QUnit.test('chat - states: close manually by clicking the title', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: true,
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
"Category chat should be closed and the content should be invisible"
);
});
QUnit.test('chat - states: open manually by clicking the title', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: false,
});
await this.start();
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
"Category chat should be closed and the content should be invisible"
);
});
QUnit.test('chat - states: close should call update server data', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: true,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_chat_open,
true,
"the value in server side should be true"
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_chat_open,
false,
"the value in server side should be false"
);
});
QUnit.test('chat - states: open should call update server data', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: false,
});
const currentUserId = this.data.currentUserId;
await this.start();
const initalSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
initalSettings.is_discuss_sidebar_category_chat_open,
false,
"the value in server side should be false"
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
const newSettings = await this.env.services.rpc({
model: 'res.users.settings',
method: '_find_or_create_for_user',
args: [[currentUserId]],
});
assert.strictEqual(
newSettings.is_discuss_sidebar_category_chat_open,
true,
"the value in server side should be true"
);
});
QUnit.test('chat - states: close from the bus', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: true,
});
await this.start();
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_chat_open: false,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
"Category chat should be open and the content should be visible"
);
});
QUnit.test('chat - states: open from the bus', async function (assert) {
assert.expect(1);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
this.data['res.users.settings'].records.push({
user_id: this.data.currentUserId,
is_discuss_sidebar_category_chat_open: false,
});
await this.start();
await afterNextRender(() => {
const notif = [
["dbName", "res.partner", this.messaging.currentPartner.id],
{
type: "res.users_settings_changed",
payload: {
is_discuss_sidebar_category_chat_open: true,
},
},
];
this.env.services.bus_service.trigger('notification', [notif]);
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
"Category chat should be closed and the content should be invisible"
);
});
QUnit.test('chat - states: the active category item should be visble even if the category is closed', async function (assert) {
assert.expect(4);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
message_unread_counter: 0,
public: 'private',
});
await this.start();
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`
);
const chat = document.querySelector(`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`);
await afterNextRender(() => {
chat.click();
});
assert.ok(chat.classList.contains('o-active'));
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_title`).click();
});
assert.containsOnce(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
'the active chat item should remain even if the category is folded'
);
await afterNextRender(() => {
document.querySelector(`.o_DiscussSidebarMailbox[data-thread-local-id="${
this.messaging.inbox.localId
}"]`).click();
});
assert.containsNone(
document.body,
`.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]`,
"inactive item should be invisible if the category is folded"
);
});
});
});
});
@@ -0,0 +1,28 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class DiscussSidebarCategoryItem extends Component {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {mail.discuss_sidebar_category_item}
*/
get categoryItem() {
return this.messaging.models['mail.discuss_sidebar_category_item'].get(this.props.categoryItemLocalId);
}
}
Object.assign(DiscussSidebarCategoryItem, {
props: {
categoryItemLocalId: String,
},
template: 'mail.DiscussSidebarCategoryItem',
});
registerMessagingComponent(DiscussSidebarCategoryItem);
@@ -0,0 +1,125 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_DiscussSidebarCategoryItem {
cursor: pointer;
display: flex;
align-items: center;
padding: map-get($spacers, 2) 0;
&:hover .o_DiscussSidebarCategoryItem_commands {
display: flex;
}
}
.o_DiscussSidebarCategoryItem_command {
margin-left: $o-mail-discuss-sidebar-category-item-margin;
margin-right: $o-mail-discuss-sidebar-category-item-margin;
&:first-child {
margin-left: 0px;
}
&:last-child {
margin-right: 0px;
}
}
.o_DiscussSidebarCategoryItem_commands {
display: none;
}
.o_DiscussSidebarCategoryItem_image {
object-fit: cover;
width: map-get($sizes, 100);
height: map-get($sizes, 100);
}
.o_DiscussSidebarCategoryItem_imageContainer {
position: relative;
width: $o-mail-discuss-sidebar-category-item-avatar-size;
height: $o-mail-discuss-sidebar-category-item-avatar-size;
}
.o_DiscussSidebarCategoryItem_item {
margin-left: $o-mail-discuss-sidebar-category-item-margin;
margin-right: $o-mail-discuss-sidebar-category-item-margin;
&:first-child {
margin-left: $o-mail-discuss-sidebar-category-item-avatar-left-margin;
}
&:last-child {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarCategoryItem_name {
@include text-truncate();
margin-left: 10px;
&.o-editable {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarCategoryItem_nameInput {
width: 100%;
}
.o_DiscussSidebarCategoryItem_threadIcon {
@include o-position-absolute($bottom: 0, $right: 0);
display: flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o_DiscussSidebarCategoryItem {
.o_DiscussSidebarCategoryItem_threadIcon {
font-size: xx-small;
background-color: gray('100');
border-radius: 50%;
}
&.o-active {
background-color: gray('200');
}
&:hover {
background-color: gray('300');
}
}
.o_DiscussSidebarCategoryItem_command {
&:not(:hover) {
color: gray('600');
}
}
.o_DiscussSidebarCategoryItem_counter {
background-color: $o-brand-primary;
color: gray('300');
}
.o_DiscussSidebarCategoryItem_name {
&.o-item-unread {
font-weight: bold;
}
}
.o_DiscussSidebarCategoryItem_nameInput{
outline: none;
border: none;
border-radius: 2px;
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.DiscussSidebarCategoryItem" owl="1">
<div class="o_DiscussSidebarCategoryItem"
t-att-class="{
'o-active': categoryItem.isActive,
'o-unread': categoryItem.isUnread,
}" t-on-click="categoryItem.onClick" t-att-data-thread-local-id="categoryItem.channel.localId" t-att-data-thread-name="categoryItem.channel.displayName"
>
<div class="o_DiscussSidebarCategoryItem_item o_DiscussSidebarCategoryItem_avatar">
<div class="o_DiscussSidebarCategoryItem_imageContainer">
<img class="o_DiscussSidebarCategoryItem_image rounded-circle" t-att-src="categoryItem.avatarUrl" alt="Thread Image"/>
<t t-if="categoryItem.hasThreadIcon">
<ThreadIcon class="o_DiscussSidebarCategoryItem_threadIcon" threadLocalId="categoryItem.channel.localId"/>
</t>
</div>
</div>
<t t-if="categoryItem.isRenaming">
<span class="o_DiscussSidebarCategoryItem_item o_DiscussSidebarCategoryItem_name o-editable">
<EditableText
class="o_DiscussSidebarCategoryItem_nameInput"
placeholder="categoryItem.channel.correspondent ? categoryItem.channel.correspondent.name : categoryItem.channel.name"
value="categoryItem.channel.displayName"
t-on-o-cancel="categoryItem.onCancelRenaming"
t-on-o-clicked="categoryItem.onClickedEditableText"
t-on-o-validate="categoryItem.onValidateEditableText"
/>
</span>
</t>
<t t-else="">
<span class="o_DiscussSidebarCategoryItem_item o_DiscussSidebarCategoryItem_name" t-att-class="{ 'o-item-unread': categoryItem.isUnread }">
<t t-esc="categoryItem.channel.displayName"/>
</span>
</t>
<div class="o-autogrow o_DiscussSidebarCategoryItem_item"/>
<div class="o_DiscussSidebarCategoryItem_item o_DiscussSidebarCategoryItem_commands">
<t t-if="categoryItem.hasSettingsCommand">
<div class="o_DiscussSidebarCategoryItem_command o_DiscussSidebarCategoryItem_commandSettings fa fa-cog" t-on-click="categoryItem.onClickCommandSettings" title="Channel settings" role="img"/>
</t>
<t t-if="categoryItem.hasLeaveCommand">
<div class="o_DiscussSidebarCategoryItem_command o_DiscussSidebarCategoryItem_commandLeave fa fa-times" t-on-click="categoryItem.onClickCommandLeave" title="Leave this channel" role="img"/>
</t>
<t t-if="categoryItem.hasRenameCommand">
<div class="o_DiscussSidebarCategoryItem_command o_DiscussSidebarCategoryItem_commandRename fa fa-cog" t-on-click="categoryItem.onClickCommandRename" title="Rename conversation" role="img"/>
</t>
<t t-if="categoryItem.hasUnpinCommand">
<div class="o_DiscussSidebarCategoryItem_command o_DiscussSidebarCategoryItem_commandUnpin fa fa-times" t-on-click="categoryItem.onClickCommandUnpin" title="Unpin Conversation" role="img"/>
</t>
</div>
<t t-if="categoryItem.counter > 0">
<div class="o_DiscussSidebarCategoryItem_counter o_DiscussSidebarCategoryItem_item badge badge-pill">
<t t-esc="categoryItem.counter"/>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,159 @@
/** @odoo-module **/
import {
afterEach,
afterNextRender,
beforeEach,
start,
} from '@mail/utils/test_utils';
import { datetime_to_str } from 'web.time';
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss_sidebar_category_item', {}, function () {
QUnit.module('discuss_sidebar_category_item_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('channel - avatar: should have correct avatar', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
await this.start();
const channelItem = document.querySelector(`
.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
}).localId
}"]
`);
assert.strictEqual(
channelItem.querySelectorAll(`:scope .o_DiscussSidebarCategoryItem_image`).length,
1,
"channel should have an avatar"
);
assert.strictEqual(
channelItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
'/web/image/mail.channel/20/image_128',
'should link to the correct picture source'
);
});
QUnit.test('chat - avatar: should have correct avatar', async function (assert) {
assert.expect(2);
this.data['res.partner'].records.push({ id: 15, name: "Demo", im_status: 'offline' });
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
members: [this.data.currentPartnerId, 15],
public: 'private',
});
await this.start();
const chatItem = document.querySelector(`
.o_DiscussSidebarCategoryItem[data-thread-local-id="${
this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
}).localId
}"]
`);
assert.strictEqual(
chatItem.querySelectorAll(`:scope .o_DiscussSidebarCategoryItem_image`).length,
1,
"chat should have an avatar"
);
assert.strictEqual(
chatItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
'/web/image/res.partner/15/avatar_128',
'should link to the partner avatar'
);
});
QUnit.test('chat - sorting: should be sorted by last activity time', async function (assert) {
assert.expect(6);
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 10,
public: 'private',
last_interest_dt: datetime_to_str(new Date(2021, 0, 1)), // less recent one
}, {
channel_type: 'chat',
id: 20,
public: 'private',
last_interest_dt: datetime_to_str(new Date(2021, 0, 2)), // more recent one
});
await this.start();
const chat10 = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 10,
model: 'mail.channel',
});
const chat20 = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
});
const initialChats = document.querySelectorAll('.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategoryItem');
assert.strictEqual(
initialChats.length,
2,
"should have 2 livechat items"
);
assert.strictEqual(
initialChats[0].dataset.threadLocalId,
chat20.localId,
"first livechat should be the one with the more recent last activity time"
);
assert.strictEqual(
initialChats[1].dataset.threadLocalId,
chat10.localId,
"second chat should be the one with the less recent last activity time"
);
// post a new message on the last channel
await afterNextRender(() => initialChats[1].click());
await afterNextRender(() => document.execCommand('insertText', false, "Blabla"));
await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
const newChats = document.querySelectorAll('.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategoryItem');
assert.strictEqual(
newChats.length,
2,
"should have 2 chat items"
);
assert.strictEqual(
newChats[0].dataset.threadLocalId,
chat10.localId,
"first chat should be the one with the more recent last activity time"
);
assert.strictEqual(
newChats[1].dataset.threadLocalId,
chat20.localId,
"second chat should be the one with the less recent last activity time"
);
});
});
});
});
@@ -1,183 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import { isEventHandled } from '@mail/utils/utils';
import Dialog from 'web.Dialog';
const { Component } = owl;
export class DiscussSidebarItem extends Component {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Get the counter of this discuss item, which is based on the thread type.
*
* @returns {integer}
*/
get counter() {
if (this.thread.model === 'mail.box') {
return this.thread.counter;
} else if (this.thread.channel_type === 'channel') {
return this.thread.message_needaction_counter;
} else if (this.thread.channel_type === 'chat') {
return this.thread.localMessageUnreadCounter;
}
return 0;
}
/**
* @returns {mail.discuss}
*/
get discuss() {
return this.messaging && this.messaging.discuss;
}
/**
* @returns {boolean}
*/
hasUnpin() {
return this.thread.channel_type === 'chat';
}
/**
* @returns {mail.thread}
*/
get thread() {
return this.messaging && this.messaging.models['mail.thread'].get(this.props.threadLocalId);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @returns {Promise}
*/
_askAdminConfirmation() {
return new Promise(resolve => {
Dialog.confirm(this,
this.env._t("You are the administrator of this channel. Are you sure you want to leave?"),
{
buttons: [
{
text: this.env._t("Leave"),
classes: 'btn-primary',
close: true,
click: resolve
},
{
text: this.env._t("Discard"),
close: true
}
]
}
);
});
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev
*/
_onCancelRenaming(ev) {
this.discuss.cancelThreadRenaming(this.thread);
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClick(ev) {
if (isEventHandled(ev, 'EditableText.click')) {
return;
}
this.thread.open();
}
/**
* Stop propagation to prevent selecting this item.
*
* @private
* @param {CustomEvent} ev
*/
_onClickedEditableText(ev) {
ev.stopPropagation();
}
/**
* @private
* @param {MouseEvent} ev
*/
async _onClickLeave(ev) {
ev.stopPropagation();
if (this.thread.creator === this.messaging.currentUser) {
await this._askAdminConfirmation();
}
this.thread.unsubscribe();
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickRename(ev) {
ev.stopPropagation();
this.discuss.setThreadRenaming(this.thread);
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickSettings(ev) {
ev.stopPropagation();
return this.env.bus.trigger('do-action', {
action: {
type: 'ir.actions.act_window',
res_model: this.thread.model,
res_id: this.thread.id,
views: [[false, 'form']],
target: 'current'
},
});
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickUnpin(ev) {
ev.stopPropagation();
this.thread.unsubscribe();
}
/**
* @private
* @param {CustomEvent} ev
* @param {Object} ev.detail
* @param {string} ev.detail.newName
*/
_onValidateEditableText(ev) {
ev.stopPropagation();
this.discuss.onValidateEditableText(this.thread, ev.detail.newName);
}
}
Object.assign(DiscussSidebarItem, {
props: {
threadLocalId: String,
},
template: 'mail.DiscussSidebarItem',
});
registerMessagingComponent(DiscussSidebarItem);
@@ -1,109 +0,0 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_DiscussSidebarItem {
display: flex;
align-items: center;
padding: map-get($spacers, 1) 0;
&:hover .o_DiscussSidebarItem_commands {
display: flex;
}
}
.o_DiscussSidebarItem_activeIndicator {
width: $o-mail-discuss-sidebar-active-indicator-width;
align-self: stretch;
flex: 0 0 auto;
}
.o_DiscussSidebarItem_command {
margin-left: 3px;
margin-right: 3px;
&:first-child {
margin-left: 0px;
}
&:last-child {
margin-right: 0px;
}
}
.o_DiscussSidebarItem_commands {
display: none;
}
.o_DiscussSidebarItem_item {
margin-left: 3px;
margin-right: 3px;
&:first-child {
margin-left: 0px;
margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right;
}
&:last-child {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarItem_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.o-editable {
margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right;
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarItem_nameInput {
width: 100%;
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o_DiscussSidebarItem {
cursor: pointer;
&:hover {
background-color: darken(gray('900'), 5%);
}
&.o-starred-box {
.o_DiscussSidebarItem_counter {
border-color: gray('600');
background-color: gray('600');
}
}
}
.o_DiscussSidebarItem_activeIndicator.o-item-active {
background-color: $o-brand-primary;
}
.o_DiscussSidebarItem_command:not(:hover) {
color: gray('600');
}
.o_DiscussSidebarItem_counter {
background-color: $o-brand-primary;
}
.o_DiscussSidebarItem_name {
&.o-item-unread {
font-weight: bold;
}
}
.o_DiscussSidebarItem_nameInput {
outline: none;
border: none;
border-radius: 2px;
}
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.DiscussSidebarItem" owl="1">
<div class="o_DiscussSidebarItem"
t-att-class="{
'o-active': thread and discuss.thread === thread,
'o-starred-box': thread and thread === messaging.starred,
'o-unread': thread and thread.localMessageUnreadCounter > 0,
}" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined"
>
<t t-if="thread">
<div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/>
<ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/>
<t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)">
<div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable">
<EditableText
class="o_DiscussSidebarItem_nameInput"
placeholder="thread.correspondent ? thread.correspondent.nameOrDisplayName : thread.name"
value="thread.displayName"
t-on-o-cancel="_onCancelRenaming"
t-on-o-clicked="_onClickedEditableText"
t-on-o-validate="_onValidateEditableText"
/>
</div>
</t>
<t t-else="">
<div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.localMessageUnreadCounter > 0 }">
<t t-esc="thread.displayName"/>
</div>
</t>
<div class="o-autogrow o_DiscussSidebarItem_item"/>
<t t-if="thread.model !== 'mail.box'">
<div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item">
<t t-if="thread.channel_type === 'channel'">
<div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/>
<t t-if="!thread.message_needaction_counter and !thread.group_based_subscription">
<div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/>
</t>
</t>
<t t-if="thread.channel_type === 'chat'">
<div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/>
</t>
<t t-if="hasUnpin()">
<t t-if="!thread.localMessageUnreadCounter">
<div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/>
</t>
</t>
</div>
</t>
<t t-if="counter > 0">
<div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill">
<t t-esc="counter"/>
</div>
</t>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,29 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class DiscussSidebarMailbox extends Component {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {mail.thread}
*/
get mailbox() {
return this.messaging.models['mail.thread'].get(this.props.threadLocalId);
}
}
Object.assign(DiscussSidebarMailbox, {
props: {
threadLocalId: String,
},
template: 'mail.DiscussSidebarMailbox',
});
registerMessagingComponent(DiscussSidebarMailbox);
@@ -0,0 +1,57 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_DiscussSidebarMailbox {
display: flex;
align-items: center;
padding: map-get($spacers, 1) 0;
}
.o_DiscussSidebarMailbox_item {
margin-left: $o-mail-discuss-sidebar-category-item-margin;
margin-right: $o-mail-discuss-sidebar-category-item-margin;
&:first-child {
margin-left: $o-mail-discuss-sidebar-category-item-avatar-left-margin;
}
&:last-child {
margin-right: $o-mail-discuss-sidebar-scrollbar-width;
}
}
.o_DiscussSidebarMailbox_name {
@include text-truncate();
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o_DiscussSidebarMailbox {
cursor: pointer;
&.o-active {
background-color: gray('200')
}
&:hover {
background-color: gray('300');
}
&.o-starred-box {
.o_DiscussSidebarMailbox_counter {
border-color: gray('400');
background-color: gray('400');
color: gray('300');
}
}
}
.o_DiscussSidebarMailbox_counter {
background-color: $o-brand-primary;
color: gray('300');
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.DiscussSidebarMailbox" owl="1">
<div class="o_DiscussSidebarMailbox"
t-att-class="{
'o-active': mailbox and mailbox === messaging.discuss.thread,
'o-starred-box': mailbox and mailbox === messaging.starred,
}" t-on-click="mailbox.onClick" t-att-data-thread-local-id="mailbox.localId" t-att-data-thread-name="mailbox.displayName"
>
<ThreadIcon class="o_DiscussSidebarMailbox_item" threadLocalId="mailbox.localId"/>
<div class="o_DiscussSidebarMailbox_item o_DiscussSidebarMailbox_name">
<t t-esc="mailbox.displayName"/>
</div>
<div class="o-autogrow o_DiscussSidebarMailbox_item"/>
<t t-if="mailbox.counter > 0">
<div class="o_DiscussSidebarMailbox_counter o_DiscussSidebarMailbox_item badge badge-pill">
<t t-esc="mailbox.counter"/>
</div>
</t>
</div>
</t>
</templates>
+7 -7
View File
@@ -1,18 +1,18 @@
/** @odoo-module **/
import {_t} from 'web.core';
import {Markup} from 'web.utils';
import { _t } from 'web.core';
import { Markup } from 'web.utils';
import tour from 'web_tour.tour';
tour.register('mail_tour', {
url: "/web#action=mail.widgets.discuss",
sequence: 80,
}, [{
trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd',
trigger: '.o_DiscussSidebar_categoryChannel .o_DiscussSidebarCategory_commandAdd',
content: Markup(_t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>")),
position: 'bottom',
}, {
trigger: '.o_DiscussSidebar_itemNewInput',
trigger: '.o_DiscussSidebarCategory_itemNewInput',
content: Markup(_t("<p>Create a channel here.</p>")),
position: 'bottom',
auto: true,
@@ -21,7 +21,7 @@ tour.register('mail_tour', {
actions.text("SomeChannel_" + t, this.$anchor);
},
}, {
trigger: ".o_DiscussSidebar_newChannelAutocompleteSuggestions",
trigger: ".o_DiscussSidebarCategory_newChannelAutocompleteSuggestions",
content: Markup(_t("<p>Create a public or private channel.</p>")),
position: 'right',
run() {
@@ -45,11 +45,11 @@ tour.register('mail_tour', {
content: Markup(_t("Messages can be <b>starred</b> to remind you to check back later.")),
position: "bottom",
}, {
trigger: '.o_DiscussSidebarItem.o-starred-box',
trigger: '.o_DiscussSidebarMailbox.o-starred-box',
content: _t("Once a message has been starred, you can come back and review it at any time here."),
position: "bottom",
}, {
trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd',
trigger: '.o_DiscussSidebar_categoryChat .o_DiscussSidebarCategory_commandAdd',
content: Markup(_t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>")),
position: 'bottom',
}]);
@@ -246,6 +246,18 @@ function factory(dependencies) {
return `${thread.model}_${thread.id}`;
}
/**
* @param {string} value
*/
onInputQuickSearch(value) {
// Opens all categories only when user starts to search from empty search value.
if (!this.sidebarQuickSearchValue) {
this.categoryChat.open();
this.categoryChannel.open();
}
this.update({ sidebarQuickSearchValue: value });
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
@@ -383,6 +395,18 @@ function factory(dependencies) {
compute: '_computeAddingChannelValue',
default: "",
}),
/**
* Discuss sidebar category for `channel` type channel threads.
*/
categoryChannel: one2one('mail.discuss_sidebar_category', {
isCausal: true,
}),
/**
* Discuss sidebar category for `chat` type channel threads.
*/
categoryChat: one2one('mail.discuss_sidebar_category', {
isCausal: true,
}),
/**
* Determines whether `this.thread` should be displayed.
*/
@@ -0,0 +1,411 @@
/** @odoo-module **/
import { registerNewModel } from '@mail/model/model_core';
import { attr, many2one, one2many, one2one } from '@mail/model/model_field';
import { clear, insertAndReplace, link, replace } from '@mail/model/model_field_command';
import { OnChange } from '@mail/model/model_onchange';
function factory(dependencies) {
class DiscussSidebarCategory extends dependencies['mail.model'] {
/**
* @override
*/
_created() {
this.onClick = this.onClick.bind(this);
this.onHideAddingItem = this.onHideAddingItem.bind(this);
this.onAddItemAutocompleteSelect = this.onAddItemAutocompleteSelect.bind(this);
this.onAddItemAutocompleteSource = this.onAddItemAutocompleteSource.bind(this);
this.onClickCommandAdd = this.onClickCommandAdd.bind(this);
this.onClickCommandView = this.onClickCommandView.bind(this);
return super._created();
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
/**
* Performs the `set_res_users_settings` RPC on `res.users.settings`.
*
* @static
* @param {Object} resUsersSettings
* @param {boolean} [resUsersSettings.is_category_channel_open]
* @param {boolean} [resUsersSettings.is_category_chat_open]
*/
static async performRpcSetResUsersSettings(resUsersSettings) {
return this.env.services.rpc(
{
model: 'res.users.settings',
method: 'set_res_users_settings',
args: [[this.messaging.currentUser.resUsersSettingsId]],
kwargs: {
new_settings: resUsersSettings,
},
},
{ shadow: true },
);
}
/**
* Closes the category and notity server to change the state
*/
async close() {
this.update({ isPendingOpen: false });
await this.messaging.models['mail.discuss_sidebar_category'].performRpcSetResUsersSettings({
[this.serverStateKey]: false,
});
}
/**
* Opens the category and notity server to change the state
*/
async open() {
this.update({ isPendingOpen: true });
await this.messaging.models['mail.discuss_sidebar_category'].performRpcSetResUsersSettings({
[this.serverStateKey]: true,
});
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @returns {mail.discuss_sidebar_category_item | undefined}
*/
_computeActiveItem() {
const thread = this.messaging.discuss.thread;
if (thread && this.supportedChannelTypes.includes(thread.channel_type)) {
return insertAndReplace({ channelId: thread.id });
}
return clear();
}
/**
* @private
* @returns {mail.discuss_sidebar_category_item[]}
*/
_computeCategoryItems() {
let channels = this.selectedSortedChannels;
const searchValue = this.messaging.discuss.sidebarQuickSearchValue;
if (searchValue) {
const qsVal = searchValue.toLowerCase();
channels = channels.filter(t => {
const nameVal = t.displayName.toLowerCase();
return nameVal.includes(qsVal);
});
}
return insertAndReplace(channels.map(c => ({ channelId: c.id })));
}
/**
* @private
* @returns {integer}
*/
_computeCounter() {
switch (this.counterComputeMethod) {
case 'needaction':
return this.selectedChannels.filter(thread => thread.message_needaction_counter > 0).length;
case 'unread':
return this.selectedChannels.filter(thread => thread.localMessageUnreadCounter > 0).length;
}
}
/**
* @private
* @returns {boolean}
*/
_computeIsOpen() {
return this.isPendingOpen !== undefined ? this.isPendingOpen : this.isServerOpen;
}
/**
* @private
* @returns {mail.thread[]}
*/
_computeSelectedChannels() {
return replace(this.messaging.models['mail.thread'].all(thread =>
thread.model === 'mail.channel' &&
thread.isPinned &&
this.supportedChannelTypes.includes(thread.channel_type))
);
}
/**
*
* @private
* @returns {mail.thread[]}
*/
_computeSelectedSortedChannels() {
switch (this.sortComputeMethod) {
case 'name':
return replace(this._sortByDisplayName());
case 'last_action':
return replace(this._sortByLastInterestDateTime());
}
}
/**
* Sorts `selectedChannels` by `displayName` in
* case-insensitive alphabetical order.
*
* @private
* @returns {mail.thread[]}
*/
_sortByDisplayName() {
return this.selectedChannels.sort((t1, t2) => {
if (t1.displayName && !t2.displayName) {
return -1;
} else if (!t1.displayName && t2.displayName) {
return 1;
} else if (t1.displayName && t2.displayName && t1.displayName !== t2.displayName) {
return t1.displayName.toLowerCase() < t2.displayName.toLowerCase() ? -1 : 1;
} else {
return t1.id - t2.id;
}
});
}
/**
* Sorts `selectedChannels` by `lastInterestDateTime`.
* The most recent one will come first.
*
* @private
* @returns {mail.thread[]}
*/
_sortByLastInterestDateTime() {
return this.selectedChannels.sort((t1, t2) => {
if (t1.lastInterestDateTime && !t2.lastInterestDateTime) {
return -1;
} else if (!t1.lastInterestDateTime && t2.lastInterestDateTime) {
return 1;
} else if (t1.lastInterestDateTime && t2.lastInterestDateTime && t1.lastInterestDateTime !== t2.lastInterestDateTime) {
return t2.lastInterestDateTime - t1.lastInterestDateTime;
} else {
return t2.id - t1.id;
}
});
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Changes the category open states when clicked.
*/
async onClick() {
if (this.isOpen) {
await this.close();
} else {
await this.open();
}
}
/**
* @param {CustomEvent} ev
*/
onHideAddingItem(ev) {
ev.stopPropagation();
this.update({ isAddingItem: false });
}
/**
* @param {Event} ev
* @param {Object} ui
* @param {Object} ui.item
* @param {integer} ui.item.id
*/
onAddItemAutocompleteSelect(ev, ui) {
switch (this.autocompleteMethod) {
case 'channel':
this.messaging.discuss.handleAddChannelAutocompleteSelect(ev, ui);
break;
case 'chat':
this.messaging.discuss.handleAddChatAutocompleteSelect(ev, ui);
break;
}
}
/**
* @param {Object} req
* @param {string} req.term
* @param {function} res
*/
onAddItemAutocompleteSource(req, res) {
switch (this.autocompleteMethod) {
case 'channel':
this.messaging.discuss.handleAddChannelAutocompleteSource(req, res);
break;
case 'chat':
this.messaging.discuss.handleAddChatAutocompleteSource(req, res);
break;
}
}
/**
* @param {MouseEvent} ev
*/
onClickCommandAdd(ev) {
ev.stopPropagation();
this.update({ isAddingItem: true });
}
/**
* Redirects to the public channels window when view command is clicked.
*
* @param {MouseEvent} ev
*/
onClickCommandView(ev) {
ev.stopPropagation();
return this.env.bus.trigger('do-action', {
action: {
name: this.env._t("Public Channels"),
type: 'ir.actions.act_window',
res_model: 'mail.channel',
views: [[false, 'kanban'], [false, 'form']],
domain: [['public', '!=', 'private']],
},
});
}
/**
* Handles change of open state coming from the server. Useful to
* clear pending state once server acknowledged the change.
*
* @private
*/
_onIsServerOpenChanged() {
if (this.isServerOpen === this.isPendingOpen) {
this.update({ isPendingOpen: clear() });
}
}
}
DiscussSidebarCategory.fields = {
/**
* The category item which is active and belongs
* to the category.
*/
activeItem: one2one('mail.discuss_sidebar_category_item', {
compute: '_computeActiveItem',
}),
/**
* Determines how the autocomplete of this category should behave.
* Must be one of: 'channel', 'chat'.
*/
autocompleteMethod: attr(),
/**
* The title text in UI for command `add`
*/
commandAddTitleText: attr(),
/**
* The sorted category items which belong to the category.
* These items are also filtered by `sidebarQuickSearchValue`.
*/
categoryItems: one2many('mail.discuss_sidebar_category_item', {
compute: '_computeCategoryItems',
}),
/**
* The total amount unread/action-needed threads in the category.
*/
counter: attr({
compute: '_computeCounter',
}),
/**
* Determines how the counter of this category should be computed.
* Supported methods: 'needaction', 'unread'.
*/
counterComputeMethod: attr({
required: true,
}),
/**
* Display name of the category.
*/
name: attr(),
/**
* Boolean that determines whether this category has a 'add' command.
*/
hasAddCommand: attr({
default: false,
}),
/**
* Boolean that determines whether this category has a 'view' command.
*/
hasViewCommand: attr({
default: false,
}),
/**
* Boolean that determines whether discuss is adding a new category item.
*/
isAddingItem: attr({
default: false,
}),
/**
* Boolean that determines whether this category is open.
*/
isOpen: attr({
compute: '_computeIsOpen',
}),
/**
* Boolean that determines if there is a pending open state change,
* which is requested by the client but not yet confirmed by the server.
*
* This field can be updated to immediately change the open state on the
* interface and to notify the server of the new state.
*/
isPendingOpen: attr(),
/**
* Boolean that determines the last open state known by the server.
*/
isServerOpen: attr(),
/**
* The placeholder text used when a new item is being added in UI.
*/
newItemPlaceholderText: attr(),
/**
* Channels which belong to the category,
*/
selectedChannels: one2many('mail.thread', {
compute: '_computeSelectedChannels',
}),
/**
* Channels which belongs to the category,
* and sorted based on the `supported_channel_type`.
*/
selectedSortedChannels: one2many('mail.thread', {
compute: '_computeSelectedSortedChannels',
}),
/**
* The key used in the server side for the category state
*/
serverStateKey: attr(),
/**
* Determines the sorting method of channels in this category.
* Must be one of: 'name', 'last_action'.
*/
sortComputeMethod: attr({
required: true,
}),
/**
* Channel type which is supported by the category.
*/
supportedChannelTypes: attr({
required: true,
readonly: true,
}),
};
DiscussSidebarCategory.onChanges = [
new OnChange({
dependencies: ['isServerOpen'],
methodName: ['_onIsServerOpenChanged'],
}),
];
DiscussSidebarCategory.modelName = 'mail.discuss_sidebar_category';
return DiscussSidebarCategory;
}
registerNewModel('mail.discuss_sidebar_category', factory);
@@ -0,0 +1,347 @@
/** @odoo-module **/
import { registerNewModel } from '@mail/model/model_core';
import { attr, one2one } from '@mail/model/model_field';
import { clear, link } from '@mail/model/model_field_command';
import { isEventHandled } from '@mail/utils/utils';
import Dialog from 'web.Dialog';
function factory(dependencies) {
class DiscussSidebarCategoryItem extends dependencies['mail.model'] {
/**
* @override
*/
_created() {
this.onCancelRenaming = this.onCancelRenaming.bind(this);
this.onClick = this.onClick.bind(this);
this.onClickCommandLeave = this.onClickCommandLeave.bind(this);
this.onClickCommandRename = this.onClickCommandRename.bind(this);
this.onClickCommandSettings = this.onClickCommandSettings.bind(this);
this.onClickCommandUnpin = this.onClickCommandUnpin.bind(this);
this.onClickedEditableText = this.onClickedEditableText.bind(this);
this.onValidateEditableText = this.onValidateEditableText.bind(this);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
static _createRecordLocalId(data) {
return `${this.modelName}_${data.channelId}`;
}
/**
* @private
* @returns {string}
*/
_computeAvatarUrl() {
switch (this.channelType) {
case 'channel':
return `/web/image/mail.channel/${this.channelId}/image_128`;
case 'chat':
return this.channel.correspondent.avatarUrl;
}
}
/**
* @private
* @returns {mail.thread}
*/
_computeChannel() {
return link(this.messaging.models['mail.thread'].findFromIdentifyingData({
id: this.channelId,
model: 'mail.channel',
}));
}
/**
* @private
* @returns {integer}
*/
_computeCounter() {
switch (this.channelType) {
case 'channel':
return this.channel.message_needaction_counter;
case 'chat':
return this.channel.localMessageUnreadCounter;
}
}
/**
* @private
* @returns {boolean}
*/
_computeHasLeaveCommand() {
return this.channelType === 'channel' &&
!this.channel.message_needaction_counter &&
!this.channel.group_based_subscription;
}
/**
* @private
* @returns {boolean}
*/
_computeHasRenameCommand() {
return this.channelType === 'chat';
}
/**
* @private
* @returns {boolean}
*/
_computeHasSettingsCommand() {
return this.channelType === 'channel';
}
/**
* @private
* @returns {boolean}
*/
_computeHasUnpinCommand() {
return this.channelType === 'chat' && !this.channel.localMessageUnreadCounter;
}
/**
* @private
* @returns {boolean}
*/
_computeIsActive() {
return this.messaging.discuss && this.channel === this.messaging.discuss.thread;
}
/**
* @private
* @returns {boolean}
*/
_computeIsRenaming() {
return this.messaging.discuss && this.hasRenameCommand && this.messaging.discuss.renamingThreads.includes(this.channel);
}
/**
* @private
* @returns {boolean}
*/
_computeIsUnread() {
if (!this.channel) {
return clear();
}
return this.channel.localMessageUnreadCounter > 0;
}
/**
* @private
* @returns {boolean}
*/
_computeHasThreadIcon() {
switch (this.channelType) {
case 'channel':
case 'chat':
return true;
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @param {MouseEvent} ev
*/
onCancelRenaming(ev) {
this.messaging.discuss.cancelThreadRenaming(this.channel);
}
/**
* @param {MouseEvent} ev
*/
onClick(ev) {
if (isEventHandled(ev, 'EditableText.click')) {
return;
}
this.channel.open();
}
/**
* @param {MouseEvent} ev
*/
async onClickCommandLeave(ev) {
ev.stopPropagation();
if (this.channel.creator === this.messaging.currentUser) {
await this._askAdminConfirmation();
}
this.channel.unsubscribe();
}
/**
* @param {MouseEvent} ev
*/
onClickCommandRename(ev) {
ev.stopPropagation();
this.messaging.discuss.setThreadRenaming(this.channel);
}
/**
* Redirects to channel form page when `settings` command is clicked.
*
* @param {MouseEvent} ev
*/
onClickCommandSettings(ev) {
ev.stopPropagation();
return this.env.bus.trigger('do-action', {
action: {
type: 'ir.actions.act_window',
res_model: this.channel.model,
res_id: this.channel.id,
views: [[false, 'form']],
target: 'current',
},
});
}
/**
* @param {MouseEvent} ev
*/
onClickCommandUnpin(ev) {
ev.stopPropagation();
this.channel.unsubscribe();
}
/**
* Stop propagation to prevent selecting this item.
*
* @param {CustomEvent} ev
*/
onClickedEditableText(ev) {
ev.stopPropagation();
}
/**
* @param {CustomEvent} ev
* @param {Object} ev.detail
* @param {string} ev.detail.newName
*/
onValidateEditableText(ev) {
ev.stopPropagation();
this.messaging.discuss.onValidateEditableText(this.channel, ev.detail.newName);
}
/**
* @private
* @returns {Promise}
*/
_askAdminConfirmation() {
return new Promise(resolve => {
Dialog.confirm(this,
this.env._t("You are the administrator of this channel. Are you sure you want to leave?"),
{
buttons: [
{
text: this.env._t("Leave"),
classes: 'btn-primary',
close: true,
click: resolve,
},
{
text: this.env._t("Discard"),
close: true,
},
],
}
);
});
}
}
DiscussSidebarCategoryItem.fields = {
/**
* Image URL for the related channel thread.
*/
avatarUrl: attr({
compute: '_computeAvatarUrl',
}),
/**
* Amount of unread/action-needed messages
*/
counter: attr({
compute: '_computeCounter',
}),
/**
* Boolean determines whether the item has a "leave" command
*/
hasLeaveCommand: attr({
compute: '_computeHasLeaveCommand',
}),
/**
* Boolean determines whether the item has a "rename" command.
*/
hasRenameCommand: attr({
compute: '_computeHasRenameCommand',
}),
/**
* Boolean determines whether the item has a "settings" command.
*/
hasSettingsCommand: attr({
compute: '_computeHasSettingsCommand',
}),
/**
* Boolean determines whether ThreadIcon will be displayed in UI.
*/
hasThreadIcon: attr({
compute: '_computeHasThreadIcon',
}),
/**
* Boolean determines whether the item has a "unpin" command.
*/
hasUnpinCommand: attr({
compute: '_computeHasUnpinCommand',
}),
/**
* Boolean determines whether the item is currently active in discuss.
*/
isActive: attr({
compute: '_computeIsActive',
}),
/**
* Boolean determines whether the item is currently being renamed.
*/
isRenaming: attr({
compute: '_computeIsRenaming',
}),
/**
* Boolean determines whether the item has any unread messages.
*/
isUnread: attr({
compute: '_computeIsUnread',
}),
/**
* The related channel thread.
*/
channel: one2one('mail.thread', {
compute: '_computeChannel',
}),
/**
* Id of the related channel thread.
*/
channelId: attr({
required: true,
}),
/**
* Type of the related channel thread.
*/
channelType: attr({
related: 'channel.channel_type',
}),
};
DiscussSidebarCategoryItem.modelName = 'mail.discuss_sidebar_category_item';
return DiscussSidebarCategoryItem;
}
registerNewModel('mail.discuss_sidebar_category_item', factory);
@@ -4,7 +4,6 @@ import { registerNewModel } from '@mail/model/model_core';
import { executeGracefully } from '@mail/utils/utils';
import { create, link, insert } from '@mail/model/model_field_command';
function factory(dependencies) {
class MessagingInitializer extends dependencies['mail.model'] {
@@ -67,6 +66,7 @@ function factory(dependencies) {
* @param {Object[]} param0.channels
* @param {Object} param0.current_partner
* @param {integer} param0.current_user_id
* @param {Object} param0.current_user_settings
* @param {Object} [param0.mail_failures={}]
* @param {integer} [param0.needaction_inbox_counter=0]
* @param {Object} param0.partner_root
@@ -79,6 +79,7 @@ function factory(dependencies) {
commands = [],
current_partner,
current_user_id,
current_user_settings,
mail_failures = {},
menu_id,
needaction_inbox_counter = 0,
@@ -101,6 +102,8 @@ function factory(dependencies) {
needaction_inbox_counter,
starred_counter,
});
// init mail user settings
this._initResUsersSettings(current_user_settings);
// various suggestions in no particular order
this._initCannedResponses(shortcodes);
this._initCommands();
@@ -206,6 +209,43 @@ function factory(dependencies) {
this.messaging.notificationGroupManager.computeGroups();
}
/**
* @param {object} resUsersSettings
* @param {integer} resUsersSettings.id
* @param {boolean} resUsersSettings.is_discuss_sidebar_category_channel_open
* @param {boolean} resUsersSettings.is_discuss_sidebar_category_chat_open
*/
_initResUsersSettings({ id, is_discuss_sidebar_category_channel_open, is_discuss_sidebar_category_chat_open }) {
this.messaging.currentUser.update({ resUsersSettingsId: id });
this.messaging.discuss.update({
categoryChannel: create({
autocompleteMethod: 'channel',
commandAddTitleText: this.env._t("Add or join a channel"),
counterComputeMethod: 'needaction',
hasAddCommand: true,
hasViewCommand: true,
isServerOpen: is_discuss_sidebar_category_channel_open,
name: this.env._t("Channels"),
newItemPlaceholderText: this.env._t("Find or create a channel..."),
serverStateKey: 'is_discuss_sidebar_category_channel_open',
sortComputeMethod: 'name',
supportedChannelTypes: ['channel'],
}),
categoryChat: create({
autocompleteMethod: 'chat',
commandAddTitleText: this.env._t("Start a conversation"),
counterComputeMethod: 'unread',
hasAddCommand: true,
isServerOpen: is_discuss_sidebar_category_chat_open,
name: this.env._t("Direct Messages"),
newItemPlaceholderText: this.env._t("Find or start a conversation..."),
serverStateKey: 'is_discuss_sidebar_category_chat_open',
sortComputeMethod: 'last_action',
supportedChannelTypes: ['chat'],
}),
});
}
/**
* @private
* @param {Object} current_partner
@@ -4,6 +4,8 @@ import { registerNewModel } from '@mail/model/model_core';
import { decrement, increment, insert, link } from '@mail/model/model_field_command';
import { htmlToTextContentInline } from '@mail/js/utils';
import { str_to_datetime } from 'web.time';
const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
function factory(dependencies) {
@@ -81,8 +83,12 @@ function factory(dependencies) {
return this._handleNotificationChannelDescriptionChanged(message.payload);
case 'mail.channel_joined':
return this._handleNotificationChannelJoined(message.payload);
case 'mail.channel_last_interest_dt_changed':
return this._handleNotificationChannelLastInterestDateTimeChanged(message.payload);
case 'mail.channel_rename':
return this._handleNotificationChannelRenamed(message.payload);
case 'res.users_settings_changed':
return this._handleNotificationResUsersSettings(message.payload);
}
}
const [, model, id] = channel;
@@ -226,6 +232,24 @@ function factory(dependencies) {
channel.update({ name });
}
/**
* @private
* @param {object} payload
* @param {integer} payload.id
* @param {string} payload.last_interest_dt
*/
_handleNotificationChannelLastInterestDateTimeChanged({ id, last_interest_dt }) {
const channel = this.messaging.models['mail.thread'].findFromIdentifyingData({
id: id,
model: 'mail.channel',
});
if (channel) {
channel.update({
lastInterestDateTime: str_to_datetime(last_interest_dt),
});
}
}
/**
* @private
* @param {integer} channelId
@@ -386,6 +410,25 @@ function factory(dependencies) {
}
}
/**
* @private
* @param {object} settings
* @param {boolean} [settings.is_discuss_sidebar_category_channel_open]
* @param {boolean} [settings.is_discuss_sidebar_category_chat_open]
*/
_handleNotificationResUsersSettings(settings) {
if ('is_discuss_sidebar_category_channel_open' in settings) {
this.messaging.discuss.categoryChannel.update({
isServerOpen: settings.is_discuss_sidebar_category_channel_open,
});
}
if ('is_discuss_sidebar_category_chat_open' in settings) {
this.messaging.discuss.categoryChat.update({
isServerOpen: settings.is_discuss_sidebar_category_chat_open,
});
}
}
/**
* @private
* @param {Object} data
@@ -9,6 +9,8 @@ import Timer from '@mail/utils/timer/timer';
import { cleanSearchTerm } from '@mail/utils/utils';
import * as mailUtils from '@mail/js/utils';
import { str_to_datetime } from 'web.time';
function factory(dependencies) {
class Thread extends dependencies['mail.model'] {
@@ -99,6 +101,14 @@ function factory(dependencies) {
return res;
}
/**
* @override
*/
_created() {
this.onClick = this.onClick.bind(this);
return super._created();
}
/**
* @override
*/
@@ -185,6 +195,9 @@ function factory(dependencies) {
if ('is_pinned' in data) {
data2.isServerPinned = data.is_pinned;
}
if ('last_interest_dt' in data && data.last_interest_dt) {
data2.lastInterestDateTime = str_to_datetime(data.last_interest_dt);
}
if ('last_message' in data && data.last_message) {
const messageData = this.messaging.models['mail.message'].convertData({
id: data.last_message.id,
@@ -1640,6 +1653,13 @@ function factory(dependencies) {
// Handlers
//----------------------------------------------------------------------
/**
* Event handler for clicking thread in discuss app.
*/
async onClick() {
await this.open();
}
/**
* @private
*/
@@ -1840,6 +1860,12 @@ function factory(dependencies) {
isTemporary: attr({
default: false,
}),
/**
* States the date and time of the last interesting event that happened
* in this channel for this partner. This includes: creating, joining,
* pinning, and new message posted. It is contained as a Date object.
*/
lastInterestDateTime: attr(),
lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
compute: '_computeLastCurrentPartnerMessageSeenByEveryone',
}),
@@ -224,6 +224,10 @@ function factory(dependencies) {
partner: one2one('mail.partner', {
inverse: 'user',
}),
/**
* Id of this user's res.users.settings record.
*/
resUsersSettingsId: attr(),
};
User.modelName = 'mail.user';
@@ -19,6 +19,10 @@ $o-mail-chat-window-header-height-mobile: 46px !default;
$o-mail-discuss-message-highlight-indicator-width: 4px !default;
$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default;
$o-mail-discuss-sidebar-active-indicator-width: 3px !default;
$o-mail-discuss-sidebar-category-title-icon-size: 15px !default;
$o-mail-discuss-sidebar-category-item-margin: 3px !default;
$o-mail-discuss-sidebar-category-item-avatar-left-margin: $o-mail-discuss-sidebar-category-title-icon-size + $o-mail-discuss-sidebar-category-item-margin !default;
$o-mail-discuss-sidebar-category-item-avatar-size: 30px !default;
$o-mail-discuss-sidebar-scrollbar-width: 15px !default;
$o-mail-message-sidebar-width: 50px;
@@ -1,5 +1,7 @@
/** @odoo-module **/
import { datetime_to_str } from 'web.time';
/**
* Allows to generate mocked models that will be used by the mocked server.
* This is defined as a class to allow patches by dependent modules and a new
@@ -76,6 +78,10 @@ export class MockModels {
is_minimized: { string: "isMinimized", type: "boolean", default: false },
// In python this belongs to mail.channel.partner. Here for simplicity.
is_pinned: { string: "isPinned", type: "boolean", default: true },
// In python this belongs to mail.channel.partner. Here for simplicity.
last_interest_dt: { string: "Last Interest", type: "datetime", default() {
return datetime_to_str(new Date());
} },
members: { string: "Members", type: 'many2many', relation: 'res.partner', default() { return [this.currentPartnerId]; } },
message_unread_counter: { string: "# unread messages", type: 'integer' },
name: { string: "Name", type: "char", required: true },
@@ -179,6 +185,14 @@ export class MockModels {
},
records: [],
},
'res.users.settings': {
fields: {
is_discuss_sidebar_category_channel_open: { string: "Is Discuss Sidebar Category Channel Open?", type: 'boolean', default: true },
is_discuss_sidebar_category_chat_open: { string: "Is Discuss Sidebar Category Chat Open?", type: 'boolean', default: true },
user_id: { string: "User Id", type: 'many2one', relation: 'res.users' },
},
records: [],
},
'res.country': {
fields: {
code: { string: "Code", type: 'char' },
@@ -3,6 +3,7 @@
import { nextAnimationFrame } from '@mail/utils/test_utils';
import MockServer from 'web.MockServer';
import { datetime_to_str } from 'web.time';
MockServer.include({
/**
@@ -246,6 +247,15 @@ MockServer.include({
if (args.model === 'mail.message' && args.method === 'unstar_all') {
return this._mockMailMessageUnstarAll();
}
if (args.model === 'res.users.settings' && args.method === '_find_or_create_for_user') {
const user_id = args.args[0][0];
return this._mockResUsersSettings_FindOrCreateForUser(user_id);
}
if (args.model === 'res.users.settings' && args.method === 'set_res_users_settings') {
const id = args.args[0][0];
const newSettings = args.kwargs.new_settings;
return this._mockResUsersSettingsSetResUsersSettings(id, newSettings);
}
// res.partner methods
if (args.method === 'get_mention_suggestions') {
if (args.model === 'mail.channel') {
@@ -710,6 +720,7 @@ MockServer.include({
channel_type: 'chat',
is_minimized: true,
is_pinned: true,
last_interest_dt: datetime_to_str(new Date()),
members: [[6, 0, partners_to]],
name: partners.map(partner => partner.name).join(", "),
public: 'private',
@@ -965,11 +976,14 @@ MockServer.include({
_mockMailChannelMessagePost(id, kwargs, context) {
const message_type = kwargs.message_type || 'notification';
const channel = this._getRecords('mail.channel', [['id', '=', id]])[0];
if (channel.channel_type !== 'channel' && !channel.is_pinned) {
if (channel.channel_type !== 'channel') {
// channel.partner not handled here for simplicity
this._mockWrite('mail.channel', [
[channel.id],
{ is_pinned: true },
{
last_interest_dt: datetime_to_str(new Date()),
is_pinned: true,
},
]);
}
const messageId = this._mockMailThreadMessagePost(
@@ -1570,6 +1584,22 @@ MockServer.include({
const channels = this._getRecords('mail.channel', [['id', '=', message.res_id]]);
for (const channel of channels) {
notifications.push([[false, 'mail.channel', channel.id], messageFormat]);
// notify update of last_interest_dt
const now = datetime_to_str(new Date());
this._mockWrite('mail.channel',
[channel.id],
{ last_interest_dt: now },
);
for (const member of channel.members) {
notifications.push([[false, 'res.partner', member], {
type: 'mail.channel_last_interest_dt_changed',
payload: {
id: channel.id,
last_interest_dt: now,
},
}]);
}
}
this._widget.call('bus_service', 'trigger', 'notification', notifications);
},
@@ -1623,6 +1653,50 @@ MockServer.include({
...directMessages,
];
},
/**
* Simulates `_find_or_create_for_user` on `res.users.settings`.
*
* @param {Object} user
* @returns {Object}
*/
_mockResUsersSettings_FindOrCreateForUser(user_id) {
let settings = this._getRecords('res.users.settings', [['user_id', '=', user_id]])[0];
if (!settings) {
const settingsId = this._mockCreate('res.users.settings', { user_id: user_id });
settings = this._getRecords('res.users.settings', [['id', '=', settingsId]])[0];
}
return settings;
},
/**
* Simulates `set_res_users_settings` on `res.users.settings`.
*
* @param {integer} id
* @param {Object} newSettings
*/
_mockResUsersSettingsSetResUsersSettings(id, newSettings) {
const oldSettings = this._getRecords('res.users.settings', [['id', '=', id]])[0];
const changedSettings = {};
for (const setting in newSettings) {
if (setting in oldSettings && newSettings[setting] !== oldSettings[setting]) {
changedSettings[setting] = newSettings[setting];
}
}
this._mockWrite('res.users.settings', [
[id],
changedSettings,
]);
const notification = [
['dbName', 'res.partner', this.currentPartnerId],
{
type: 'res.users_settings_changed',
payload: changedSettings,
},
];
this._widget.call('bus_service', 'trigger', 'notification', [notification]);
},
/**
* Simulates `get_mention_suggestions` on `res.partner`.
*
@@ -1806,6 +1880,7 @@ MockServer.include({
channels: this._mockMailChannelChannelInfo(this._mockResPartner_GetChannelsAsMember(user.partner_id).map(channel => channel.id)),
current_partner: this._mockResPartnerMailPartnerFormat(user.partner_id).get(user.partner_id),
current_user_id: this.currentUserId,
current_user_settings: this._mockResUsersSettings_FindOrCreateForUser(user.id),
mail_failures: this._mockResPartner_MessageFetchFailed(user.partner_id),
menu_id: false, // not useful in QUnit tests
needaction_inbox_counter: this._mockResPartner_GetNeedactionCount(user.partner_id),
+1
View File
@@ -7,4 +7,5 @@ from . import test_mail_full_composer
from . import test_mail_render
from . import test_mail_tools
from . import test_res_partner
from . import test_res_users_settings
from . import test_update_notification
+39 -1
View File
@@ -1,13 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from datetime import datetime
from unittest.mock import patch
from odoo import Command, fields
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError
from odoo.tests import tagged, Form
from odoo.tests.common import users
from odoo.tools import mute_logger
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
@tagged('mail_channel')
@@ -196,6 +200,27 @@ class TestChannelInternals(MailCommon):
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
self.assertEqual(channel.channel_partner_ids, self.env['res.partner'])
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_chat_message_post_should_update_last_interest_dt(self):
channel_info = self.env['mail.channel'].with_user(self.user_admin).channel_get((self.partner_employee | self.user_admin.partner_id).ids)
chat = self.env['mail.channel'].with_user(self.user_admin).browse(channel_info['id'])
post_time = fields.Datetime.now()
# Mocks the return value of field.Datetime.now(),
# so we can see if the `last_interest_dt` is updated correctly
with patch.object(fields.Datetime, 'now', lambda: post_time):
chat.message_post(body="Test", message_type='comment', subtype_xmlid='mail.mt_comment')
channel_partner_employee = self.env['mail.channel.partner'].search([
('partner_id', '=', self.partner_employee.id),
('channel_id', '=', chat.id),
])
channel_partner_admin = self.env['mail.channel.partner'].search([
('partner_id', '=', self.partner_admin.id),
('channel_id', '=', chat.id),
])
self.assertEqual(channel_partner_employee.last_interest_dt, post_time)
self.assertEqual(channel_partner_admin.last_interest_dt, post_time)
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_recipients_channel(self):
@@ -290,6 +315,19 @@ class TestChannelInternals(MailCommon):
same_solo_channel_info = self.env['mail.channel'].channel_get(partners_to=self.partner_employee_nomail.ids)
self.assertEqual(same_solo_channel_info['id'], solo_channel_info['id'])
# `channel_get` will pin the channel by default and thus last interest will be updated.
@users('employee')
def test_channel_info_get_should_update_last_interest_dt(self):
# create the channel via `channel_get`
self.env['mail.channel'].channel_get(partners_to=self.partner_admin.ids)
retrieve_time = datetime(2021, 1, 1, 0, 0)
with patch.object(fields.Datetime, 'now', lambda: retrieve_time):
# `last_interest_dt` should be updated again when `channel_get` is called
# because `channel_pin` is called.
channel_info = self.env['mail.channel'].channel_get(partners_to=self.partner_admin.ids)
self.assertEqual(channel_info['last_interest_dt'], retrieve_time.strftime(DEFAULT_SERVER_DATETIME_FORMAT))
@users('employee')
def test_channel_info_seen(self):
""" In case of concurrent channel_seen RPC, ensure the oldest call has no effect. """
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests.common import users
class TestResUsersSettings(MailCommon):
@users('employee')
def test_find_or_create_for_user_should_create_record_if_not_existing(self):
settings = self.user_employee.res_users_settings_ids
self.assertFalse(settings, "no records should exist")
self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
settings = self.user_employee.res_users_settings_ids
self.assertTrue(settings, "a record should be created after _find_or_create_for_user is called")
@users('employee')
def test_find_or_create_for_user_should_return_correct_res_users_settings(self):
settings = self.env['res.users.settings'].create({
'user_id': self.user_employee.id,
})
result = self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
self.assertEqual(result, settings, "Correct mail user settings should be returned")
@users('employee')
def test_set_res_users_settings_should_send_notification_on_bus(self):
settings = self.env['res.users.settings'].create({
'is_discuss_sidebar_category_channel_open': False,
'is_discuss_sidebar_category_chat_open': False,
'user_id': self.user_employee.id,
})
with self.assertBus(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{
"type": "res.users_settings_changed",
"payload": {"is_discuss_sidebar_category_chat_open": True}
}]):
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
@users('employee')
def test_set_res_users_settings_should_set_settings_properly(self):
settings = self.env['res.users.settings'].create({
'is_discuss_sidebar_category_channel_open': False,
'is_discuss_sidebar_category_chat_open': False,
'user_id': self.user_employee.id,
})
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
self.assertEqual(
settings.is_discuss_sidebar_category_chat_open,
True,
"category state should be updated correctly"
)
+6
View File
@@ -93,6 +93,12 @@
parent="mail.mail_menu_technical"
sequence="22"/>
<menuitem id="res_users_settings_menu"
name="User Settings"
action="res_users_settings_action"
parent="mail.mail_menu_technical"
sequence="50"/>
<!--
This menuitem will be activated by integrations modules (like github, twitter, ...). It
is a hook to ease other modules to plug into mail.
@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<odoo>
<record id="res_users_settings_view_tree" model="ir.ui.view">
<field name="name">res.users.settings.tree</field>
<field name="model">res.users.settings</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="User Settings">
<field name="id"/>
<field name="user_id"/>
</tree>
</field>
</record>
<record id="res_users_settings_action" model="ir.actions.act_window">
<field name="name">User Settings</field>
<field name="res_model">res.users.settings</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
@@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta
from odoo import Command
from odoo.tests.common import users, tagged, TransactionCase, warmup
from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
@tagged('post_install', '-at_install')
@@ -59,6 +59,7 @@ class TestDiscussFullPerformance(TransactionCase):
def test_init_messaging(self):
"""Test performance of `_init_messaging`."""
channel_general = self.env.ref('mail.channel_all_employees') # Unfortunately #general cannot be deleted. Assertions below assume data from a fresh db with demo.
self.env['mail.channel'].search([('id', '!=', channel_general.id)]).unlink()
user_root = self.env.ref('base.user_root')
# create public channels
channel_public_1 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='public 1', privacy='public')['id'])
@@ -95,8 +96,8 @@ class TestDiscussFullPerformance(TransactionCase):
self.users[0].flush()
self.users[0].invalidate_cache()
with self.assertQueryCount(emp=33):
init_messaging = self.users[0]._init_messaging()
with self.assertQueryCount(emp=48):
init_messaging = self.users[0].with_user(self.users[0])._init_messaging()
self.assertEqual(init_messaging, {
'needaction_inbox_counter': 1,
@@ -111,9 +112,10 @@ class TestDiscussFullPerformance(TransactionCase):
'id': channel_general.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_general.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_general._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 3,
'message_unread_counter': 5,
'name': 'general',
'public': 'groups',
'seen_message_id': False,
@@ -123,100 +125,127 @@ class TestDiscussFullPerformance(TransactionCase):
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_public_1.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_public_1.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_public_1._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_needaction_counter': 1,
'message_unread_counter': 0,
'name': 'public 1',
'public': 'public',
'seen_message_id': next(res['message_id'] for res in channel_public_1._channel_last_message_ids()),
'state': 'open',
'uuid': channel_public_1.uuid,
},
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_public_2.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_public_2.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_public_2._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 0,
'name': 'public 2',
'public': 'public',
'seen_message_id': next(res['message_id'] for res in channel_public_2._channel_last_message_ids()),
'state': 'open',
'uuid': channel_public_2.uuid,
},
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_group_1.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_group_1.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_group_1._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 0,
'name': 'group 1',
'public': 'groups',
'seen_message_id': next(res['message_id'] for res in channel_group_1._channel_last_message_ids()),
'state': 'open',
'uuid': channel_group_1.uuid,
},
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_group_2.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_group_2.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_group_2._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 0,
'name': 'group 2',
'public': 'groups',
'seen_message_id': next(res['message_id'] for res in channel_group_2._channel_last_message_ids()),
'state': 'open',
'uuid': channel_group_2.uuid,
},
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_private_1.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_private_1.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_private_1._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 0,
'name': 'private 1',
'public': 'private',
'seen_message_id': next(res['message_id'] for res in channel_private_1._channel_last_message_ids()),
'state': 'open',
'uuid': channel_private_1.uuid,
},
{
'channel_type': 'channel',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_private_2.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_private_2.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_private_2._channel_last_message_ids()),
'message_needaction_counter': 0,
'message_unread_counter': 0,
'name': 'private 2',
'public': 'private',
'seen_message_id': next(res['message_id'] for res in channel_private_2._channel_last_message_ids()),
'state': 'open',
'uuid': channel_private_2.uuid,
},
{
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_dm_1.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_dm_1.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': False,
'members': [
{
@@ -260,16 +289,20 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': False,
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_dm_1.uuid,
},
{
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_dm_2.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_dm_2.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': False,
'members': [
{
@@ -313,16 +346,20 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': False,
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_dm_2.uuid,
},
{
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_dm_3.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_dm_3.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': False,
'members': [
{
@@ -366,16 +403,20 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': False,
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_dm_3.uuid,
},
{
'channel_type': 'chat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_dm_4.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_dm_4.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': False,
'members': [
{
@@ -419,16 +460,20 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': False,
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_dm_4.uuid,
},
{
'channel_type': 'livechat',
'create_uid': self.env.user.id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_livechat_1.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_livechat_1.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_livechat_1._channel_last_message_ids()),
'livechat_visitor': {
'country': False,
@@ -478,16 +523,20 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': next(res['message_id'] for res in channel_livechat_1._channel_last_message_ids()),
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_livechat_1.uuid,
},
{
'channel_type': 'livechat',
'create_uid': self.env.ref('base.public_user').id,
'custom_channel_name': False,
'description': False,
'group_based_subscription': False,
'id': channel_livechat_2.id,
'is_minimized': False,
'is_pinned': True,
'last_interest_dt': channel_livechat_2.channel_last_seen_partner_ids.filtered(lambda p: p.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
'last_message_id': next(res['message_id'] for res in channel_livechat_2._channel_last_message_ids()),
'livechat_visitor': {
'country': (self.env.ref('base.be').id, 'Belgium'),
@@ -537,6 +586,7 @@ class TestDiscussFullPerformance(TransactionCase):
'seen_message_id': False,
},
],
'seen_message_id': False,
'state': 'open',
'uuid': channel_livechat_2.uuid,
},
@@ -591,4 +641,11 @@ class TestDiscussFullPerformance(TransactionCase):
'user_id': self.users[0].id,
},
'current_user_id': self.users[0].id,
'current_user_settings': {
'id': self.env['res.users.settings']._find_or_create_for_user(self.users[0]).id,
'is_discuss_sidebar_category_channel_open': True,
'is_discuss_sidebar_category_chat_open': True,
'is_discuss_sidebar_category_livechat_open': True,
'user_id': (self.users[0].id, 'Ernest Employee'),
},
})