[FIX] mail: fix discuss search filters and related issues

- don't reset `stringifiedDomain` when changing thread (this led to the
  control panel displaying obsolete filters)
- refresh non-main cache that are currently displayed when there is a known
  change of messages in the same thread's main cache
- refresh non-main-cache on displaying them, to fetch potentially new messages
- fix discuss "select all" conditions

task-2171873
task-2282374

closes odoo/odoo#59644

X-original-commit: b89dc5f949b3ad062fc4197a4152066c207e1bf9
Related: odoo/enterprise#13962
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2020-10-09 12:56:24 +00:00
parent 4c2a03aa8f
commit a8dad56d6b
11 changed files with 524 additions and 48 deletions
@@ -0,0 +1,407 @@
odoo.define('mail/static/src/components/discuss/tests/discuss_domain_tests.js', function (require) {
'use strict';
const {
afterEach,
afterNextRender,
beforeEach,
start,
} = require('mail/static/src/utils/test_utils.js');
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss', {}, function () {
QUnit.module('discuss_domain_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { afterEvent, env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.afterEvent = afterEvent;
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('discuss should filter messages based on given domain', async function (assert) {
assert.expect(2);
this.data['mail.message'].records.push({
body: "test",
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
}, {
body: "not empty",
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
});
await this.start();
assert.containsN(
document.body,
'.o_Message',
2,
"should have 2 messages in Inbox initially"
);
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
// simulate control panel search
this.env.messaging.discuss.update({
stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
});
},
message: "should wait until search filter is applied",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.box' &&
threadViewer.thread.id === 'inbox'
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should only have the 1 message containing 'test' remaining after doing a search"
);
});
QUnit.test('discuss should keep filter domain on changing thread', async function (assert) {
assert.expect(3);
this.data['mail.channel'].records.push({ id: 20 });
this.data['mail.message'].records.push({
body: "test",
channel_ids: [20],
}, {
body: "not empty",
channel_ids: [20],
});
await this.start();
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
});
assert.containsNone(
document.body,
'.o_Message',
"should have no message in Inbox initially"
);
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
// simulate control panel search
this.env.messaging.discuss.update({
stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
});
},
message: "should wait until search filter is applied",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.box' &&
threadViewer.thread.id === 'inbox'
);
},
});
assert.containsNone(
document.body,
'.o_Message',
"should have still no message in Inbox after doing a search"
);
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"]
`).click();
},
message: "should wait until channel 20 is loaded after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should only have the 1 message containing 'test' in channel 20 (due to the domain still applied on changing thread)"
);
});
QUnit.test('discuss should refresh filtered thread on receiving new message', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({ id: 20 });
await this.start();
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
});
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"]
`).click();
},
message: "should wait until channel 20 is loaded after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20
);
},
});
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
// simulate control panel search
this.env.messaging.discuss.update({
stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
});
},
message: "should wait until search filter is applied",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20
);
},
});
assert.containsNone(
document.body,
'.o_Message',
"should have initially no message in channel 20 matching the search 'test'"
);
// simulate receiving a message
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => this.env.services.rpc({
route: '/mail/chat_post',
params: {
uuid: channel.uuid,
message_content: "test",
},
}),
message: "should wait until channel 20 refreshed its filtered message list",
predicate: data => {
return (
data.threadViewer.thread.model === 'mail.channel' &&
data.threadViewer.thread.id === 20 &&
data.hint.type === 'messages-loaded'
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should only have the 1 message containing 'test' in channel 20 after just receiving it"
);
});
QUnit.test('discuss should refresh filtered thread on changing thread', async function (assert) {
assert.expect(4);
this.data['mail.channel'].records.push({ id: 20 }, { id: 21 });
await this.start();
const channel20 = this.env.models['mail.thread'].findFromIdentifyingData({
id: 20,
model: 'mail.channel',
});
const channel21 = this.env.models['mail.thread'].findFromIdentifyingData({
id: 21,
model: 'mail.channel',
});
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"]
`).click();
},
message: "should wait until channel 20 is loaded after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20
);
},
});
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
// simulate control panel search
this.env.messaging.discuss.update({
stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
});
},
message: "should wait until search filter is applied",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20
);
},
});
assert.containsNone(
document.body,
'.o_Message',
"should have initially no message in channel 20 matching the search 'test'"
);
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${channel21.localId}"]
`).click();
},
message: "should wait until channel 21 is loaded after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 21
);
},
});
assert.containsNone(
document.body,
'.o_Message',
"should have no message in channel 21 matching the search 'test'"
);
// simulate receiving a message on channel 20 while channel 21 is displayed
await this.env.services.rpc({
route: '/mail/chat_post',
params: {
uuid: channel20.uuid,
message_content: "test",
},
});
assert.containsNone(
document.body,
'.o_Message',
"should still have no message in channel 21 matching the search 'test' after receiving a message on channel 20"
);
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"]
`).click();
},
message: "should wait until channel 20 is loaded with the new message after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 20 &&
threadViewer.threadCache.fetchedMessages.length === 1
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have the 1 message containing 'test' in channel 20 when displaying it, after having received the message while the channel was not visible"
);
});
QUnit.test('select all and unselect all buttons should work on filtered thread', async function (assert) {
assert.expect(4);
this.data['mail.channel'].records.push({
id: 20,
is_moderator: true,
moderation: true,
name: "general",
});
this.data['mail.message'].records.push({
body: "<p>test</p>",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
});
await this.start();
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
document.querySelector(`
.o_DiscussSidebar_item[data-thread-local-id="${this.env.messaging.moderation.localId}"]
`).click();
},
message: "should wait until moderation box is loaded after clicking on it",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.box' &&
threadViewer.thread.id === 'moderation'
);
},
});
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
// simulate control panel search
this.env.messaging.discuss.update({
stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
});
},
message: "should wait until search filter is applied",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.box' &&
threadViewer.thread.id === 'moderation'
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"should only have the 1 message containing 'test' in moderation box"
);
assert.notOk(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should not be checked initially"
);
await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click());
assert.ok(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should be checked after clicking on 'select all'"
);
await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click());
assert.notOk(
document.querySelector('.o_Message_checkbox').checked,
"the moderation checkbox should be unchecked after clicking on 'unselect all'"
);
});
});
});
});
});
@@ -124,12 +124,18 @@ class MessageList extends Component {
case 'home-menu-shown':
this._adjustFromHomeMenuShown(hint);
break;
case 'messages-loaded':
this.threadView.markComponentHintProcessed(hint);
break;
case 'message-received':
this._adjustFromMessageReceived(hint);
break;
case 'more-messages-loaded':
this._adjustFromMoreMessagesLoaded(hint);
break;
case 'new-messages-loaded':
this.threadView.markComponentHintProcessed(hint);
break;
}
}
this._willPatchSnapshot = undefined;
@@ -333,9 +339,6 @@ class MessageList extends Component {
isProcessed = true;
}
if (isProcessed) {
this.env.messagingBus.trigger('o-component-message-list-thread-cache-changed', {
threadViewer: this.threadView.threadViewer,
});
this.threadView.markComponentHintProcessed(hint);
}
}
@@ -741,13 +741,17 @@ QUnit.test('basic rendering of canceled notification', async function (assert) {
}]],
});
await this.afterEvent({
eventName: 'o-component-message-list-thread-cache-changed',
eventName: 'o-thread-view-hint-processed',
func: () => {
this.createThreadViewComponent(threadViewer.threadView);
},
message: "thread become loaded with messages",
predicate: ({ threadViewer }) => {
return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 11;
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 11
);
},
});
@@ -175,7 +175,6 @@ function factory(dependencies) {
*/
async openThread(thread) {
this.update({
stringifiedDomain: '[]',
thread: [['link', thread]],
});
this.focus();
@@ -509,6 +508,7 @@ function factory(dependencies) {
}),
/**
* Determines the domain to apply when fetching messages for `this.thread`.
* This value should only be written by the control panel.
*/
stringifiedDomain: attr({
default: '[]',
@@ -50,16 +50,11 @@ function factory(dependencies) {
return;
}
const inbox = this.env.messaging.inbox;
if (
!inbox ||
!inbox.mainCache ||
inbox.mainCache.isLoaded ||
inbox.mainCache.isLoading
) {
if (!inbox || !inbox.mainCache) {
return;
}
// populate some needaction messages on threads.
inbox.mainCache.update({ hasToLoadMessages: true });
inbox.mainCache.update({ isCacheRefreshRequested: true });
}
/**
@@ -127,8 +122,6 @@ function factory(dependencies) {
'isOpen',
'messagingInbox',
'messagingInboxMainCache',
'messagingInboxMainCacheIsLoaded',
'messagingInboxMainCacheIsLoading',
],
}),
/**
@@ -152,12 +145,6 @@ function factory(dependencies) {
messagingInboxMainCache: one2one('mail.thread_cache', {
related: 'messagingInbox.mainCache',
}),
messagingInboxMainCacheIsLoaded: attr({
related: 'messagingInboxMainCache.isLoaded',
}),
messagingInboxMainCacheIsLoading: attr({
related: 'messagingInboxMainCache.isLoading',
}),
};
MessagingMenu.modelName = 'mail.messaging_menu';
@@ -551,8 +551,6 @@ function factory(dependencies) {
const inboxMailbox = this.env.messaging.inbox;
// 1. move messages from inbox to history
// AKU TODO: flag other caches to invalidate
// task-2171873
for (const message_id of message_ids) {
// We need to ignore all not yet known messages because we don't want them
// to be shown partially as they would be linked directly to mainCache
@@ -625,10 +623,6 @@ function factory(dependencies) {
continue;
}
message.update({ isStarred: starred });
if (!starred) {
// AKU TODO: flag starred other caches for invalidation
// task-2171873
}
starredMailbox.update({
counter: starred
? starredMailbox.counter + 1
@@ -1803,6 +1803,9 @@ function factory(dependencies) {
suggestedRecipientInfoList: one2many('mail.suggested_recipient_info', {
inverse: 'thread',
}),
threadViews: one2many('mail.thread_view', {
inverse: 'thread',
}),
/**
* States the `mail.activity` that belongs to `this` and that are due
* specifically today.
@@ -1825,9 +1828,6 @@ function factory(dependencies) {
dependencies: ['orderedOtherTypingMembers'],
}),
uuid: attr(),
threadViews: one2many('mail.thread_view', {
inverse: 'thread',
}),
};
Thread.modelName = 'mail.thread';
@@ -20,7 +20,7 @@ function factory(dependencies) {
return;
}
if (!this.isLoaded) {
this.update({ hasToLoadMessages: true });
this.update({ isCacheRefreshRequested: true });
return;
}
this.update({ isLoadingMore: true });
@@ -31,7 +31,7 @@ function factory(dependencies) {
limit,
}));
for (const threadView of this.threadViews) {
threadView.addComponentHint('more-messages-loaded');
threadView.addComponentHint('more-messages-loaded', { fetchedMessages });
}
this.update({ isLoadingMore: false });
if (fetchedMessages.length < limit) {
@@ -48,14 +48,18 @@ function factory(dependencies) {
return;
}
if (!this.isLoaded) {
this.update({ hasToLoadMessages: true });
this.update({ isCacheRefreshRequested: true });
return;
}
const messageIds = this.fetchedMessages.map(message => message.id);
return this._loadMessages({
const fetchedMessages = this._loadMessages({
extraDomain: [['id', '>', Math.max(...messageIds)]],
limit: false,
});
for (const threadView of this.threadViews) {
threadView.addComponentHint('new-messages-loaded', { fetchedMessages });
}
return fetchedMessages;
}
//----------------------------------------------------------------------
@@ -140,8 +144,6 @@ function factory(dependencies) {
}
let messages = this.fetchedMessages;
if (this.stringifiedDomain !== '[]') {
// AKU TODO: flag for invalidation if there are newer messages
// in thread. task-2171873
return [['replace', messages]];
}
// main cache: adjust with newer messages
@@ -178,13 +180,34 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeHasToLoadMessages() {
return (
this.thread &&
!this.thread.isTemporary &&
this.threadViews.length > 0 &&
!this.isLoaded &&
!this.isLoading
);
if (!this.thread) {
// happens during destroy or compute executed in wrong order
return false;
}
const wasCacheRefreshRequested = this.isCacheRefreshRequested;
// mark hint as processed
this.update({ isCacheRefreshRequested: false });
if (this.thread.isTemporary) {
// temporary threads don't exist on the server
return false;
}
if (!wasCacheRefreshRequested && this.threadViews.length === 0) {
// don't load message that won't be used
return false;
}
if (!wasCacheRefreshRequested && (this.isLoaded || this.isLoading)) {
// avoid duplicate RPC
return false;
}
const isMainCache = this.thread.mainCache === this;
if (isMainCache && (this.isLoaded || this.isLoading)) {
// Ignore request on the main cache if it is already loaded or
// loading. Indeed the main cache is automatically sync with
// server updates already, so there is never a need to refresh
// it past the first time.
return false;
}
return true;
}
/**
@@ -279,7 +302,31 @@ function factory(dependencies) {
if (!this.hasToLoadMessages) {
return;
}
this._loadMessages();
this._loadMessages().then(fetchedMessages => {
for (const threadView of this.threadViews) {
threadView.addComponentHint('messages-loaded', { fetchedMessages });
}
});
}
/**
* Handles change of messages on this thread cache. This is useful to
* refresh non-main caches that are currently displayed when the main
* cache receives updates. This is necessary because only the main cache
* is aware of changes in real time.
*/
_onMessagesChanged() {
if (!this.thread) {
return;
}
if (this.thread.mainCache !== this) {
return;
}
for (const threadView of this.thread.threadViews) {
if (threadView.threadCache) {
threadView.threadCache.update({ isCacheRefreshRequested: true });
}
}
}
}
@@ -315,15 +362,19 @@ function factory(dependencies) {
dependencies: ['threadMessages'],
}),
/**
* Determines whether `this` should load initial messages.
* Determines whether `this` should load initial messages. This field is
* computed and should be considered read-only.
* @see `isCacheRefreshRequested` to request manual refresh of messages.
*/
hasToLoadMessages: attr({
compute: '_computeHasToLoadMessages',
dependencies: [
'isCacheRefreshRequested',
'isLoaded',
'isLoading',
'thread',
'threadIsTemporary',
'threadMainCache',
'threadViews',
],
}),
@@ -339,6 +390,14 @@ function factory(dependencies) {
isLoadingMore: attr({
default: false,
}),
/**
* Determines whether `this` should consider refreshing its messages.
* This field is a hint that may or may not lead to an actual refresh.
* @see `hasToLoadMessages`
*/
isCacheRefreshRequested: attr({
default: false,
}),
/**
* Last message that has been fetched by this thread cache.
*
@@ -378,6 +437,18 @@ function factory(dependencies) {
'hasToLoadMessages',
],
}),
/**
* Not a real field, used to trigger `_onMessagesChanged` when one of
* the dependencies changes.
*/
onMessagesChanged: attr({
compute: '_onMessagesChanged',
dependencies: [
'messages',
'thread',
'threadMainCache',
],
}),
/**
* Ordered list of messages that have been fetched by this cache.
*
@@ -408,6 +479,12 @@ function factory(dependencies) {
threadIsTemporary: attr({
related: 'thread.isTemporary',
}),
/**
* Serves as compute dependency.
*/
threadMainCache: many2one('mail.thread_cache', {
related: 'thread.mainCache',
}),
threadMessages: many2many('mail.message', {
related: 'thread.messages',
}),
@@ -118,6 +118,9 @@ function factory(dependencies) {
*/
_onThreadCacheChanged() {
this.addComponentHint('change-of-thread-cache');
if (this.threadCache) {
this.threadCache.update({ isCacheRefreshRequested: true });
}
}
/**
@@ -361,7 +361,7 @@ const DiscussWidget = AbstractAction.extend({
_onClickSelectAll() {
this.env.models['mail.message'].checkAll(
this.discuss.thread,
this.discuss.threadStringifiedDomain
this.discuss.stringifiedDomain
);
},
/**
@@ -370,7 +370,7 @@ const DiscussWidget = AbstractAction.extend({
_onClickUnselectAll() {
this.env.models['mail.message'].uncheckAll(
this.discuss.thread,
this.discuss.threadStringifiedDomain
this.discuss.stringifiedDomain
);
},
/**
+1
View File
@@ -281,6 +281,7 @@
<script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js"/>
<script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js"/>
<script type="text/javascript" src="/mail/static/src/components/dialog_manager/dialog_manager_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_domain_tests.js"/>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_inbox_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_moderation_tests.js"></script>
<script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_pinned_tests.js"></script>