From 8ff923fec6e5d27cd834cbfce6d8f44a8c171757 Mon Sep 17 00:00:00 2001 From: "Simon Genin (ges)" Date: Tue, 10 Aug 2021 13:24:19 +0000 Subject: [PATCH] [IMP] mail: Make the channel name / description editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: Only the channel name was updatable and needed refactoring. After: The description can be updated as well and both edition have the same code flow. task id 2620687 closes odoo/odoo#74962 Signed-off-by: Sébastien Theys (seb) --- addons/mail/models/mail_channel.py | 19 +- .../thread_view_topbar/thread_view_topbar.js | 1 + .../thread_view_topbar.scss | 23 ++- .../thread_view_topbar/thread_view_topbar.xml | 22 ++- .../messaging_notification_handler.js | 32 +++ .../mail/static/src/models/thread/thread.js | 40 +++- .../thread_view_topbar/thread_view_topbar.js | 182 +++++++++++++++++- 7 files changed, 301 insertions(+), 18 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index ed872d3cdac..f67766d040d 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -768,7 +768,24 @@ class Channel(models.Model): def channel_rename(self, name): self.ensure_one() self.write({'name': name}) - self._broadcast(self.channel_partner_ids.ids) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.channel_rename', + 'payload': { + 'id': self.id, + 'name': name + }, + }) + + def channel_change_description(self, description): + self.ensure_one() + self.write({'description': description}) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.channel_description_change', + 'payload': { + 'id': self.id, + 'description': description + }, + }) def notify_typing(self, is_typing): """ Broadcast the typing notification to channel members diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js index a2e5ac9b444..ae197e2de35 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js @@ -14,6 +14,7 @@ export class ThreadViewTopbar extends Component { setup() { super.setup(); useRefToModel({ fieldName: 'threadNameInputRef', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId', refName: 'threadNameInput' }); + useRefToModel({ fieldName: 'threadDescriptionInputRef', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId', refName: 'threadDescriptionInput' }); useUpdateToModel({ methodName: 'onComponentUpdate', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId' }); } diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss index dee5b2cc7da..db639744fb0 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss @@ -11,7 +11,7 @@ height: $font-size-sm; } -.o_ThreadViewTopbar_threadName { +.o_ThreadViewTopbar_threadName, .o_ThreadViewTopbar_threadDescription { border: $border-width solid; } @@ -65,3 +65,24 @@ border-color: $border-color; } } + +.o_ThreadViewTopbar_threadDescription { + + &:not(.o-isMouseOverThreadDescription) { + border-color: transparent; // presence of border even if invisible to prevent flicker + } + + &.o-isMouseOverThreadDescription { + background-color: $white; + border-color: $border-color; + } +} + +.o_ThreadViewTopbar_threadAddDescriptionEmptyLabel { + color: gray('400'); + cursor: pointer; + + &:hover { + color: gray('900'); + } +} diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml index 348411362c3..7b4b57c6f98 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml @@ -7,7 +7,7 @@ -
+
@@ -23,11 +23,23 @@ Discuss
- +
-
- -
+ + +
+ +
+
+ +
+ Add a description +
+
+
+ + +
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index 5712639906b..9df37fcb585 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -77,8 +77,12 @@ function factory(dependencies) { } if (typeof message === 'object') { switch (message.type) { + case 'mail.channel_description_change': + return this._handleNotificationChannelDescriptionChanged(message.payload); case 'mail.channel_joined': return this._handleNotificationChannelJoined(message.payload); + case 'mail.channel_rename': + return this._handleNotificationChannelRenamed(message.payload); } } const [, model, id] = channel; @@ -137,6 +141,20 @@ function factory(dependencies) { } } + /** + * @private + * @param {Object} payload + * @param {integer} payload.id + * @param {String} payload.description + */ + _handleNotificationChannelDescriptionChanged({ id, description }) { + const channel = this.messaging.models['mail.thread'].insert({ + id, + model: 'mail.channel', + }); + channel.update({ description }); + } + /** * @private * @param {integer} channelId @@ -194,6 +212,20 @@ function factory(dependencies) { } } + /** + * @private + * @param {Object} payload + * @param {integer} payload.id + * @param {String} payload.name + */ + _handleNotificationChannelRenamed({ id, name }) { + const channel = this.messaging.models['mail.thread'].insert({ + id, + model: 'mail.channel', + }); + channel.update({ name }); + } + /** * @private * @param {integer} channelId diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 0de39466e90..6e41e709d07 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -121,6 +121,22 @@ function factory(dependencies) { // Public //---------------------------------------------------------------------- + /** + * Changes description of the thread to the given new description. + * Only makes sense for channels. + * + * @param {string} description + */ + async changeDescription(description) { + this.update({ description }); + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_change_description', + args: [[this.id]], + kwargs: { description }, + }); + } + /** * @static * @param {mail.thread} [thread] the concerned thread @@ -988,14 +1004,15 @@ function factory(dependencies) { * Renames this thread to the given new name. * Only makes sense for channels. * - * @param {string} newName + * @param {string} name */ - async rename(newName) { + async rename(name) { + this.update({ name }); return this.env.services.rpc({ model: 'mail.channel', method: 'channel_rename', - args: [this.id], - kwargs: { name: newName }, + args: [[this.id]], + kwargs: { name }, }); } @@ -1189,6 +1206,14 @@ function factory(dependencies) { return ['chat', 'livechat'].includes(this.channel_type); } + /** + * @private + * @returns {boolean} + */ + _computeIsChannelDescriptionChangeable() { + return this.model === 'mail.channel' && this.channel_type === 'channel'; + } + /** * @private * @returns {boolean} @@ -1750,6 +1775,13 @@ function factory(dependencies) { id: attr({ required: true, }), + /** + * Determines whether this description can be changed. + * Only makes sense for channels. + */ + isChannelDescriptionChangeable: attr({ + compute: '_computeIsChannelDescriptionChangeable', + }), /** * Determines whether this thread can be renamed. * Only makes sense for channels. diff --git a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js index 5abf3525a85..5c31bf6b6a3 100644 --- a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js @@ -22,6 +22,11 @@ function factory(dependencies) { this.onMouseEnterTopBarThreadName = this.onMouseEnterTopBarThreadName.bind(this); this.onMouseLeaveTopBarThreadName = this.onMouseLeaveTopBarThreadName.bind(this); this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + this.onClickTopbarThreadDescription = this.onClickTopbarThreadDescription.bind(this); + this.onInputThreadDescriptionInput = this.onInputThreadDescriptionInput.bind(this); + this.onKeyDownThreadDescriptionInput = this.onKeyDownThreadDescriptionInput.bind(this); + this.onMouseEnterTopBarThreadDescription = this.onMouseEnterTopBarThreadDescription.bind(this); + this.onMouseLeaveTopBarThreadDescription = this.onMouseLeaveTopBarThreadDescription.bind(this); document.addEventListener('click', this._onClickCaptureGlobal, true); return super._created(); } @@ -81,6 +86,27 @@ function factory(dependencies) { }); } + /** + * Handles click on the "thread description" of this top bar. + * + * @param {MouseEvent} ev + */ + onClickTopbarThreadDescription(ev) { + if (!this.thread || !this.thread.isChannelDescriptionChangeable) { + return; + } + const selection = window.getSelection(); + this.update({ + doFocusOnThreadDescriptionInput: true, + doSetSelectionDirectionOnThreadDescriptionInput: selection.anchorOffset < selection.focusOffset ? 'forward' : 'backward', + doSetSelectionEndOnThreadDescriptionInput: Math.max(selection.focusOffset, selection.anchorOffset), + doSetSelectionStartOnThreadDescriptionInput: Math.min(selection.focusOffset, selection.anchorOffset), + isEditingThreadDescription: true, + isMouseOverThreadDescription: false, + pendingThreadDescription: this.thread.description || "", + }); + } + /** * Handles click on the "unstar all" button of Starred box. * @@ -98,6 +124,10 @@ function factory(dependencies) { this.threadNameInputRef.el.focus(); this.update({ doFocusOnThreadNameInput: clear() }); } + if (this.doFocusOnThreadDescriptionInput) { + this.threadDescriptionInputRef.el.focus(); + this.update({ doFocusOnThreadDescriptionInput: clear() }); + } if ( this.doSetSelectionStartOnThreadNameInput !== undefined && this.doSetSelectionEndOnThreadNameInput !== undefined && @@ -114,6 +144,22 @@ function factory(dependencies) { doSetSelectionStartOnThreadNameInput: clear(), }); } + if ( + this.doSetSelectionStartOnThreadDescriptionInput !== undefined && + this.doSetSelectionEndOnThreadDescriptionInput !== undefined && + this.doSetSelectionDirectionOnThreadDescriptionInput !== undefined + ) { + this.threadDescriptionInputRef.el.setSelectionRange( + this.doSetSelectionStartOnThreadDescriptionInput, + this.doSetSelectionEndOnThreadDescriptionInput, + this.doSetSelectionDirectionOnThreadDescriptionInput + ); + this.update({ + doSetSelectionDirectionOnThreadDescriptionInput: clear(), + doSetSelectionEndOnThreadDescriptionInput: clear(), + doSetSelectionStartOnThreadDescriptionInput: clear(), + }); + } } /** @@ -125,6 +171,15 @@ function factory(dependencies) { this.update({ pendingThreadName: ev.target.value }); } + /** + * Handles input on the "thread description" input of this top bar. + * + * @param {InputEvent} ev + */ + onInputThreadDescriptionInput(ev) { + this.update({ pendingThreadDescription: ev.target.value }); + } + /** * Handles keydown on the "thread name" input of this top bar. * @@ -141,6 +196,22 @@ function factory(dependencies) { } } + /** + * Handles keydown on the "thread description" input of this top bar. + * + * @param {KeyboardEvent} ev + */ + onKeyDownThreadDescriptionInput(ev) { + switch (ev.key) { + case 'Enter': + this._applyThreadChangeDescription(); + break; + case 'Escape': + this._discardThreadChangeDescription(); + break; + } + } + /** * Handles mouseenter on the "thread name" of this top bar. * @@ -153,6 +224,18 @@ function factory(dependencies) { this.update({ isMouseOverThreadName: true }); } + /** + * Handles mouseenter on the "thread description" of this top bar. + * + * @param {MouseEvent} ev + */ + onMouseEnterTopBarThreadDescription(ev) { + if (!this.thread || !this.thread.isChannelDescriptionChangeable) { + return; + } + this.update({ isMouseOverThreadDescription: true }); + } + /** * Handles mouseleave on the "thread name" of this top bar. * @@ -162,6 +245,15 @@ function factory(dependencies) { this.update({ isMouseOverThreadName: false }); } + /** + * Handles mouseleave on the "thread description" of this top bar. + * + * @param {MouseEvent} ev + */ + onMouseLeaveTopBarThreadDescription(ev) { + this.update({ isMouseOverThreadDescription: false }); + } + //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- @@ -183,6 +275,20 @@ function factory(dependencies) { } } + /** + * @private + */ + _applyThreadChangeDescription() { + const newDescription = this.pendingThreadDescription || ""; + this.update({ + isEditingThreadDescription: false, + pendingThreadDescription: clear(), + }); + if (this.thread.channel_type === 'channel' && newDescription !== this.thread.description) { + this.thread.changeDescription(newDescription); + } + } + /** * @private */ @@ -193,20 +299,27 @@ function factory(dependencies) { }); } + /** + * @private + */ + _discardThreadChangeDescription() { + this.update({ + isEditingThreadDescription: false, + pendingThreadDescription: clear(), + }); + } + /** * @private * @param {MouseEvent} ev */ _onClickCaptureGlobal(ev) { - if (!this.threadNameInputRef) { - return; - } - if (this.threadNameInputRef.el && this.threadNameInputRef.el.contains(ev.target)) { - return; - } - if (this.isEditingThreadName) { + if (this.isEditingThreadName && this.threadNameInputRef.el && !this.threadNameInputRef.el.contains(ev.target)) { this._applyThreadRename(); } + if (this.isEditingThreadDescription && this.threadDescriptionInputRef.el && !this.threadDescriptionInputRef.el.contains(ev.target)) { + this._applyThreadChangeDescription(); + } } } @@ -217,6 +330,10 @@ function factory(dependencies) { */ doFocusOnThreadNameInput: attr(), /** + * Determines whether this thread description input needs to be focused. + */ + doFocusOnThreadDescriptionInput: attr(), + /** * Determines the direction to set on the selection of this thread name * input. This value is not a representation of current selection, but * an instruction to set a new selection. Must be set together with @@ -224,6 +341,14 @@ function factory(dependencies) { * to have an effect. */ doSetSelectionDirectionOnThreadNameInput: attr(), + /** + * Determines the direction to set on the selection of this thread description + * input. This value is not a representation of current selection, but + * an instruction to set a new selection. Must be set together with + * `doSetSelectionEndOnThreadDescriptionInput` and `doSetSelectionStartOnThreadDescriptionInput` + * to have an effect. + */ + doSetSelectionDirectionOnThreadDescriptionInput: attr(), /** * Determines the ending position where to place the selection on this * thread name input (zero-based index). This value is not a @@ -232,6 +357,14 @@ function factory(dependencies) { * and `doSetSelectionStartOnThreadNameInput` to have an effect. */ doSetSelectionEndOnThreadNameInput: attr(), + /** + * Determines the ending position where to place the selection on this + * thread description input (zero-based index). This value is not a + * representation of current selection, but an instruction to set a new + * selection. Must be set together with `doSetSelectionDirectionOnThreadDescriptionInput` + * and `doSetSelectionStartOnThreadDescriptionInput` to have an effect. + */ + doSetSelectionEndOnThreadDescriptionInput: attr(), /** * Determines the starting position where to place the selection on this * thread name input (zero-based index). This value is not a @@ -240,12 +373,26 @@ function factory(dependencies) { * `doSetSelectionEndOnThreadNameInput` to have an effect. */ doSetSelectionStartOnThreadNameInput: attr(), + /** + * Determines the starting position where to place the selection on this + * thread description input (zero-based index). This value is not a + * representation of current selection, but an instruction to set a new + * selection. Must be set together with `doSetSelectionDirectionOnThreadDescriptionInput` and + * `doSetSelectionEndOnThreadDescriptionInput` to have an effect. + */ + doSetSelectionStartOnThreadDescriptionInput: attr(), /** * Determines whether this thread is currently being renamed. */ isEditingThreadName: attr({ default: false, }), + /** + * Determines whether this thread description is currently being changed. + */ + isEditingThreadDescription: attr({ + default: false, + }), /** * States whether the cursor is currently over this thread name in * the top bar. @@ -253,6 +400,13 @@ function factory(dependencies) { isMouseOverThreadName: attr({ default: false, }), + /** + * States whether the cursor is currently over this thread description in + * the top bar. + */ + isMouseOverThreadDescription: attr({ + default: false, + }), /** * Determines the pending name of this thread, which is the new name of * the thread as the current user is currently typing it, with the goal @@ -262,6 +416,15 @@ function factory(dependencies) { pendingThreadName: attr({ default: "", }), + /** + * Determines the pending description of this thread, which is the new description of + * the thread as the current user is currently typing it, with the goal + * of changing the description the thread. + * This value can either be applied or discarded. + */ + pendingThreadDescription: attr({ + default: "", + }), /** * States the thread that is displayed by this top bar. */ @@ -273,6 +436,11 @@ function factory(dependencies) { * Useful to focus it, or to know when a click is done outside of it. */ threadNameInputRef: attr(), + /** + * States the OWL ref of the thread description input of this top bar. + * Useful to focus it, or to know when a click is done outside of it. + */ + threadDescriptionInputRef: attr(), /** * States the thread view managing this top bar. */