diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/composer | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/composer')
4 files changed, 3049 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js new file mode 100644 index 00000000..31654a4f --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.js @@ -0,0 +1,444 @@ +odoo.define('mail/static/src/components/composer/composer.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + ComposerSuggestedRecipientList: require('mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'), + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); +const { + isEventHandled, + markEventHandled, +} = require('mail/static/src/utils/utils.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Composer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useShouldUpdateBasedOnProps({ + compareDepth: { + textInputSendShortcuts: 1, + }, + }); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const thread = composer && composer.thread; + return { + composer, + composerAttachments: composer ? composer.attachments : [], + composerCanPostMessage: composer && composer.canPostMessage, + composerHasFocus: composer && composer.hasFocus, + composerIsLog: composer && composer.isLog, + composerSubjectContent: composer && composer.subjectContent, + isDeviceMobile: this.env.messaging.device.isMobile, + thread, + threadChannelType: thread && thread.channel_type, // for livechat override + threadDisplayName: thread && thread.displayName, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + threadName: thread && thread.name, + }; + }, { + compareDepth: { + composerAttachments: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * Reference of the emoji popover. Useful to include emoji popover as + * contained "inside" the composer. + */ + this._emojisPopoverRef = useRef('emojisPopover'); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + /** + * Reference of the text input component. + */ + this._textInputRef = useRef('textInput'); + /** + * Reference of the subject input. Useful to set content. + */ + this._subjectRef = useRef('subject'); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * Returns whether the given node is self or a children of self, including + * the emoji popover. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + // emoji popover is outside but should be considered inside + const emojisPopover = this._emojisPopoverRef.comp; + if (emojisPopover && emojisPopover.contains(node)) { + return true; + } + return this.el.contains(node); + } + + /** + * Get the current partner image URL. + * + * @returns {string} + */ + get currentPartnerAvatar() { + const avatar = this.env.messaging.currentUser + ? this.env.session.url('/web/image', { + field: 'image_128', + id: this.env.messaging.currentUser.id, + model: 'res.users', + }) + : '/web/static/src/img/user_menu_avatar.png'; + return avatar; + } + + /** + * Focus the composer. + */ + focus() { + if (this.env.messaging.device.isMobile) { + this.el.scrollIntoView(); + } + this._textInputRef.comp.focus(); + } + + /** + * Focusout the composer. + */ + focusout() { + this._textInputRef.comp.focusout(); + } + + /** + * Determine whether composer should display a footer. + * + * @returns {boolean} + */ + get hasFooter() { + return ( + this.props.hasThreadTyping || + this.composer.attachments.length > 0 || + !this.props.isCompact + ); + } + + /** + * Determine whether the composer should display a header. + * + * @returns {boolean} + */ + get hasHeader() { + return ( + (this.props.hasThreadName && this.composer.thread) || + (this.props.hasFollowers && !this.composer.isLog) + ); + } + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + composers: [['replace', this.composer]], + }; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Post a message in the composer on related thread. + * + * Posting of the message could be aborted if it cannot be posted like if there are attachments + * currently uploading or if there is no text content and no attachments. + * + * @private + */ + async _postMessage() { + if (!this.composer.canPostMessage) { + if (this.composer.hasUploadingAttachment) { + this.env.services['notification'].notify({ + message: this.env._t("Please wait while the file is uploading."), + type: 'warning', + }); + } + return; + } + await this.composer.postMessage(); + // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done + // task-2252858 + this.trigger('o-message-posted'); + } + + /** + * @private + */ + _update() { + if (this.props.isDoFocus) { + this.focus(); + } + if (!this.composer) { + return; + } + if (this._subjectRef.el) { + this._subjectRef.el.value = this.composer.subjectContent; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on attachment button. + * + * @private + */ + _onClickAddAttachment() { + this._fileUploaderRef.comp.openBrowserFileUploader(); + if (!this.env.device.isMobile) { + this.focus(); + } + } + + /** + * Discards the composer when clicking away. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (this.contains(ev.target)) { + return; + } + this.composer.discard(); + } + + /** + * Called when clicking on "expand" button. + * + * @private + */ + _onClickFullComposer() { + this.composer.openFullComposer(); + } + + /** + * Called when clicking on "discard" button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard(ev) { + this.composer.discard(); + } + + /** + * Called when clicking on "send" button. + * + * @private + */ + _onClickSend() { + this._postMessage(); + this.focus(); + } + + /** + * @private + */ + _onComposerSuggestionClicked() { + this.focus(); + } + + /** + * @private + */ + _onComposerTextInputSendShortcut() { + this._postMessage(); + } + + /** + * Called when some files have been dropped in the dropzone. + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + + /** + * Called when selection an emoji from the emoji popover (from the emoji + * button). + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.unicode + */ + _onEmojiSelection(ev) { + ev.stopPropagation(); + this._textInputRef.comp.saveStateInStore(); + this.composer.insertIntoTextInput(ev.detail.unicode); + if (!this.env.device.isMobile) { + this.focus(); + } + } + + /** + * @private + */ + _onInputSubject() { + this.composer.update({ subjectContent: this._subjectRef.el.value }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { + return; + } + if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { + return; + } + ev.preventDefault(); + this.composer.discard(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownEmojiButton(ev) { + if (ev.key === 'Escape') { + if (this._emojisPopoverRef.comp) { + this._emojisPopoverRef.comp.close(); + this.focus(); + markEventHandled(ev, 'Composer.closeEmojisPopover'); + } + } + } + + /** + * @private + * @param {CustomEvent} ev + */ + async _onPasteTextInput(ev) { + if (!ev.clipboardData || !ev.clipboardData.files) { + return; + } + await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files); + } + +} + +Object.assign(Composer, { + components, + defaultProps: { + hasCurrentPartnerAvatar: true, + hasDiscardButton: false, + hasFollowers: false, + hasSendButton: true, + hasThreadName: false, + hasThreadTyping: false, + isCompact: true, + isDoFocus: false, + isExpandable: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + }, + composerLocalId: String, + hasCurrentPartnerAvatar: Boolean, + hasDiscardButton: Boolean, + hasFollowers: Boolean, + hasMentionSuggestionsBelowPosition: { + type: Boolean, + optional: true, + }, + hasSendButton: Boolean, + hasThreadName: Boolean, + hasThreadTyping: Boolean, + /** + * Determines whether this should become focused. + */ + isDoFocus: Boolean, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + isCompact: Boolean, + isExpandable: Boolean, + /** + * If set, keyboard shortcuts from text input to send message. + * If not set, will use default values from `ComposerTextInput`. + */ + textInputSendShortcuts: { + type: Array, + element: String, + optional: true, + }, + }, + template: 'mail.Composer', +}); + +return Composer; + +}); diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss new file mode 100644 index 00000000..df695cce --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.scss @@ -0,0 +1,273 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Composer { + display: grid; + grid-template-areas: + "sidebar-header core-header" + "sidebar-main core-main" + "sidebar-footer core-footer"; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + + &.o-has-current-partner-avatar { + grid-template-columns: 50px 1fr; + padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 4) map-get($spacers, 1); + + &:not(.o-has-footer) { + padding-bottom: 20px; + } + + &:not(.o-has-header) { + padding-top: 20px; + } + } +} + +.o_Composer_actionButtons { + &.o-composer-is-compact { + display: flex; + } + &:not(.o-composer-is-compact) { + margin-top: 10px; + } +} + +.o_Composer_attachmentList { + flex: 1 1 auto; + + &.o-composer-is-compact { + max-height: 100px; + } + + &:not(.o-composer-is-compact) { + overflow-y: auto; + max-height: 300px; + } +} + +.o_Composer_buttons { + display: flex; + align-items: stretch; + align-self: stretch; + flex: 0 0 auto; + min-height: 41px; // match minimal-height of input, including border width + + &:not(.o-composer-is-compact) { + border: 0; + height: auto; + padding: 0 10px; + width: 100%; + } +} + +.o_Composer_coreFooter { + grid-area: core-footer; + overflow-x: hidden; + + &:not(.o-composer-is-compact) { + margin-left: 0; + } +} + +.o_Composer_coreHeader { + grid-area: core-header; +} + +.o_Composer_coreMain { + grid-area: core-main; + min-width: 0; + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + flex: 1 1 auto; + + &:not(.o-composer-is-compact) { + flex-direction: column; + } +} + +.o_Composer_currentPartnerAvatar { + width: 36px; + height: 36px; +} + +.o_Composer_followers, +.o_Composer_suggestedPartners { + flex: 0 0 100%; + margin-bottom: $o-mail-chatter-gap * 0.5; +} + +.o_Composer_primaryToolButtons { + display: flex; + align-items: center; + + &.o-composer-is-compact { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + } +} + +.o_Composer_sidebarMain { + grid-area: sidebar-main; + justify-self: center; +} + +.o_Composer_subject { + border-top: $border-width solid $border-color; + border-right: $border-width solid $border-color; + border-left: $border-width solid $border-color; + border-radius: $o-mail-rounded-rectangle-border-radius-sm $o-mail-rounded-rectangle-border-radius-sm 0 0; +} + +.o_Composer_subjectInput { + display: flex; + flex: 1; + padding: map-get($spacers, 2) map-get($spacers, 3); + border: 0; +} + +.o_Composer_textInput { + flex: 1 1 auto; + align-self: stretch; + + &:not(.o-composer-is-compact) { + border: 0; + min-height: 40px; + } +} + +.o_Composer_threadTextualTypingStatus { + font-size: $font-size-sm; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + // invisible character so that typing status bar has constant height, regardless of text content. + content: "\200b"; /* unicode zero width space character */ + } +} + +.o_Composer_toolButton { + // keep a margin between the buttons to prevent their focus shadow from overlapping + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); +} + +.o_Composer_toolButtons { + display: flex; + padding-top: map-get($spacers, 1); + padding-bottom: map-get($spacers, 1); + + &:not(.o-composer-is-compact) { + flex-direction: row; + justify-content: space-between; + flex: 100%; + } +} + +.o_Composer_toolButtonSeparator { + flex: 0 0 auto; + margin-top: map-get($spacers, 2); + margin-bottom: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// TODO FIXME o-open on the button should be enough. +// Style of button when popover is "open" comes from web.Popover, and we can't +// define a modifier on .o_Composer_button due to not being aware of Popover's +// state in context of template. https://github.com/odoo/owl/issues/693 +.o_is_open .o_Composer_toolButton { + background-color: gray('200'); +} + +.o_Composer { + background-color: lighten(gray('300'), 7%); +} + +.o_Composer_actionButton.o-last.o-has-current-partner-avatar.o-composer-is-compact { + border-radius: 0 $o-mail-rounded-rectangle-border-radius-lg $o-mail-rounded-rectangle-border-radius-lg 0; +} + +.o_Composer_button.o-composer-is-compact { + border-left: none; // overrides bootstrap button style + + :last-child { + border-radius: 0 3px 3px 0; + } +} + +.o_Composer_buttonDiscard { + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_buttons { + border: 0; +} + +.o_Composer_coreMain:not(.o-composer-is-compact) { + background: white; + border: 1px solid lighten(gray('400'), 5%); + + // textarea should be all rounded but only when there is no subject field above + &:not(.o-composer-is-extended) { + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + } +} + +.o_Composer_currentPartnerAvatar { + object-fit: cover; +} + +.o_Composer_textInput { + appearance: none; + outline: none; + background-color: white; + border: 0; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + border-left: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border: 0; + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + } + + &.o-has-current-partner-avatar.o-composer-is-compact { + border-radius: $o-mail-rounded-rectangle-border-radius-lg 0 0 $o-mail-rounded-rectangle-border-radius-lg; + } +} + +.o_Composer_toolButton { + border: 0; // overrides bootstrap btn + background-color: white; // overrides bootstrap btn-light + color: gray('600'); // overrides bootstrap btn-light + border-radius: 50%; + + &.o-open { + background-color: gray('200'); + } +} + +.o_Composer_toolButtons { + background-color: white; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border-bottom: 0; + border-radius: initial; + } + + &:last-child:not(.o-composer-has-current-partner-avatar) { + border-right: 1px solid lighten(gray('400'), 5%); + } +} + +.o_Composer_toolButtonSeparator { + border-left: 1px solid lighten(gray('400'), 5%); +} diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml new file mode 100644 index 00000000..cc7038d3 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.xml @@ -0,0 +1,179 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Composer" owl="1"> + <div class="o_Composer" + t-att-class="{ + 'o-focused': composer and composer.hasFocus, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-has-footer': hasFooter, + 'o-has-header': hasHeader, + 'o-is-compact': props.isCompact, + }" + t-on-keydown="_onKeydown" + > + <t t-if="composer"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_Composer_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <FileUploader + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + t-ref="fileUploader" + /> + <t t-if="hasHeader"> + <div class="o_Composer_coreHeader"> + <t t-if="props.hasThreadName and composer.thread"> + <span class="o_Composer_threadName"> + on: <b><t t-esc="composer.thread.displayName"/></b> + </span> + </t> + <t t-if="props.hasFollowers and !composer.isLog"> + <!-- Text for followers --> + <small class="o_Composer_followers"> + <b class="text-muted">To: </b> + <em class="text-muted">Followers of </em> + <b> + <t t-if="composer.thread and composer.thread.name"> +  "<t t-esc="composer.thread.name"/>" + </t> + <t t-else=""> + this document + </t> + </b> + </small> + <ComposerSuggestedRecipientList + threadLocalId="composer.thread.localId" + /> + </t> + </div> + </t> + <t t-if="composer.thread and composer.thread.model === 'mail.channel' and composer.thread.mass_mailing"> + <div class="o_Composer_subject"> + <input class="o_Composer_subjectInput" type="text" placeholder="Subject" t-on-input="_onInputSubject" t-ref="subject"/> + </div> + </t> + <t t-if="props.hasCurrentPartnerAvatar"> + <div class="o_Composer_sidebarMain"> + <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/> + </div> + </t> + <div + class="o_Composer_coreMain" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o-composer-is-extended': composer.thread and composer.thread.mass_mailing, + }" + > + <TextInput + class="o_Composer_textInput" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o_Composer_textInput-mobile': env.messaging.device.isMobile, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + }" + composerLocalId="composer.localId" + hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition" + isCompact="props.isCompact" + sendShortcuts="props.textInputSendShortcuts" + t-on-o-composer-suggestion-clicked="_onComposerSuggestionClicked" + t-on-o-composer-text-input-send-shortcut="_onComposerTextInputSendShortcut" + t-on-paste="_onPasteTextInput" + t-key="composer.localId" + t-ref="textInput" + /> + <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }"> + <div class="o_Composer_toolButtons" + t-att-class="{ + 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-composer-is-compact': props.isCompact, + }"> + <t t-if="props.isCompact"> + <div class="o_Composer_toolButtonSeparator"/> + </t> + <div class="o_Composer_primaryToolButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection"> + <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 --> + <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-light" + t-att-class="{ + 'o-open': false and state.displayed, + 'o-mobile': env.messaging.device.isMobile, + }" + t-on-keydown="_onKeydownEmojiButton" + > + <i class="fa fa-smile-o"/> + </button> + <t t-set="opened"> + <EmojisPopover t-ref="emojisPopover"/> + </t> + </Popover> + <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-light fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/> + </div> + <t t-if="props.isExpandable"> + <div class="o_Composer_secondaryToolButtons"> + <button class="btn btn-light fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/> + </div> + </t> + </div> + <t t-if="props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </div> + <t t-if="hasFooter"> + <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasThreadTyping"> + <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/> + </t> + <t t-if="composer.attachments.length > 0"> + <AttachmentList + class="o_Composer_attachmentList" + t-att-class="{ 'o-composer-is-compact': props.isCompact }" + areAttachmentsEditable="true" + attachmentsDetailsMode="props.attachmentsDetailsMode" + attachmentsImageSize="'small'" + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + showAttachmentsExtensions="props.showAttachmentsExtensions" + showAttachmentsFilenames="props.showAttachmentsFilenames" + /> + </t> + <t t-if="!props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </t> + </t> + </div> + </t> + + <t t-name="mail.Composer.actionButtons" owl="1"> + <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasSendButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary" + t-att-class="{ + 'fa': env.messaging.device.isMobile, + 'fa-paper-plane-o': env.messaging.device.isMobile, + 'o-last': env.messaging.device.isMobile or !props.hasDiscardButton, + 'o-composer-is-compact': props.isCompact, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + }" + t-att-disabled="!composer.canPostMessage ? 'disabled' : ''" + type="button" + t-on-click="_onClickSend" + > + <t t-if="!env.messaging.device.isMobile"><t t-if="composer.isLog">Log</t><t t-else="">Send</t></t> + </button> + </t> + <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton"> + <button class="o_Composer_actionButton o-last o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar }" type="button" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js new file mode 100644 index 00000000..a4ff5978 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -0,0 +1,2153 @@ +odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + dropFiles, + nextAnimationFrame, + pasteFiles, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { + createFile, + inputFiles, + }, + makeTestPromise, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer', {}, function () { +QUnit.module('composer_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerComponent = async (composer, otherProps) => { + const props = Object.assign({ composerLocalId: composer.localId }, otherProps); + await createRootComponent(this, components.Composer, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('composer text input: basic rendering when posting a message', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Send a message to followers...", + "should have 'Send a message to followers...' as placeholder composer text input" + ); +}); + +QUnit.test('composer text input: basic rendering when logging note', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: true }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Log an internal note...", + "should have 'Log an internal note...' as placeholder in composer text input if composer is log" + ); +}); + +QUnit.test('composer text input: basic rendering when linked thread is a mail.channel', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Write something...", + "should have 'Write something...' as placeholder in composer text input if composer is for a 'mail.channel'" + ); +}); + +QUnit.test('mailing channel composer: basic rendering', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered, with proper mass_mailing + // value and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.containsOnce( + document.body, + '.o_ComposerTextInput', + "Composer should have a text input" + ); + assert.containsOnce( + document.body, + '.o_Composer_subjectInput', + "Composer should have a subject input" + ); +}); + +QUnit.test('add an emoji', async function (assert) { + assert.expect(1); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "emoji should be inserted in the composer text input" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add an emoji after some text', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla", + "composer text input should have text only initially" + ); + + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla😊", + "emoji should be inserted after the text" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + await afterNextRender(() => { + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + composerTextInputTextArea.value, + "Blabla", + "composer text input should have text only initially" + ); + + // simulate selection of all the content by keyboard + composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length); + + // select emoji + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "whole text selection should have been replaced by emoji" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('display canned response suggestions on typing ":"', async function (assert) { + assert.expect(2); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "Canned responses suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display canned response suggestions on typing ':'" + ); +}); + +QUnit.test('use a canned response', async function (assert) { + assert.expect(4); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have canned response + additional whitespace afterwards" + ); +}); + +QUnit.test('use a canned response some text', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a canned response', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? 😊", + "text content of composer should have previous canned response substitution and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display channel mention suggestions on typing "#"', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display channel mention suggestions on typing '#'" + ); +}); + +QUnit.test('mention a channel', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a channel after some text', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh #General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a channel mention', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General 😊", + "text content of composer should have previous channel mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display command suggestions on typing "/"', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display command suggestions on typing '/'" + ); +}); + +QUnit.test('do not send typing notification on typing "/" command', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel_command'].records.push({ + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.verifySteps([], "No rpc done"); +}); + +QUnit.test('do not send typing notification on typing after selecting suggestion from "/" command', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel_command'].records.push({ + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, " is user?"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.verifySteps([], "No rpc done"); +}); + +QUnit.test('use a command for a specific channel type', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have used command + additional whitespace afterwards" + ); +}); + +QUnit.test("channel with no commands should not prompt any command suggestions on typing /", async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ channel_type: 'chat', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "bla bla bla", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + await afterNextRender(() => { + document.querySelector('.o_ComposerTextInput_textarea').focus(); + document.execCommand('insertText', false, "/"); + const composer_text_input = document.querySelector('.o_ComposerTextInput_textarea'); + composer_text_input.dispatchEvent(new window.KeyboardEvent('keydown')); + composer_text_input.dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "should not prompt (command) suggestion after typing / (reason: no channel commands in chat channels)" + ); +}); + +QUnit.test('command suggestion should only open if command is the first character', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "should not have a command suggestion" + ); +}); + +QUnit.test('add an emoji after a command', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have previous content + used command + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who 😊", + "text content of composer should have previous command and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ + id: 11, + email: "testpartner@odoo.com", + name: "TestPartner", + }); + this.data['res.partner'].records.push({ + id: 12, + email: "testpartner2@odoo.com", + name: "TestPartner2", + }); + this.data['res.users'].records.push({ + partner_id: 11, + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display mention suggestions on typing '@'" + ); + assert.containsOnce( + document.body, + '.dropdown-divider', + "should have a separator" + ); +}); + +QUnit.test('mention a partner', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a partner after some text', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh @TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a partner mention', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner 😊", + "text content of composer should have previous mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('composer: add an attachment', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.ok( + document.querySelector('.o_Composer_attachmentList'), + "should have an attachment list" + ); + assert.ok( + document.querySelector(`.o_Composer .o_Attachment`), + "should have an attachment" + ); +}); + +QUnit.test('composer: drop attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + await createFile({ + content: 'hello, worlduh', + contentType: 'text/plain', + name: 'text2.txt', + }), + ]; + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "should have 2 attachments in the composer after files dropped" + ); + + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + await afterNextRender(async () => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }) + ] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +QUnit.test('composer: paste attachments', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }) + ]; + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should not have any attachment in the composer before paste" + ); + + await afterNextRender(() => + pasteFiles(document.querySelector('.o_ComposerTextInput'), files) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer after paste" + ); +}); + +QUnit.test('send message when enter is pressed while holding ctrl key (this shortcut is available)', async function (assert) { + // Note that test doesn't assert ENTER makes no newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['ctrl-enter'], + }); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable as message has not been posted" + ); + + // Send message with ctrl+enter + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input as message has been posted" + ); +}); + +QUnit.test('send message when enter is pressed while holding meta key (this shortcut is available)', async function (assert) { + // Note that test doesn't assert ENTER makes no newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['meta-enter'], + }); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable as message has not been posted" + ); + + // Send message with meta+enter + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', metaKey: true })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input as message has been posted" + ); +}); + +QUnit.test('composer text input cleared on message post', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + // Send message + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); +}); + +QUnit.test('composer inputs cleared on message post in composer of a mailing channel', async function (assert) { + assert.expect(10); + + // channel that is expected to be rendered, with proper mass_mailing + // value and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + assert.ok( + 'body' in args.kwargs, + "body should be posted with the message" + ); + assert.strictEqual( + args.kwargs.body, + "test message", + "posted body should be the one typed in text input" + ); + assert.ok( + 'subject' in args.kwargs, + "subject should be posted with the message" + ); + assert.strictEqual( + args.kwargs.subject, + "test subject", + "posted subject should be the one typed in subject input" + ); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Composer_subjectInput`).focus(); + document.execCommand('insertText', false, "test subject"); + }); + assert.strictEqual( + document.querySelector(`.o_Composer_subjectInput`).value, + "test subject", + "should have inserted text content in input" + ); + + // Send message + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); + assert.strictEqual( + document.querySelector(`.o_Composer_subjectInput`).value, + "", + "should have no content in composer subject input after posting message" + ); +}); + +QUnit.test('composer with thread typing notification status', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + assert.containsOnce( + document.body, + '.o_Composer_threadTextualTypingStatus', + "Composer should have a thread textual typing status bar" + ); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "By default, thread textual typing status bar should be empty" + ); +}); + +QUnit.test('current partner notify is typing to other thread members', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); +}); + +QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) { + assert.expect(3); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); + + await nextAnimationFrame(); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "Thread textual typing status bar should not display current partner is typing" + ); +}); + +QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + await this.env.testUtils.advanceTime(5 * 1000); + assert.verifySteps( + ['notify_typing:false'], + "should have notified current partner no longer is typing (inactive for 5 seconds)" + ); +}); + +QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + // simulate current partner typing a character every 2.5 seconds for 50 seconds straight. + let totalTimeElapsed = 0; + const elapseTickTime = 2.5 * 1000; + while (totalTimeElapsed < 50 * 1000) { + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + totalTimeElapsed += elapseTickTime; + await this.env.testUtils.advanceTime(elapseTickTime); + } + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is still typing after 50s of straight typing" + ); +}); + +QUnit.test('composer: send button is disabled if attachment upload is not finished', async function (assert) { + assert.expect(8); + + const attachmentUploadedPromise = makeTestPromise(); + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + await attachmentUploadedPromise; + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment after a file has been input" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + assert.ok( + !!document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be disabled as attachment is not yet uploaded" + ); + + // simulates attachment finishes uploading + await afterNextRender(() => attachmentUploadedPromise.resolve()); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsNone( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed should be uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should still be present" + ); + assert.ok( + !document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be enabled as attachment is now uploaded" + ); +}); + +QUnit.test('warning on send with shortcut when attempting to post message with still-uploading attachments', async function (assert) { + assert.expect(7); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates attachment is never finished uploading + await new Promise(() => {}); + } + return res; + }, + services: { + notification: { + notify(params) { + assert.strictEqual( + params.message, + "Please wait while the file is uploading.", + "notification content should be about the uploading file" + ); + assert.strictEqual( + params.type, + 'warning', + "notification should be a warning" + ); + assert.step('notification'); + } + } + }, + }); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['enter'], + }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + + // Try to send message + document + .querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })); + assert.verifySteps( + ['notification'], + "should have triggered a notification for inability to post message at the moment (some attachments are still being uploaded)" + ); +}); + +QUnit.test('remove an attachment from composer does not need any confirmation', async function (assert) { + assert.expect(3); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Composer_attachmentList', + "should have an attachment list" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment', + "should have only one attachment" + ); + + await afterNextRender(() => + document.querySelector('.o_Attachment_asideItemUnlink').click() + ); + assert.containsNone( + document.body, + '.o_Composer .o_Attachment', + "should not have any attachment left after unlinking the only one" + ); +}); + +QUnit.test('remove an uploading attachment', async function (assert) { + assert.expect(4); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates uploading indefinitely + await new Promise(() => {}); + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Composer_attachmentList', + "should have an attachment list" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment', + "should have only one attachment" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment.o-temporary', + "should have an uploading attachment" + ); + + await afterNextRender(() => + document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.containsNone( + document.body, + '.o_Composer .o_Attachment', + "should not have any attachment left after unlinking temporary one" + ); +}); + +QUnit.test('remove an uploading attachment aborts upload', async function (assert) { + assert.expect(1); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates uploading indefinitely + await new Promise(() => {}); + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should contain an attachment" + ); + const attachmentLocalId = document.querySelector('.o_Attachment').dataset.attachmentLocalId; + + await this.afterEvent({ + eventName: 'o-attachment-upload-abort', + func: () => { + document.querySelector('.o_Attachment_asideItemUnlink').click(); + }, + message: "attachment upload request should have been aborted", + predicate: ({ attachment }) => { + return attachment.localId === attachmentLocalId; + }, + }); +}); + +QUnit.test("basic rendering when sending a message to the followers and thread doesn't have a name", async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer, { hasFollowers: true }); + assert.strictEqual( + document.querySelector('.o_Composer_followers').textContent.replace(/\s+/g, ''), + "To:Followersofthisdocument", + "Composer should display \"To: Followers of this document\" if the thread as no name." + ); +}); + +QUnit.test('send message only once when button send is clicked twice quickly', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + + await afterNextRender(() => { + document.querySelector(`.o_Composer_buttonSend`).click(); + document.querySelector(`.o_Composer_buttonSend`).click(); + }); + assert.verifySteps( + ['message_post'], + "The message has been posted only once" + ); +}); + +QUnit.test('send message only once when enter is pressed twice quickly', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['enter'], + }); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.verifySteps( + ['message_post'], + "The message has been posted only once" + ); +}); + +QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) { + // Uploading multiple files uploads attachments one at a time, this test + // ensures that there is no crash when an attachment is destroyed before its + // upload started. + assert.expect(1); + + // Promise to block attachment uploading + const uploadPromise = makeTestPromise(); + await this.start({ + async mockFetch(resource) { + const _super = this._super.bind(this, ...arguments); + if (resource === '/web/binary/upload_attachment') { + await uploadPromise; + } + return _super(); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file1 = await createFile({ + name: 'text1.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + const file2 = await createFile({ + name: 'text2.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file1, file2] + ) + ); + await afterNextRender(() => { + Array.from(document.querySelectorAll('div')) + .find(el => el.textContent === 'text2.txt') + .closest('.o_Attachment') + .querySelector('.o_Attachment_asideItemUnlink') + .click(); + } + ); + // Simulates the completion of the upload of the first attachment + uploadPromise.resolve(); + assert.containsOnce( + document.body, + '.o_Attachment:contains("text1.txt")', + "should only have the first attachment after cancelling the second attachment" + ); +}); + +}); +}); +}); + +}); |
