[IMP] mail: Make the channel name / description editable

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) <seb@odoo.com>
This commit is contained in:
Simon Genin (ges)
2021-08-24 18:54:36 +00:00
committed by Sébastien Theys
parent 51ff8b09db
commit 8ff923fec6
7 changed files with 301 additions and 18 deletions
+18 -1
View File
@@ -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
@@ -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' });
}
@@ -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');
}
}
@@ -7,7 +7,7 @@
<t t-if="threadViewTopBar.thread">
<ThreadIcon class="o_ThreadViewTopbar_threadIcon mr-2 align-self-center" threadLocalId="threadViewTopBar.thread.localId"/>
</t>
<div class="o_ThreadViewTopbar_title d-flex flex-grow-1 align-self-center align-items-baseline">
<div class="o_ThreadViewTopbar_title d-flex flex-grow-1 align-self-center align-items-center">
<t t-if="threadViewTopBar.thread">
<t t-if="!threadViewTopBar.isEditingThreadName">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 mw-75 px-1 text-truncate lead font-weight-bold" t-att-title="threadViewTopBar.thread.displayName" t-on-click="threadViewTopBar.onClickTopbarThreadName" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadName" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadName" t-att-class="{ 'o-isMouseOverThreadName': threadViewTopBar.isMouseOverThreadName }">
@@ -23,11 +23,23 @@
Discuss
</div>
</t>
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread.description">
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread.isChannelDescriptionChangeable">
<div class="o_ThreadViewTopbar_threadDescriptionSeparator flex-shrink-0 mx-2"/>
<div class="o_ThreadViewTopbar_threadDescription text-truncate" t-att-title="threadViewTopBar.thread.description">
<t t-esc="threadViewTopBar.thread.description"/>
</div>
<t t-if="!threadViewTopBar.isEditingThreadDescription">
<t t-if="threadViewTopBar.thread.description">
<div class="o_ThreadViewTopbar_threadDescription text-truncate px-1" t-att-title="threadViewTopBar.thread.description" t-on-click="threadViewTopBar.onClickTopbarThreadDescription" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadDescription" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadDescription" t-att-class="{ 'o-isMouseOverThreadDescription': threadViewTopBar.isMouseOverThreadDescription }">
<t t-esc="threadViewTopBar.thread.description"/>
</div>
</t>
<t t-if="!threadViewTopBar.thread.description and threadViewTopBar.thread.isChannelDescriptionChangeable">
<div class="o_ThreadViewTopbar_threadAddDescriptionEmptyLabel text-truncate" t-on-click="threadViewTopBar.onClickTopbarThreadDescription">
Add a description
</div>
</t>
</t>
<t t-if="threadViewTopBar.isEditingThreadDescription">
<input class="o_ThreadViewTopbar_threadDescriptionInput" type="text" t-att-value="threadViewTopBar.pendingThreadDescription" t-on-input="threadViewTopBar.onInputThreadDescriptionInput" t-on-keydown="threadViewTopBar.onKeyDownThreadDescriptionInput" t-ref="threadDescriptionInput"/>
</t>
</t>
</div>
<div class="o_ThreadViewTopbar_actions d-flex align-items-center">
@@ -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
+36 -4
View File
@@ -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.
@@ -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.
*/