[IMP] mail: open document threads in thread windows
Also roughly called "open chatter in chat windows".
With this commit, document threads can now be detached in a small thread window.
It uses client-side cross-tab window synchronization by means of the local storage.
To detach a document thread in a thread window, click on the document thread preview
in the systray messaging menu when receving a new (needaction) document thread message.
-------------------------
Technical Limitations
-------------------------
1. In order to use this feature, the user must select the Notification Management
'Handle with Odoo' in the Preferences of the User settings (top-right menu).
2. For the moment, only needaction messages automatically updates the thread window. Manual
refresh is still needed for other kinds of messages (e.g. reload document record to fetch
new messages in the document thread).
Server-side code changes are required to receive new document thread message notifications on the
longpoll bus, which would fix the limitations above.
-------------------------
Future Works
-------------------------
1. Receive any new message from the document thread window when it is detached.
2. Log a note from the document thread window.
Task-33118
This commit is contained in:
@@ -71,6 +71,47 @@ var DocumentThread = Thread.extend({
|
||||
this._messageIDs.push(message.getID());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to store the thread's state in the LocalStorage, so that it is
|
||||
* shared between tabs, and restored on F5.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
close: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.call('mail_service', 'updateDocumentThreadState', this._id, {
|
||||
name: this.getName(),
|
||||
windowState: 'closed',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to store the thread's state in the LocalStorage, so that it is
|
||||
* shared between tabs, and restored on F5.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
detach: function () {
|
||||
this._super.apply(this, arguments);
|
||||
var windowState = this._folded ? 'folded' : 'open';
|
||||
this.call('mail_service', 'updateDocumentThreadState', this._id, {
|
||||
name: this.getName(),
|
||||
windowState: windowState,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to store the thread's state in the LocalStorage, so that it is
|
||||
* shared between tabs, and restored on F5.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
fold: function () {
|
||||
this._super.apply(this, arguments);
|
||||
var windowState = this._folded ? 'folded' : 'open';
|
||||
this.call('mail_service', 'updateDocumentThreadState', this._id, {
|
||||
name: this.getName(),
|
||||
windowState: windowState,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get the model name of the document that is linked to this document thread
|
||||
*
|
||||
@@ -151,7 +192,7 @@ var DocumentThread = Thread.extend({
|
||||
_.extend(messageData, {
|
||||
context: data.context,
|
||||
message_type: data.message_type,
|
||||
subtype: data.subtype,
|
||||
subtype: data.subtype || "mail.mt_comment",
|
||||
subtype_id: data.subtype_id,
|
||||
});
|
||||
return self._rpc({
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
odoo.define('mail.Manager.DocumentThread', function (require) {
|
||||
"use strict";
|
||||
|
||||
var MailManager = require('mail.Manager');
|
||||
var DocumentThread = require('mail.model.DocumentThread');
|
||||
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* Document Thread Manager
|
||||
*
|
||||
* This part of the mail manager extends the core functionnalities of mail
|
||||
* manager with document threads, in addition to enable cross tab
|
||||
* synchronization of document thread windows, through the local storage.
|
||||
*
|
||||
* Note that it assumes that the Mail Window Manager is loaded
|
||||
*/
|
||||
MailManager.include({
|
||||
DOCUMENT_THREAD_MESSAGE_KEY: 'mail.document_threads_last_message',
|
||||
DOCUMENT_THREAD_STATE_KEY: 'mail.document_threads_state',
|
||||
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// retrieve the open DocumentThreads from the localStorage
|
||||
var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
|
||||
if (!state) {
|
||||
this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, '{}');
|
||||
} else {
|
||||
this.isReady().then(this._updateDocumentThreadWindows.bind(this, JSON.parse(state)));
|
||||
}
|
||||
// listen to localStorage changes to synchronize DocumentThread's
|
||||
// windows between tabs
|
||||
window.addEventListener('storage', this._onStorage.bind(this));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For new posted message of current user in a document thread: store it in
|
||||
* the localStorage to make it appear on the other tabs.
|
||||
*
|
||||
* Note: 'addMessage' modifies the message in place (by setting the channel_id,
|
||||
* so we must store the message in the localStorage *before* calling
|
||||
* 'addMessage')
|
||||
*
|
||||
* @override
|
||||
* @param {Object} data
|
||||
* @param {Array} [data.author_id] [int, string] where int is server ID of
|
||||
* the author, and string is the name of the author.
|
||||
* @param {integer} data.id server ID of the message
|
||||
* @param {string} [data.model] the model name of the document that this
|
||||
* message is related to.
|
||||
* @param {integer} [data.res_id] the ID of the document that this message
|
||||
* is related to.
|
||||
*
|
||||
*/
|
||||
addMessage: function (data) {
|
||||
var message = this.getMessage(data.id);
|
||||
if (
|
||||
!message &&
|
||||
data.res_id &&
|
||||
data.model &&
|
||||
data.author_id &&
|
||||
data.author_id[0] === session.partner_id
|
||||
) {
|
||||
var key = this.DOCUMENT_THREAD_MESSAGE_KEY;
|
||||
this.call('local_storage', 'setItem', key, JSON.stringify(data));
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Returns a document thread corresponding to the given model and resID.
|
||||
*
|
||||
* @param {string} model of the document thread, if it exists
|
||||
* @return {integer} resID of the document thread, if it exists
|
||||
*/
|
||||
getDocumentThread: function (model, resID) {
|
||||
return this.getThread(model + '_' + resID);
|
||||
},
|
||||
/**
|
||||
* Add a new document thread, or get if it exists already.
|
||||
*
|
||||
* Also, if a name is provided for the document thread, it overwrites the
|
||||
* previous one.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {interger[]} [params.message_ids] the list of message ids linked
|
||||
* to the document (if not given, they will be fetched before fetching the
|
||||
* messages)
|
||||
* @param {string} [params.name] if provided, overwrites the name of the
|
||||
* existing document thread
|
||||
* @param {integer} params.resID
|
||||
* @param {string} params.resModel
|
||||
* @return {mail.model.DocumentThread}
|
||||
*/
|
||||
getOrAddDocumentThread: function (params) {
|
||||
var thread = this.getDocumentThread(params.resModel, params.resID);
|
||||
if (!thread) {
|
||||
thread = new DocumentThread(this, {
|
||||
messageIDs: params.messageIDs,
|
||||
name: params.name,
|
||||
resID: params.resID,
|
||||
resModel: params.resModel,
|
||||
});
|
||||
this._threads.push(thread);
|
||||
} else if (params.name) {
|
||||
if ('messageIDs' in params) {
|
||||
thread.setMessageIDs(params.messageIDs);
|
||||
}
|
||||
// document thread may have a change of name
|
||||
thread.setName(params.name);
|
||||
}
|
||||
return thread;
|
||||
},
|
||||
/**
|
||||
* Open the document thread form if discuss is open
|
||||
*
|
||||
* @override
|
||||
* @param {integer|string} threadID
|
||||
*/
|
||||
openThread: function (threadID) {
|
||||
var thread = this.getThread(threadID);
|
||||
if (
|
||||
thread &&
|
||||
thread.getType() === 'document_thread' &&
|
||||
this._isDiscussOpen()
|
||||
) {
|
||||
var resModel = thread.getDocumentModel();
|
||||
var resID = thread.getDocumentID();
|
||||
this._redirectToDocument(resModel, resID);
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the state of a given document thread (stored in localStorage).
|
||||
* Garbage collects windows previously marked as 'closed' (assuming that the
|
||||
* info has already been processed by the other tabs).
|
||||
*
|
||||
* @param {string} threadID ID of a document thread
|
||||
* @param {Object} state
|
||||
* @param {string} state.name name of the document thread
|
||||
* @param {string} state.windowState ('closed', 'folded' or 'open')
|
||||
*/
|
||||
updateDocumentThreadState: function (threadID, state) {
|
||||
var item = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
|
||||
var states = JSON.parse(item);
|
||||
states = _.omit(states, function (state) {
|
||||
return state.windowState === 'closed';
|
||||
});
|
||||
states[threadID] = state;
|
||||
this.call('local_storage', 'setItem',
|
||||
this.DOCUMENT_THREAD_STATE_KEY,
|
||||
JSON.stringify(states));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For new messages, postprocess document thread linked to this message.
|
||||
* If there is no such document thread, create it and register its ID to
|
||||
* the message's thread IDs.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
* @param {mail.model.Message} message
|
||||
*/
|
||||
_addNewMessagePostprocessThread: function (message) {
|
||||
var resModel = message.getDocumentModel();
|
||||
var resID = message.getDocumentID();
|
||||
if (resModel && resModel !== 'mail.channel' && resID) {
|
||||
this.getOrAddDocumentThread({
|
||||
name: message.getDocumentName(),
|
||||
resID: resID,
|
||||
resModel: resModel,
|
||||
});
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Updates the thread windows related to document threads.
|
||||
*
|
||||
* @param {Object} documentThreadStates - keys are strings 'resModel_resID',
|
||||
* and values are the state of the corresponding document thread ('open'
|
||||
* or 'folded')
|
||||
*/
|
||||
_updateDocumentThreadWindows: function (documentThreadStates) {
|
||||
var self = this;
|
||||
_.each(documentThreadStates, function (state, key) {
|
||||
var info = key.split('_');
|
||||
var documentThread = self.getOrAddDocumentThread({
|
||||
name: state.name,
|
||||
resID: parseInt(info[1]),
|
||||
resModel: info[0],
|
||||
});
|
||||
if (state.windowState === 'closed') {
|
||||
documentThread.close();
|
||||
} else {
|
||||
documentThread.fold(state.windowState === 'folded');
|
||||
self.openThreadWindow(documentThread.getID(), { keepFoldState: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called each time a localStorage key is updated.
|
||||
*
|
||||
* @private
|
||||
* @param {StorageEvent} ev
|
||||
*/
|
||||
_onStorage: function (ev) {
|
||||
if (ev.key === this.DOCUMENT_THREAD_STATE_KEY) {
|
||||
var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY);
|
||||
this._updateDocumentThreadWindows(JSON.parse(state));
|
||||
} else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) {
|
||||
var message = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_MESSAGE_KEY);
|
||||
if (message) {
|
||||
this.addMessage(JSON.parse(message));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return MailManager;
|
||||
|
||||
});
|
||||
@@ -7,6 +7,7 @@ var MailManager = require('mail.Manager');
|
||||
|
||||
require('mail.Manager.Notification');
|
||||
require('mail.Manager.Window');
|
||||
require('mail.Manager.DocumentThread');
|
||||
|
||||
core.serviceRegistry.add('mail_service', MailManager);
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
odoo.define('mail.document_thread_window_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mailTestUtils = require('mail.testUtils');
|
||||
var MessagingMenu = require('mail.systray.MessagingMenu');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
|
||||
QUnit.module('DocumentThreadWindow', {
|
||||
beforeEach: function () {
|
||||
var partnerID = 44;
|
||||
this.data = {
|
||||
'mail.message': {
|
||||
fields: {
|
||||
body: {
|
||||
string: "Contents",
|
||||
type: 'html',
|
||||
},
|
||||
author_id: {
|
||||
string: "Author",
|
||||
type: 'many2one',
|
||||
relation: 'res.partner',
|
||||
},
|
||||
channel_ids: {
|
||||
string: "Channels",
|
||||
type: 'many2many',
|
||||
relation: 'mail.channel',
|
||||
},
|
||||
starred: {
|
||||
string: "Starred",
|
||||
type: 'boolean',
|
||||
},
|
||||
needaction: {
|
||||
string: "Need Action",
|
||||
type: 'boolean',
|
||||
},
|
||||
needaction_partner_ids: {
|
||||
string: 'Needaction Partner IDs',
|
||||
type: 'one2many',
|
||||
relation: 'res.partner',
|
||||
},
|
||||
starred_partner_ids: {
|
||||
string: "partner ids",
|
||||
type: 'integer',
|
||||
}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
author_id: [1, 'Me'],
|
||||
body: '<p>Some Message on a document</p>',
|
||||
channel_ids: [],
|
||||
model: 'some.res.model',
|
||||
record_name: "Some Record",
|
||||
res_id: 1,
|
||||
}, {
|
||||
author_id: [2, "Demo"],
|
||||
body: "<p>*MessageOnDocument*</p>",
|
||||
id: 2,
|
||||
model: 'some.res.model',
|
||||
needaction: true,
|
||||
needaction_partner_ids: [partnerID],
|
||||
record_name: "Some Record",
|
||||
res_id: 1,
|
||||
}],
|
||||
},
|
||||
'some.res.model': {
|
||||
fields: {
|
||||
message_ids: {string: 'Messages', type: 'one2many'},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
message_ids: [1, 2],
|
||||
}],
|
||||
},
|
||||
initMessaging: {
|
||||
channel_slots: {
|
||||
channel_channel: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
this.session = {
|
||||
partner_id: partnerID, // so that needaction messages are treated as needactions
|
||||
};
|
||||
this.services = mailTestUtils.getMailServices();
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('open a document thread in a thread window', function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'read') {
|
||||
assert.deepEqual(args.args, [[1], ['message_ids']],
|
||||
"should read the message_ids on the document");
|
||||
}
|
||||
if (args.method === 'message_format') {
|
||||
assert.deepEqual(args.args[0], [1],
|
||||
"should only fetch the unknown message");
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// toggle the messaging menu and open the documentThread
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
"should display one preview");
|
||||
messagingMenu.$('.o_mail_preview').click();
|
||||
|
||||
assert.strictEqual($('.o_thread_window').length, 1,
|
||||
"should have open the DocumentThread in a thread window");
|
||||
assert.strictEqual($('.o_thread_window .o_thread_message').length, 2,
|
||||
"should have fetched the history of the documentThread");
|
||||
assert.strictEqual($('.o_thread_window_title').text().trim(), 'Some Record',
|
||||
"should display the display_name of the record in the header");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('expand a document thread window', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
intercepts: {
|
||||
do_action: function (ev) {
|
||||
assert.deepEqual(ev.data.action, {
|
||||
res_id: 1,
|
||||
res_model: 'some.res.model',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'form']],
|
||||
}, "should open the document in form view");
|
||||
},
|
||||
},
|
||||
});
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// toggle the messaging menu and open the documentThread
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
"should display one preview");
|
||||
messagingMenu.$('.o_mail_preview').click();
|
||||
assert.strictEqual($('.o_thread_window').length, 1,
|
||||
"should have open the DocumentThread in a thread window");
|
||||
|
||||
assert.strictEqual($('.o_thread_window .o_thread_window_expand').attr('title'),
|
||||
'Open document', "button should have correct title");
|
||||
|
||||
// click to expand
|
||||
$('.o_thread_window .o_thread_window_expand').click();
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('post messages in a document thread window', function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
var newMessageBody = 'Some message';
|
||||
var newMessage = {
|
||||
id: 6783,
|
||||
author_id: [this.session.partner_id, 'Me'],
|
||||
body: newMessageBody,
|
||||
channel_ids: [],
|
||||
model: 'some.res.model',
|
||||
record_name: "Some Record",
|
||||
res_id: 1,
|
||||
};
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'message_post') {
|
||||
assert.strictEqual(args.model, 'some.res.model',
|
||||
"should post the message on the correct model");
|
||||
assert.deepEqual(args.args, [1],
|
||||
"should post the message on the correct record");
|
||||
assert.strictEqual(args.kwargs.body, newMessageBody,
|
||||
"should post correct message");
|
||||
|
||||
// add the message to the fake DB
|
||||
this.data['mail.message'].records.push(newMessage);
|
||||
this.data['some.res.model'].records[0].message_ids.push(6783);
|
||||
return $.when(6783);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
testUtils.intercept(messagingMenu, 'call_service', function (ev) {
|
||||
if (ev.data.service === 'local_storage' && ev.data.method === 'setItem' &&
|
||||
ev.data.args[0] === 'mail.document_threads_last_message') {
|
||||
assert.strictEqual(ev.data.args[1], JSON.stringify(newMessage),
|
||||
"should write sent message in local storage, to share info with other tabs");
|
||||
}
|
||||
}, true);
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// toggle the messaging menu and open the documentThread
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
"should display one preview");
|
||||
messagingMenu.$('.o_mail_preview').click();
|
||||
assert.strictEqual($('.o_thread_window').length, 1,
|
||||
"should have open the DocumentThread in a thread window");
|
||||
assert.strictEqual($('.o_thread_window .o_thread_message').length, 2,
|
||||
"should display 2 messages in the thread window");
|
||||
|
||||
// post a message
|
||||
$('.o_thread_window .o_composer_text_field')
|
||||
.val(newMessageBody)
|
||||
.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
|
||||
|
||||
assert.strictEqual($('.o_thread_window .o_thread_message').length, 3,
|
||||
"should display 3 messages in the thread window");
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('open, fold, unfold and close a document thread window', function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
var messagingMenu = new MessagingMenu();
|
||||
testUtils.addMockEnvironment(messagingMenu, {
|
||||
services: this.services,
|
||||
data: this.data,
|
||||
session: this.session,
|
||||
});
|
||||
testUtils.intercept(messagingMenu, 'call_service', function (ev) {
|
||||
if (ev.data.service === 'local_storage' && ev.data.method === 'setItem') {
|
||||
assert.step(ev.data.args);
|
||||
}
|
||||
}, true);
|
||||
messagingMenu.appendTo($('#qunit-fixture'));
|
||||
|
||||
// toggle the messaging menu and open the documentThread
|
||||
messagingMenu.$('.dropdown-toggle').click();
|
||||
assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1,
|
||||
"should display one preview");
|
||||
messagingMenu.$('.o_mail_preview').click();
|
||||
assert.strictEqual($('.o_thread_window').length, 1,
|
||||
"should have open the DocumentThread in a thread window");
|
||||
assert.strictEqual($('.o_thread_window .o_thread_message').length, 2,
|
||||
"should display 2 messages in the thread window");
|
||||
|
||||
// fold and unfold thread window
|
||||
$('.o_thread_window .o_thread_window_title').click();
|
||||
$('.o_thread_window .o_thread_window_title').click();
|
||||
|
||||
// close thread window
|
||||
$('.o_thread_window .o_thread_window_close').click();
|
||||
|
||||
assert.verifySteps([
|
||||
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"open\"}}"],
|
||||
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"folded\"}}"],
|
||||
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"open\"}}"],
|
||||
['mail.document_threads_state', "{\"some.res.model_1\":{\"name\":\"Some Record\",\"windowState\":\"closed\"}}"],
|
||||
]);
|
||||
|
||||
messagingMenu.destroy();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user