diff --git a/addons/mail/static/src/js/models/document_thread.js b/addons/mail/static/src/js/models/document_thread.js index b9671107d9d..92cc26e845b 100644 --- a/addons/mail/static/src/js/models/document_thread.js +++ b/addons/mail/static/src/js/models/document_thread.js @@ -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({ diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js new file mode 100644 index 00000000000..40301ad724c --- /dev/null +++ b/addons/mail/static/src/js/services/mail_document_thread_manager.js @@ -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; + +}); diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js index 9815b23b6de..4f6b0c29dfc 100644 --- a/addons/mail/static/src/js/services/mail_service.js +++ b/addons/mail/static/src/js/services/mail_service.js @@ -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); diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js new file mode 100644 index 00000000000..c9814ada195 --- /dev/null +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -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: '
Some Message on a document
', + channel_ids: [], + model: 'some.res.model', + record_name: "Some Record", + res_id: 1, + }, { + author_id: [2, "Demo"], + body: "*MessageOnDocument*
", + 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(); +}); + +}); + +});