[IMP] mail: move component handlers to models (step 5)

This commit moves some handler methods from components to models,
as a step closer to having most of business code in models.

Having business code in models is desirable so that the code is much more maintainable:
easier to change and more robust code.

Task-2579306

closes odoo/odoo#88878

Related: odoo/enterprise#26277
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Alexandre Kühn
2022-04-15 19:08:35 +02:00
parent 1f3683a83a
commit 03b59ae410
32 changed files with 383 additions and 368 deletions
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import Popover from "web.Popover";
@@ -7,6 +8,14 @@ import { LegacyComponent } from "@web/legacy/legacy_component";
export class Activity extends LegacyComponent {
/**
* @override
*/
setup() {
super.setup();
useComponentToModel({ fieldName: 'component', modelName: 'ActivityView' });
}
/**
* @returns {ActivityView}
*/
@@ -64,6 +64,7 @@
<FollowerListMenu
className="'o_ChatterTopbar_followerListMenu'"
isDisabled="chatter.isDisabled"
localId="chatter.followerListMenuView.localId"
threadLocalId="chatter.thread.localId"
isChatterButton="true"
/>
@@ -1,6 +1,5 @@
/** @odoo-module **/
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { useUpdate } from '@mail/component_hooks/use_update';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
@@ -14,7 +13,6 @@ export class ComposerTextInput extends Component {
*/
setup() {
super.setup();
useComponentToModel({ fieldName: 'textInputComponent', modelName: 'ComposerView' });
useRefToModel({ fieldName: 'mirroredTextareaRef', modelName: 'ComposerView', refName: 'mirroredTextarea' });
useRefToModel({ fieldName: 'textareaRef', modelName: 'ComposerView', refName: 'textarea' });
/**
@@ -40,18 +38,6 @@ export class ComposerTextInput extends Component {
return this.messaging && this.messaging.models['ComposerView'].get(this.props.localId);
}
/**
* Saves the composer text input state in store
*/
saveStateInStore() {
this.composerView.composer.update({
textInputContent: this.composerView.textareaRef.el.value,
textInputCursorEnd: this.composerView.textareaRef.el.selectionEnd,
textInputCursorStart: this.composerView.textareaRef.el.selectionStart,
textInputSelectionDirection: this.composerView.textareaRef.el.selectionDirection,
});
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -113,28 +99,6 @@ export class ComposerTextInput extends Component {
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClickTextarea() {
if (!this.composerView) {
return;
}
// clicking might change the cursor position
this.saveStateInStore();
}
/**
* @private
*/
_onFocusoutTextarea() {
if (!this.composerView) {
return;
}
this.saveStateInStore();
this.composerView.update({ isFocused: false });
}
/**
* @private
*/
@@ -142,7 +106,7 @@ export class ComposerTextInput extends Component {
if (!this.composerView) {
return;
}
this.saveStateInStore();
this.composerView.saveStateInStore();
if (this._textareaLastInputValue !== this.composerView.textareaRef.el.value) {
this.composerView.handleCurrentPartnerIsTyping();
}
@@ -150,95 +114,6 @@ export class ComposerTextInput extends Component {
this._updateHeight();
}
/**
* Key events management is performed in a Keyup to avoid intempestive RPC calls
*
* @private
* @param {KeyboardEvent} ev
*/
_onKeyupTextarea(ev) {
if (!this.composerView) {
return;
}
switch (ev.key) {
case 'Escape':
// already handled in _onKeydownTextarea, break to avoid default
break;
// ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
case 'Enter':
if (this.composerView.hasSuggestions) {
this.composerView.insertSuggestion();
this.composerView.closeSuggestions();
this.composerView.update({ doFocus: true });
}
break;
case 'ArrowUp':
case 'PageUp':
if (ev.key === 'ArrowUp' && !this.composerView.hasSuggestions && !this.composerView.composer.textInputContent && this.composerView.threadView) {
this.composerView.threadView.startEditingLastMessageFromCurrentUser();
break;
}
if (this.composerView.hasSuggestions) {
this.composerView.setPreviousSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'ArrowDown':
case 'PageDown':
if (ev.key === 'ArrowDown' && !this.composerView.hasSuggestions && !this.composerView.composer.textInputContent && this.composerView.threadView) {
this.composerView.threadView.startEditingLastMessageFromCurrentUser();
break;
}
if (this.composerView.hasSuggestions) {
this.composerView.setNextSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'Home':
if (this.composerView.hasSuggestions) {
this.composerView.setFirstSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'End':
if (this.composerView.hasSuggestions) {
this.composerView.setLastSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'Tab':
if (this.composerView.hasSuggestions) {
if (ev.shiftKey) {
this.composerView.setPreviousSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
} else {
this.composerView.setNextSuggestionActive();
this.composerView.update({ hasToScrollToActiveSuggestion: true });
}
}
break;
case 'Alt':
case 'AltGraph':
case 'CapsLock':
case 'Control':
case 'Fn':
case 'FnLock':
case 'Hyper':
case 'Meta':
case 'NumLock':
case 'ScrollLock':
case 'Shift':
case 'ShiftSuper':
case 'Symbol':
case 'SymbolLock':
// prevent modifier keys from resetting the suggestion state
break;
// Otherwise, check if a mention is typed
default:
this.saveStateInStore();
}
}
}
Object.assign(ComposerTextInput, {
@@ -10,7 +10,7 @@
isBelow="props.hasMentionSuggestionsBelowPosition"
/>
</t>
<textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': composerView.isCompact }" t-esc="composerView.composer.textInputContent" t-att-placeholder="composerView.composer.placeholder" t-on-click="_onClickTextarea" t-on-focusin="composerView.onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="composerView.onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
<textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': composerView.isCompact }" t-esc="composerView.composer.textInputContent" t-att-placeholder="composerView.composer.placeholder" t-on-click="composerView.onClickTextarea" t-on-focusin="composerView.onFocusinTextarea" t-on-focusout="composerView.onFocusoutTextarea" t-on-keydown="composerView.onKeydownTextarea" t-on-keyup="composerView.onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
<!--
This is an invisible textarea used to compute the composer
height based on the text content. We need it to downsize
@@ -21,25 +21,6 @@ export class Follower extends Component {
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onClickDetails(ev) {
ev.preventDefault();
ev.stopPropagation();
this.follower.openProfile();
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickEdit(ev) {
ev.preventDefault();
this.follower.showSubtypes();
}
/**
* @private
* @param {MouseEvent} ev
@@ -4,12 +4,12 @@
<t t-name="mail.Follower" owl="1">
<t t-if="follower">
<div class="o_Follower d-flex justify-content-between p-0" t-attf-class="{{ className }}" t-on-click="props.onClick" t-ref="root">
<a class="o_Follower_details d-flex align-items-center flex-grow-1 px-3 text-700" t-att-class="{ 'o-inactive font-italic opacity-25': !follower.isActive }" href="#" t-on-click="_onClickDetails">
<a class="o_Follower_details d-flex align-items-center flex-grow-1 px-3 text-700" t-att-class="{ 'o-inactive font-italic opacity-25': !follower.isActive }" href="#" t-on-click="follower.onClickDetails">
<img class="o_Follower_avatar me-2 rounded-circle" t-att-src="follower.partner.avatarUrl" alt="Avatar"/>
<span class="o_Follower_name flex-shrink text-truncate" t-esc="follower.partner.nameOrDisplayName"/>
</a>
<t t-if="follower.isEditable">
<button class="o_Follower_button o_Follower_editButton btn btn-icon rounded-0" title="Edit subscription" t-on-click="_onClickEdit">
<button class="o_Follower_button o_Follower_editButton btn btn-icon rounded-0" title="Edit subscription" t-on-click="follower.onClickEdit">
<i class="fa fa-pencil"/>
</button>
<button class="o_Follower_button o_Follower_removeButton btn btn-icon rounded-0" title="Remove this follower" t-on-click="_onClickRemove">
@@ -36,6 +36,13 @@ export class FollowerListMenu extends Component {
// Public
//--------------------------------------------------------------------------
/**
* @return {FollowerListMenuView}
*/
get followerListMenuView() {
return this.messaging && this.messaging.models['FollowerListMenuView'].get(this.props.localId);
}
/**
* @return {Thread}
*/
@@ -121,6 +128,7 @@ Object.assign(FollowerListMenu, {
isDisabled: { type: Boolean, optional: true },
threadLocalId: String,
isChatterButton: { type: Boolean, optional: true },
localId: String,
},
template: 'mail.FollowerListMenu',
});
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="mail.FollowerListMenu" owl="1">
<t t-if="thread">
<t t-if="followerListMenuView and thread">
<div class="o_FollowerListMenu position-relative d-flex" t-attf-class="{{ className }}" t-on-keydown="_onKeydown" t-ref="root">
<div class="o_FollowerListMenu_followers d-flex" t-ref="dropdown">
<button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers" t-att-class="{ 'o_ChatterTopbar_button': props.isChatterButton }">
@@ -17,24 +17,6 @@ export class FollowerSubtype extends Component {
return this.messaging && this.messaging.models['FollowerSubtypeView'].get(this.props.localId);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when clicking on cancel button.
*
* @private
* @param {Event} ev
*/
_onChangeCheckbox(ev) {
if (ev.target.checked) {
this.followerSubtypeView.follower.selectSubtype(this.followerSubtypeView.subtype);
} else {
this.followerSubtypeView.follower.unselectSubtype(this.followerSubtypeView.subtype);
}
}
}
Object.assign(FollowerSubtype, {
@@ -5,7 +5,7 @@
<t t-if="followerSubtypeView">
<div class="o_FollowerSubtype" t-attf-class="{{ className }}" t-att-data-local-id="followerSubtypeView.localId" t-ref="root">
<label class="o_FollowerSubtype_label flex-grow-1 align-items-center d-flex mb-0 p-2 o_cursor_pointer">
<input class="o_FollowerSubtype_checkbox me-2" type="checkbox" t-att-checked="followerSubtypeView.follower.selectedSubtypes.includes(followerSubtypeView.subtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/>
<input class="o_FollowerSubtype_checkbox me-2" type="checkbox" t-att-checked="followerSubtypeView.follower.selectedSubtypes.includes(followerSubtypeView.subtype) ? 'checked': ''" t-on-change="followerSubtypeView.onChangeCheckbox"/>
<t t-esc="followerSubtypeView.subtype.name"/>
</label>
</div>
@@ -26,30 +26,6 @@ export class FollowerSubtypeList extends Component {
return this.messaging && this.messaging.models['FollowerSubtypeList'].get(this.props.localId);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when clicking on cancel button.
*
* @private
* @param {MouseEvent} ev
*/
_onClickCancel(ev) {
this.followerSubtypeList.follower.closeSubtypes();
}
/**
* Called when clicking on apply button.
*
* @private
* @param {MouseEvent} ev
*/
_onClickApply(ev) {
this.followerSubtypeList.follower.updateSubtypes();
}
}
Object.assign(FollowerSubtypeList, {
@@ -9,7 +9,7 @@
<h4 class="modal-title">
Edit Subscription of <t t-esc="followerSubtypeList.follower.partner.nameOrDisplayName"/>
</h4>
<i class="o_FollowerSubtypeList_closeButton close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/>
<i class="o_FollowerSubtypeList_closeButton close fa fa-times" aria-label="Close" t-on-click="followerSubtypeList.onClickCancel"/>
</header>
<main class="modal-body">
<div class="o_FollowerSubtypeList_subtypes flex-column d-flex">
@@ -22,10 +22,10 @@
</div>
</main>
<div class="modal-footer">
<button class="o-apply btn btn-primary" t-on-click="_onClickApply">
<button class="o-apply btn btn-primary" t-on-click="followerSubtypeList.onClickApply">
Apply
</button>
<button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">
<button class="o-cancel btn btn-secondary" t-on-click="followerSubtypeList.onClickCancel">
Cancel
</button>
</div>
@@ -17,30 +17,6 @@ export class MailTemplate extends Component {
return this.messaging && this.messaging.models['MailTemplateView'].get(this.props.localId);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onClickPreview(ev) {
ev.stopPropagation();
ev.preventDefault();
this.mailTemplateView.mailTemplate.preview(this.mailTemplateView.activityViewOwner.activity);
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickSend(ev) {
ev.stopPropagation();
ev.preventDefault();
this.mailTemplateView.mailTemplate.send(this.mailTemplateView.activityViewOwner.activity);
}
}
Object.assign(MailTemplate, {
@@ -10,7 +10,7 @@
<button
class="o_MailTemplate_button o_MailTemplate_preview btn btn-link py-0"
t-att-data-mail-template-id="mailTemplateView.mailTemplate.id"
t-on-click="_onClickPreview"
t-on-click="mailTemplateView.onClickPreview"
>
Preview
</button>
@@ -18,7 +18,7 @@
<button
class="o_MailTemplate_button o_MailTemplate_send btn btn-link py-0"
t-att-data-mail-template-id="mailTemplateView.mailTemplate.id"
t-on-click="_onClickSend"
t-on-click="mailTemplateView.onClickSend"
>
Send Now
</button>
@@ -4,7 +4,6 @@ import { useComponentToModel } from '@mail/component_hooks/use_component_to_mode
import { useUpdate } from '@mail/component_hooks/use_update';
import { useUpdateToModel } from '@mail/component_hooks/use_update_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
import { _lt } from 'web.core';
import Popover from "web.Popover";
@@ -275,80 +274,6 @@ export class Message extends Component {
}, 60 * 1000);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onClick(ev) {
if (ev.target.closest('.o_channel_redirect')) {
this.messaging.openProfile({
id: Number(ev.target.dataset.oeId),
model: 'mail.channel',
});
// avoid following dummy href
ev.preventDefault();
return;
}
if (ev.target.tagName === 'A') {
if (ev.target.dataset.oeId && ev.target.dataset.oeModel) {
this.messaging.openProfile({
id: Number(ev.target.dataset.oeId),
model: ev.target.dataset.oeModel,
});
// avoid following dummy href
ev.preventDefault();
}
return;
}
if (
!isEventHandled(ev, 'Message.ClickAuthorAvatar') &&
!isEventHandled(ev, 'Message.ClickAuthorName') &&
!isEventHandled(ev, 'Message.ClickFailure') &&
!isEventHandled(ev, 'MessageActionList.Click') &&
!isEventHandled(ev, 'MessageReactionGroup.Click') &&
!isEventHandled(ev, 'MessageInReplyToView.ClickMessageInReplyTo')
) {
this.messageView.update({ isClicked: !this.messageView.isClicked });
}
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickAuthorAvatar(ev) {
markEventHandled(ev, 'Message.ClickAuthorAvatar');
if (!this.messageView.hasAuthorOpenChat) {
return;
}
this.messageView.message.author.openChat();
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickAuthorName(ev) {
markEventHandled(ev, 'Message.ClickAuthorName');
if (!this.messageView.message.author) {
return;
}
this.messageView.message.author.openProfile();
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickOriginThread(ev) {
// avoid following dummy href
ev.preventDefault();
this.messageView.message.originThread.open();
}
}
Object.assign(Message, {
@@ -17,7 +17,7 @@
'o-squashed': messageView.isSquashed,
'o-starred': messageView.message.isStarred,
'mt-3': !messageView.isSquashed and messageView.threadView,
}" t-attf-class="{{ messageView.extraClass }} {{ className }}" t-on-click="_onClick" t-on-mouseenter="messageView.onMouseenter" t-on-mouseleave="messageView.onMouseleave" t-att-data-message-local-id="messageView.message.localId"
}" t-attf-class="{{ messageView.extraClass }} {{ className }}" t-on-click="messageView.onClick" t-on-mouseenter="messageView.onMouseenter" t-on-mouseleave="messageView.onMouseleave" t-att-data-message-local-id="messageView.message.localId"
t-ref="root"
>
<MessageInReplyToView t-if="messageView.messageInReplyToView" messageInReplyToViewLocalId="messageView.messageInReplyToView.localId"/>
@@ -29,7 +29,7 @@
<div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed align-items-start': messageView.isSquashed }">
<t t-if="!messageView.isSquashed">
<div class="o_Message_authorAvatarContainer o_Message_sidebarItem">
<img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: messageView.hasAuthorOpenChat, o_redirect: messageView.hasAuthorOpenChat }" t-att-src="messageView.message.avatarUrl" t-on-click="_onClickAuthorAvatar" t-att-title="messageView.hasAuthorOpenChat ? messageView.authorAvatarTitleText : ''" alt="Avatar"/>
<img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: messageView.hasAuthorOpenChat, o_redirect: messageView.hasAuthorOpenChat }" t-att-src="messageView.message.avatarUrl" t-on-click="messageView.onClickAuthorAvatar" t-att-title="messageView.hasAuthorOpenChat ? messageView.authorAvatarTitleText : ''" alt="Avatar"/>
<t t-if="messageView.message.author and messageView.message.author.im_status">
<PartnerImStatusIcon
className="'o_Message_partnerImStatusIcon'"
@@ -58,7 +58,7 @@
<t t-if="!messageView.isSquashed">
<div class="o_Message_header ml-2">
<t t-if="messageView.message.author">
<div class="o_Message_authorName o_Message_authorRedirect o_redirect text-truncate" t-on-click="_onClickAuthorName" title="Open profile">
<div class="o_Message_authorName o_Message_authorRedirect o_redirect text-truncate" t-on-click="messageView.onClickAuthorName" title="Open profile">
<t t-if="messageView.message.originThread">
<t t-esc="messageView.message.originThread.getMemberName(messageView.message.author)"/>
</t>
@@ -93,10 +93,10 @@
<t t-if="messageView.threadView and messageView.message.originThread and messageView.message.originThread !== messageView.threadView.thread">
<div class="o_Message_originThread" t-att-class="{ 'o-message-selected': messageView.isSelected }">
<t t-if="messageView.message.originThread.model === 'mail.channel'">
(from <a class="o_Message_originThreadLink" t-att-href="messageView.message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="messageView.message.originThread.displayName">#<t t-esc="messageView.message.originThread.displayName"/></t><t t-else="">channel</t></a>)
(from <a class="o_Message_originThreadLink" t-att-href="messageView.message.originThread.url" t-on-click="messageView.onClickOriginThread"><t t-if="messageView.message.originThread.displayName">#<t t-esc="messageView.message.originThread.displayName"/></t><t t-else="">channel</t></a>)
</t>
<t t-else="">
on <a class="o_Message_originThreadLink" t-att-href="messageView.message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="messageView.message.originThread.displayName"><t t-esc="messageView.message.originThread.displayName"/></t><t t-else="">document</t></a>
on <a class="o_Message_originThreadLink" t-att-href="messageView.message.originThread.url" t-on-click="messageView.onClickOriginThread"><t t-if="messageView.message.originThread.displayName"><t t-esc="messageView.message.originThread.displayName"/></t><t t-else="">document</t></a>
</t>
</div>
</t>
@@ -67,40 +67,6 @@ export class MessagingMenu extends Component {
this.messagingMenu.close();
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickDesktopTabButton(ev) {
this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId });
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickNewMessage(ev) {
if (!this.messaging.device.isMobile) {
this.messaging.chatWindowManager.openNewMessage();
this.messagingMenu.close();
} else {
this.messagingMenu.toggleMobileNewMessage();
}
}
/**
* @private
* @param {MouseEvent} ev
*/
_onClickToggler(ev) {
// avoid following dummy href
ev.preventDefault();
if (!this.messagingMenu) {
return;
}
this.messagingMenu.toggleOpen();
}
/**
* @private
*/
@@ -4,7 +4,7 @@
<t t-name="mail.MessagingMenu" owl="1">
<t t-if="messagingMenu">
<div class="o_MessagingMenu dropdown" t-att-class="{ 'show': messagingMenu.isOpen, 'o-mobile': messaging.device.isMobile }" t-attf-class="{{ className }}" t-ref="root">
<a class="o_MessagingMenu_toggler dropdown-toggle o-no-caret o-dropdown--narrow" t-att-class="{ 'o-no-notification': !messagingMenu.counter }" href="#" title="Conversations" role="button" t-att-aria-expanded="messagingMenu.isOpen ? 'true' : 'false'" aria-haspopup="true" t-on-click="_onClickToggler">
<a class="o_MessagingMenu_toggler dropdown-toggle o-no-caret o-dropdown--narrow" t-att-class="{ 'o-no-notification': !messagingMenu.counter }" href="#" title="Conversations" role="button" t-att-aria-expanded="messagingMenu.isOpen ? 'true' : 'false'" aria-haspopup="true" t-on-click="messagingMenu.onClickToggler">
<i class="o_MessagingMenu_icon fa fa-lg fa-comments" role="img" aria-label="Messages"/>
<t t-if="!messaging.isInitialized">
<i class="o_MessagingMenu_loading fa fa-circle-o-notch fa-spin"/>
@@ -24,7 +24,7 @@
<div class="o_MessagingMenu_dropdownMenuHeader border-bottom" t-att-class="{ 'o-mobile': messaging.device.isMobile }">
<t t-if="!messaging.device.isMobile">
<t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId">
<button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId">
<button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="messagingMenu.onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId">
<t t-if="tabId === 'all'">All</t>
<t t-elif="tabId === 'chat'">Chat</t>
<t t-elif="tabId === 'channel'">Channels</t>
@@ -72,7 +72,7 @@
'btn-link': !messaging.device.isMobile,
'btn-secondary': messaging.device.isMobile,
'o-mobile': messaging.device.isMobile,
}" t-on-click="_onClickNewMessage" type="button"
}" t-on-click="messagingMenu.onClickNewMessage" type="button"
>
New message
</button>
@@ -190,6 +190,10 @@ registerModel({
inverse: 'activityViewOwner',
isCausal: true,
}),
/**
* States the OWL component of this activity view.
*/
component: attr(),
/**
* Compute the label for "when" the activity is due.
*/
+15
View File
@@ -144,6 +144,16 @@ registerModel({
}
return clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeFollowerListMenuView() {
if (this.hasFollowers && this.thread) {
return insertAndReplace();
}
return clear();
},
/**
* @private
* @returns {boolean}
@@ -282,6 +292,11 @@ registerModel({
context: attr({
default: {},
}),
followerListMenuView: one('FollowerListMenuView', {
compute: '_computeFollowerListMenuView',
inverse: 'chatterOwner',
isCausal: true,
}),
/**
* Determines whether `this` should display an activity box.
*/
+113 -7
View File
@@ -227,9 +227,7 @@ registerModel({
* @param {MouseEvent} ev
*/
onClickEmoji(ev) {
if (this.textInputComponent) {
this.textInputComponent.saveStateInStore();
}
this.saveStateInStore();
this.insertIntoTextInput(ev.currentTarget.dataset.unicode);
if (!this.messaging.device.isMobileDevice) {
this.update({ doFocus: true });
@@ -290,12 +288,26 @@ registerModel({
this.closeSuggestions();
this.update({ doFocus: true });
},
onClickTextarea() {
if (!this.exists()) {
return;
}
// clicking might change the cursor position
this.saveStateInStore();
},
onFocusinTextarea() {
if (!this.exists()) {
return;
}
this.update({ isFocused: true });
},
onFocusoutTextarea() {
if (!this.exists()) {
return;
}
this.saveStateInStore();
this.update({ isFocused: false });
},
/**
* @param {KeyboardEvent} ev
*/
@@ -403,6 +415,93 @@ registerModel({
return;
}
},
/**
* Key events management is performed in a Keyup to avoid intempestive RPC calls
*
* @param {KeyboardEvent} ev
*/
onKeyupTextarea(ev) {
if (!this.exists()) {
return;
}
switch (ev.key) {
case 'Escape':
// already handled in _onKeydownTextarea, break to avoid default
break;
// ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
case 'Enter':
if (this.hasSuggestions) {
this.insertSuggestion();
this.closeSuggestions();
this.update({ doFocus: true });
}
break;
case 'ArrowUp':
case 'PageUp':
if (ev.key === 'ArrowUp' && !this.hasSuggestions && !this.composer.textInputContent && this.threadView) {
this.threadView.startEditingLastMessageFromCurrentUser();
break;
}
if (this.hasSuggestions) {
this.setPreviousSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'ArrowDown':
case 'PageDown':
if (ev.key === 'ArrowDown' && !this.hasSuggestions && !this.composer.textInputContent && this.threadView) {
this.threadView.startEditingLastMessageFromCurrentUser();
break;
}
if (this.hasSuggestions) {
this.setNextSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'Home':
if (this.hasSuggestions) {
this.setFirstSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'End':
if (this.hasSuggestions) {
this.setLastSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
}
break;
case 'Tab':
if (this.hasSuggestions) {
if (ev.shiftKey) {
this.setPreviousSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
} else {
this.setNextSuggestionActive();
this.update({ hasToScrollToActiveSuggestion: true });
}
}
break;
case 'Alt':
case 'AltGraph':
case 'CapsLock':
case 'Control':
case 'Fn':
case 'FnLock':
case 'Hyper':
case 'Meta':
case 'NumLock':
case 'ScrollLock':
case 'Shift':
case 'ShiftSuper':
case 'Symbol':
case 'SymbolLock':
// prevent modifier keys from resetting the suggestion state
break;
// Otherwise, check if a mention is typed
default:
this.saveStateInStore();
}
},
/**
* @param {ClipboardEvent} ev
*/
@@ -553,6 +652,17 @@ registerModel({
}
}
},
/**
* Saves the composer text input state in store
*/
saveStateInStore() {
this.composer.update({
textInputContent: this.textareaRef.el.value,
textInputCursorEnd: this.textareaRef.el.selectionEnd,
textInputCursorStart: this.textareaRef.el.selectionStart,
textInputSelectionDirection: this.textareaRef.el.selectionDirection,
});
},
/**
* Send a message in the composer on related thread.
*
@@ -1389,10 +1499,6 @@ registerModel({
* Reference of the textarea. Useful to set height, selection and content.
*/
textareaRef: attr(),
/**
* States the OWL text input component of this composer view.
*/
textInputComponent: attr(),
/**
* States the thread view on which this composer allows editing (if any).
*/
+15
View File
@@ -46,6 +46,21 @@ registerModel({
closeSubtypes() {
this.update({ followerSubtypeListDialog: clear() });
},
/**
* @param {MouseEvent} ev
*/
onClickDetails(ev) {
ev.preventDefault();
ev.stopPropagation();
this.openProfile();
},
/**
* @param {MouseEvent} ev
*/
onClickEdit(ev) {
ev.preventDefault();
this.showSubtypes();
},
/**
* Opens the most appropriate view that is a profile for this follower.
*/
@@ -0,0 +1,15 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { one } from '@mail/model/model_field';
registerModel({
name: 'FollowerListMenuView',
identifyingFields: [['chatterOwner']],
fields: {
chatterOwner: one('Chatter', {
inverse: 'followerListMenuView',
readonly: true,
}),
},
});
@@ -17,6 +17,22 @@ registerModel({
containsElement(element) {
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
},
/**
* Called when clicking on apply button.
*
* @param {MouseEvent} ev
*/
onClickApply(ev) {
this.follower.updateSubtypes();
},
/**
* Called when clicking on cancel button.
*
* @param {MouseEvent} ev
*/
onClickCancel(ev) {
this.follower.closeSubtypes();
},
/**
* @private
* @returns {FieldCommand}
@@ -6,6 +6,20 @@ import { one } from '@mail/model/model_field';
registerModel({
name: 'FollowerSubtypeView',
identifyingFields: ['followerSubtypeListOwner', 'subtype'],
recordMethods: {
/**
* Called when clicking on cancel button.
*
* @param {Event} ev
*/
onChangeCheckbox(ev) {
if (ev.target.checked) {
this.follower.selectSubtype(this.subtype);
} else {
this.follower.unselectSubtype(this.subtype);
}
},
},
fields: {
follower: one('Follower', {
related: 'followerSubtypeListOwner.follower',
@@ -6,6 +6,24 @@ import { one } from '@mail/model/model_field';
registerModel({
name: 'MailTemplateView',
identifyingFields: ['activityViewOwner', 'mailTemplate'],
recordMethods: {
/**
* @param {MouseEvent} ev
*/
onClickPreview(ev) {
ev.stopPropagation();
ev.preventDefault();
this.mailTemplate.preview(this.activityViewOwner.activity);
},
/**
* @param {MouseEvent} ev
*/
onClickSend(ev) {
ev.stopPropagation();
ev.preventDefault();
this.mailTemplate.send(this.activityViewOwner.activity);
},
},
fields: {
activityViewOwner: one('ActivityView', {
inverse: 'mailTemplateViews',
+63 -1
View File
@@ -3,7 +3,7 @@
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { clear, insertAndReplace, replace } from '@mail/model/model_field_command';
import { markEventHandled } from '@mail/utils/utils';
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
registerModel({
name: 'MessageView',
@@ -24,6 +24,61 @@ registerModel({
}, 2000),
});
},
/**
* @param {MouseEvent} ev
*/
onClick(ev) {
if (ev.target.closest('.o_channel_redirect')) {
this.messaging.openProfile({
id: Number(ev.target.dataset.oeId),
model: 'mail.channel',
});
// avoid following dummy href
ev.preventDefault();
return;
}
if (ev.target.tagName === 'A') {
if (ev.target.dataset.oeId && ev.target.dataset.oeModel) {
this.messaging.openProfile({
id: Number(ev.target.dataset.oeId),
model: ev.target.dataset.oeModel,
});
// avoid following dummy href
ev.preventDefault();
}
return;
}
if (
!isEventHandled(ev, 'Message.ClickAuthorAvatar') &&
!isEventHandled(ev, 'Message.ClickAuthorName') &&
!isEventHandled(ev, 'Message.ClickFailure') &&
!isEventHandled(ev, 'MessageActionList.Click') &&
!isEventHandled(ev, 'MessageReactionGroup.Click') &&
!isEventHandled(ev, 'MessageInReplyToView.ClickMessageInReplyTo')
) {
this.update({ isClicked: !this.isClicked });
}
},
/**
* @param {MouseEvent} ev
*/
onClickAuthorAvatar(ev) {
markEventHandled(ev, 'Message.ClickAuthorAvatar');
if (!this.hasAuthorOpenChat) {
return;
}
this.message.author.openChat();
},
/**
* @param {MouseEvent} ev
*/
onClickAuthorName(ev) {
markEventHandled(ev, 'Message.ClickAuthorName');
if (!this.message.author) {
return;
}
this.message.author.openProfile();
},
/**
* @param {MouseEvent} ev
*/
@@ -31,6 +86,13 @@ registerModel({
markEventHandled(ev, 'Message.ClickFailure');
this.message.openResendAction();
},
/**
* @param {MouseEvent} ev
*/
onClickOriginThread(ev) {
ev.preventDefault();
this.message.originThread.open();
},
onComponentUpdate() {
if (!this.exists()) {
return;
@@ -14,6 +14,34 @@ registerModel({
close() {
this.update({ isOpen: false });
},
/**
* @param {MouseEvent} ev
*/
onClickDesktopTabButton(ev) {
this.update({ activeTabId: ev.currentTarget.dataset.tabId });
},
/**
* @param {MouseEvent} ev
*/
onClickNewMessage(ev) {
if (!this.messaging.device.isMobile) {
this.messaging.chatWindowManager.openNewMessage();
this.close();
} else {
this.toggleMobileNewMessage();
}
},
/**
* @param {MouseEvent} ev
*/
onClickToggler(ev) {
// avoid following dummy href
ev.preventDefault();
if (!this.exists()) {
return;
}
this.toggleOpen();
},
/**
* Toggle the visibility of the messaging menu "new message" input in
* mobile.
@@ -504,6 +504,18 @@ function getCreateComposerSuggestionComponent({ env, modelManager, widget }) {
};
}
function getCreateFollowerListMenuComponent({ env, modelManager, widget }) {
return async function createFollowerListMenuComponent(thread, props) {
const followerListMenuView = modelManager.messaging.models['FollowerListMenuView'].create({
qunitTest: insertAndReplace(),
});
return await createRootMessagingComponent(env, "FollowerListMenu", {
props: { localId: followerListMenuView.localId, threadLocalId: thread.localId, ...props },
target: widget.el,
});
};
}
function getCreateMessageComponent({ env, modelManager, widget }) {
return async function createMessageComponent(message) {
const messageView = modelManager.messaging.models['MessageView'].create({
@@ -930,6 +942,7 @@ async function start(param0 = {}) {
createChatterContainerComponent: getCreateChatterContainerComponent({ afterEvent, env: testEnv, widget }),
createComposerComponent: getCreateComposerComponent({ env: testEnv, modelManager, widget }),
createComposerSuggestionComponent: getCreateComposerSuggestionComponent({ env: testEnv, modelManager, widget }),
createFollowerListMenuComponent: getCreateFollowerListMenuComponent({ env: testEnv, modelManager, widget }),
createMessageComponent: getCreateMessageComponent({ env: testEnv, modelManager, widget }),
createMessagingMenuComponent: getCreateMessagingMenuComponent({ env: testEnv, widget }),
createNotificationListComponent: getCreateNotificationListComponent({ env: testEnv, modelManager, widget }),
@@ -0,0 +1,17 @@
/** @odoo-module **/
import { addFields, patchIdentifyingFields } from '@mail/model/model_core';
import { one } from '@mail/model/model_field';
// ensure that the model definition is loaded before the patch
import '@mail/models/follower_list_menu_view';
addFields('FollowerListMenuView', {
qunitTest: one('QUnitTest', {
inverse: 'followerListMenuView',
readonly: true,
}),
});
patchIdentifyingFields('FollowerListMenuView', identifyingFields => {
identifyingFields[0].push('qunitTest');
});
@@ -18,6 +18,10 @@ registerModel({
inverse: 'qunitTest',
isCausal: true,
}),
followerListMenuView: one('FollowerListMenuView', {
inverse: 'qunitTest',
isCausal: true,
}),
messageView: one('MessageView', {
inverse: 'qunitTest',
isCausal: true,
@@ -2,7 +2,6 @@
import { insert, link } from '@mail/model/model_field_command';
import {
createRootMessagingComponent,
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
@@ -11,27 +10,17 @@ import Bus from 'web.Bus';
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('follower_list_menu_tests.js', {
beforeEach() {
this.createFollowerListMenuComponent = async (thread, target, otherProps = {}) => {
const props = Object.assign({ threadLocalId: thread.localId }, otherProps);
await createRootMessagingComponent(thread.env, "FollowerListMenu", {
props,
target,
});
};
},
});
QUnit.module('follower_list_menu_tests.js');
QUnit.test('base rendering not editable', async function (assert) {
assert.expect(5);
const { messaging, widget } = await start();
const { createFollowerListMenuComponent, messaging } = await start();
const thread = messaging.models['Thread'].create({
id: 100,
model: 'res.partner',
});
await this.createFollowerListMenuComponent(thread, widget.el, { isDisabled: true });
await createFollowerListMenuComponent(thread, { isDisabled: true });
assert.containsOnce(
document.body,
'.o_FollowerListMenu',
@@ -63,13 +52,13 @@ QUnit.test('base rendering not editable', async function (assert) {
QUnit.test('base rendering editable', async function (assert) {
assert.expect(5);
const { click, messaging, widget } = await start();
const { click, createFollowerListMenuComponent, messaging, widget } = await start();
const thread = messaging.models['Thread'].create({
id: 100,
model: 'res.partner',
hasWriteAccess: true,
});
await this.createFollowerListMenuComponent(thread, widget.el);
await createFollowerListMenuComponent(thread, widget.el);
assert.containsOnce(
document.body,
@@ -141,13 +130,13 @@ QUnit.test('click on "add followers" button', async function (assert) {
pyEnv['res.partner'].write([payload.action.context.default_res_id], { message_follower_ids: [mailFollowerId1] });
payload.options.on_close();
});
const { click, messaging, widget } = await start({ env: { bus } });
const { click, createFollowerListMenuComponent, messaging, widget } = await start({ env: { bus } });
const thread = messaging.models['Thread'].create({
hasWriteAccess: true,
id: resPartnerId1,
model: 'res.partner',
});
await this.createFollowerListMenuComponent(thread, widget.el);
await createFollowerListMenuComponent(thread, widget.el);
assert.containsOnce(
document.body,
@@ -210,7 +199,7 @@ QUnit.test('click on remove follower', async function (assert) {
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create();
const { click, messaging, widget } = await start({
const { click, createFollowerListMenuComponent, messaging, widget } = await start({
async mockRPC(route, args) {
if (route.includes('message_unsubscribe')) {
assert.step('message_unsubscribe');
@@ -237,7 +226,7 @@ QUnit.test('click on remove follower', async function (assert) {
name: "François Perusse",
}),
});
await this.createFollowerListMenuComponent(thread, widget.el);
await createFollowerListMenuComponent(thread, widget.el);
await click('.o_FollowerListMenu_buttonFollowers');
assert.containsOnce(
@@ -398,14 +387,14 @@ QUnit.test('Show "Add follower" and subtypes edition/removal buttons on all foll
QUnit.test('Show "No Followers" dropdown-item if there are no followers and user dose not have write access', async function (assert) {
assert.expect(1);
const { click, messaging, widget } = await start();
const { click, createFollowerListMenuComponent, messaging, widget } = await start();
const thread = messaging.models['Thread'].create({
id: 100,
model: 'res.partner',
hasWriteAccess: false,
});
await this.createFollowerListMenuComponent(thread, widget.el);
await createFollowerListMenuComponent(thread, widget.el);
await click('.o_FollowerListMenu_buttonFollowers');
assert.containsOnce(
document.body,