summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/composer
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/composer
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/composer')
-rw-r--r--addons/mail/static/src/components/composer/composer.js444
-rw-r--r--addons/mail/static/src/components/composer/composer.scss273
-rw-r--r--addons/mail/static/src/components/composer/composer.xml179
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js2153
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">
+ &#32;&quot;<t t-esc="composer.thread.name"/>&quot;
+ </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"
+ );
+});
+
+});
+});
+});
+
+});