diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/message | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/message')
| -rw-r--r-- | addons/mail/static/src/components/message/message.js | 680 | ||||
| -rw-r--r-- | addons/mail/static/src/components/message/message.scss | 381 | ||||
| -rw-r--r-- | addons/mail/static/src/components/message/message.xml | 210 | ||||
| -rw-r--r-- | addons/mail/static/src/components/message/message_tests.js | 1580 |
4 files changed, 2851 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js new file mode 100644 index 00000000..a357c024 --- /dev/null +++ b/addons/mail/static/src/components/message/message.js @@ -0,0 +1,680 @@ +odoo.define('mail/static/src/components/message/message.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), + ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.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 { _lt } = require('web.core'); +const { format } = require('web.field_utils'); +const { getLangDatetimeFormat } = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +const READ_MORE = _lt("read more"); +const READ_LESS = _lt("read less"); +const { isEventHandled, markEventHandled } = require('mail/static/src/utils/utils.js'); + +class Message extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + // Determine if the moderation ban dialog is displayed. + hasModerationBanDialog: false, + // Determine if the moderation discard dialog is displayed. + hasModerationDiscardDialog: false, + // Determine if the moderation reject dialog is displayed. + hasModerationRejectDialog: false, + /** + * Determine whether the message is clicked. When message is in + * clicked state, it keeps displaying the commands. + */ + isClicked: false, + }); + useShouldUpdateBasedOnProps(); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const partnerRoot = this.env.messaging.partnerRoot; + const originThread = message ? message.originThread : undefined; + const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId); + const thread = threadView ? threadView.thread : undefined; + return { + attachments: message + ? message.attachments.map(attachment => attachment.__state) + : [], + author, + authorAvatarUrl: author && author.avatarUrl, + authorImStatus: author && author.im_status, + authorNameOrDisplayName: author && author.nameOrDisplayName, + correspondent: thread && thread.correspondent, + hasMessageCheckbox: message ? message.hasCheckbox : false, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessageChecked: message && threadView + ? message.isChecked(thread, threadView.stringifiedDomain) + : false, + message: message ? message.__state : undefined, + notifications: message ? message.notifications.map(notif => notif.__state) : [], + originThread, + originThreadModel: originThread && originThread.model, + originThreadName: originThread && originThread.name, + originThreadUrl: originThread && originThread.url, + partnerRoot, + thread, + threadHasSeenIndicators: thread && thread.hasSeenIndicators, + threadMassMailing: thread && thread.mass_mailing, + }; + }, { + compareDepth: { + attachments: 1, + notifications: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * The intent of the reply button depends on the last rendered state. + */ + this._wasSelected; + /** + * Value of the last rendered prettyBody. Useful to compare to new value + * to decide if it has to be updated. + */ + this._lastPrettyBody; + /** + * Reference to element containing the prettyBody. Useful to be able to + * replace prettyBody with new value in JS (which is faster than t-raw). + */ + this._prettyBodyRef = useRef('prettyBody'); + /** + * Reference to the content of the message. + */ + this._contentRef = useRef('content'); + /** + * To get checkbox state. + */ + this._checkboxRef = useRef('checkbox'); + /** + * Id of setInterval used to auto-update time elapsed of message at + * regular time. + */ + this._intervalId = undefined; + this._constructor(); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + willUnmount() { + clearInterval(this._intervalId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get avatar() { + if ( + this.message.author && + this.message.author === this.env.messaging.partnerRoot + ) { + return '/mail/static/src/img/odoobot.png'; + } else if (this.message.author) { + // TODO FIXME for public user this might not be accessible. task-2223236 + // we should probably use the correspondig attachment id + access token + // or create a dedicated route to get message image, checking the access right of the message + return this.message.author.avatarUrl; + } else if (this.message.message_type === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get the date time of the message at current user locale time. + * + * @returns {string} + */ + get datetime() { + return this.message.date.format(getLangDatetimeFormat()); + } + + /** + * Determines whether author open chat feature is enabled on message. + * + * @returns {boolean} + */ + get hasAuthorOpenChat() { + if (!this.message.author) { + return false; + } + if ( + this.threadView && + this.threadView.thread && + this.threadView.thread.correspondent === this.message.author + ) { + return false; + } + return true; + } + + /** + * Tell whether the bottom of this message is visible or not. + * + * @param {Object} param0 + * @param {integer} [offset=0] + * @returns {boolean} + */ + isBottomVisible({ offset=0 } = {}) { + if (!this.el) { + return false; + } + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // bottom with (double) 10px offset + return ( + elRect.bottom < parentRect.bottom + offset && + parentRect.top < elRect.bottom + offset + ); + } + + /** + * Tell whether the message is partially visible on browser window or not. + * + * @returns {boolean} + */ + isPartiallyVisible() { + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // intersection with 5px offset + return ( + elRect.top < parentRect.bottom + 5 && + parentRect.top < elRect.bottom + 5 + ); + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + /** + * @returns {string} + */ + get OPEN_CHAT() { + return this.env._t("Open chat"); + } + + /** + * Make this message viewable in its enclosing scroll environment (usually + * message list). + * + * @param {Object} [param0={}] + * @param {string} [param0.behavior='auto'] + * @param {string} [param0.block='end'] + * @returns {Promise} + */ + async scrollIntoView({ behavior = 'auto', block = 'end' } = {}) { + this.el.scrollIntoView({ + behavior, + block, + inline: 'nearest', + }); + if (behavior === 'smooth') { + return new Promise(resolve => setTimeout(resolve, 500)); + } else { + return Promise.resolve(); + } + } + + /** + * Get the shorttime format of the message date. + * + * @returns {string} + */ + get shortTime() { + return this.message.date.format('hh:mm'); + } + + /** + * @returns {mail.thread_view} + */ + get threadView() { + return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId); + } + + /** + * @returns {Object} + */ + get trackingValues() { + return this.message.tracking_value_ids.map(trackingValue => { + const value = Object.assign({}, trackingValue); + value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field); + /** + * Maps tracked field type to a JS formatter. Tracking values are + * not always stored in the same field type as their origin type. + * Field types that are not listed here are not supported by + * tracking in Python. Also see `create_tracking_values` in Python. + */ + switch (value.field_type) { + case 'boolean': + value.old_value = format.boolean(value.old_value, undefined, { forceString: true }); + value.new_value = format.boolean(value.new_value, undefined, { forceString: true }); + break; + /** + * many2one formatter exists but is expecting id/name_get or data + * object but only the target record name is known in this context. + * + * Selection formatter exists but requires knowing all + * possibilities and they are not given in this context. + */ + case 'char': + case 'many2one': + case 'selection': + value.old_value = format.char(value.old_value); + value.new_value = format.char(value.new_value); + break; + case 'date': + if (value.old_value) { + value.old_value = moment.utc(value.old_value); + } + if (value.new_value) { + value.new_value = moment.utc(value.new_value); + } + value.old_value = format.date(value.old_value); + value.new_value = format.date(value.new_value); + break; + case 'datetime': + if (value.old_value) { + value.old_value = moment.utc(value.old_value); + } + if (value.new_value) { + value.new_value = moment.utc(value.new_value); + } + value.old_value = format.datetime(value.old_value); + value.new_value = format.datetime(value.new_value); + break; + case 'float': + value.old_value = format.float(value.old_value); + value.new_value = format.float(value.new_value); + break; + case 'integer': + value.old_value = format.integer(value.old_value); + value.new_value = format.integer(value.new_value); + break; + case 'monetary': + value.old_value = format.monetary(value.old_value, undefined, { forceString: true }); + value.new_value = format.monetary(value.new_value, undefined, { forceString: true }); + break; + case 'text': + value.old_value = format.text(value.old_value); + value.new_value = format.text(value.new_value); + break; + } + return value; + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Modifies the message to add the 'read more/read less' functionality + * All element nodes with 'data-o-mail-quote' attribute are concerned. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * FIXME This method should be rewritten (task-2308951) + * + * @private + * @param {jQuery} $element + */ + _insertReadMoreLess($element) { + const groups = []; + let readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + const $children = $element.contents() + .filter((index, content) => + content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim()) + ); + + for (const child of $children) { + let $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('<span>', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + this._insertReadMoreLess($child); + } + } + + for (const group of groups) { + // Insert link just before the first node + const $readMoreLess = $('<a>', { + class: 'o_Message_readMoreLess', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + let isReadMore = true; + $readMoreLess.click(e => { + e.preventDefault(); + isReadMore = !isReadMore; + for (const $child of group) { + $child.hide(); + $child.toggle(!isReadMore); + } + $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS); + }); + } + } + + /** + * @private + */ + _update() { + if (!this.message) { + return; + } + if (this._prettyBodyRef.el && this.message.prettyBody !== this._lastPrettyBody) { + this._prettyBodyRef.el.innerHTML = this.message.prettyBody; + this._lastPrettyBody = this.message.prettyBody; + } + // Remove all readmore before if any before reinsert them with _insertReadMoreLess. + // This is needed because _insertReadMoreLess is working with direct DOM mutations + // which are not sync with Owl. + if (this._contentRef.el) { + for (const el of [...this._contentRef.el.querySelectorAll(':scope .o_Message_readMoreLess')]) { + el.remove(); + } + this._insertReadMoreLess($(this._contentRef.el)); + this.env.messagingBus.trigger('o-component-message-read-more-less-inserted', { + message: this.message, + }); + } + this._wasSelected = this.props.isSelected; + this.message.refreshDateFromNow(); + clearInterval(this._intervalId); + this._intervalId = setInterval(() => { + this.message.refreshDateFromNow(); + }, 60 * 1000); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeCheckbox() { + this.message.toggleCheck(this.threadView.thread, this.threadView.stringifiedDomain); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (ev.target.closest('.o_channel_redirect')) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: 'mail.channel', + }); + // avoid following dummy href + ev.preventDefault(); + return; + } + if (ev.target.tagName === 'A') { + if (ev.target.dataset.oeId && ev.target.dataset.oeModel) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + // avoid following dummy href + ev.preventDefault(); + } + return; + } + if ( + !isEventHandled(ev, 'Message.ClickAuthorAvatar') && + !isEventHandled(ev, 'Message.ClickAuthorName') && + !isEventHandled(ev, 'Message.ClickFailure') + ) { + this.state.isClicked = !this.state.isClicked; + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthorAvatar(ev) { + markEventHandled(ev, 'Message.ClickAuthorAvatar'); + if (!this.hasAuthorOpenChat) { + return; + } + this.message.author.openChat(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthorName(ev) { + markEventHandled(ev, 'Message.ClickAuthorName'); + if (!this.message.author) { + return; + } + this.message.author.openProfile(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFailure(ev) { + markEventHandled(ev, 'Message.ClickFailure'); + this.message.openResendAction(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAccept(ev) { + ev.preventDefault(); + this.message.moderate('accept'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAllow(ev) { + ev.preventDefault(); + this.message.moderate('allow'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationBan(ev) { + ev.preventDefault(); + this.state.hasModerationBanDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationDiscard(ev) { + ev.preventDefault(); + this.state.hasModerationDiscardDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationReject(ev) { + ev.preventDefault(); + this.state.hasModerationRejectDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickOriginThread(ev) { + // avoid following dummy href + ev.preventDefault(); + this.message.originThread.open(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickStar(ev) { + ev.stopPropagation(); + this.message.toggleStar(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + ev.stopPropagation(); + this.message.markAsRead(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickReply(ev) { + // Use this._wasSelected because this.props.isSelected might be changed + // by a global capture click handler (for example the one from Composer) + // before the current handler is executed. Indeed because it does a + // toggle it needs to take into account the value before the click. + if (this._wasSelected) { + this.env.messaging.discuss.clearReplyingToMessage(); + } else { + this.message.replyTo(); + } + } + + /** + * @private + */ + _onDialogClosedModerationBan() { + this.state.hasModerationBanDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.state.hasModerationDiscardDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.state.hasModerationRejectDialog = false; + } + +} + +Object.assign(Message, { + components, + defaultProps: { + hasCheckbox: false, + hasMarkAsReadIcon: false, + hasReplyIcon: false, + isSelected: false, + isSquashed: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasCheckbox: Boolean, + hasMarkAsReadIcon: Boolean, + hasReplyIcon: Boolean, + isSelected: Boolean, + isSquashed: Boolean, + messageLocalId: String, + threadViewLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.Message', +}); + +return Message; + +}); diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss new file mode 100644 index 00000000..16d9c790 --- /dev/null +++ b/addons/mail/static/src/components/message/message.scss @@ -0,0 +1,381 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Message { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Message_authorAvatar { + height: 100%; + width: 100%; + object-fit: cover; +} + +.o_Message_authorAvatarContainer { + position: relative; + height: 36px; + width: 36px; +} + +.o_Message_authorName { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_commandStar { + font-size: 1.3em; +} + +.o_Message_Composer { + flex: 1 1 auto; +} + +.o_Message_commands { + display: flex; + align-items: center; +} + +.o_Message_content { + word-wrap: break-word; + word-break: break-word; + + *:not(li):not(li div) { + // Message content can contain arbitrary HTML that might overflow and break + // the style without this rule. + // Lists are ignored because otherwise bullet style become hidden from overflow. + // It's acceptable not to manage overflow of these tags for the moment. + // It also excludes all div in li because 1st leaf and div child of list overflow + // may impact the bullet point (at least it does on Safari). + max-width: 100%; + overflow-x: auto; + } + + img { + max-width: 100%; + height: auto; + } +} + +.o_Message_core { + min-width: 0; // allows this flex child to shrink more than its content + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_footer { + display: flex; + flex-direction: column; +} + +.o_Message_header { + display: flex; + flex-flow: row wrap; + align-items: baseline; +} + +.o_Message_headerCommands { + margin-inline-end: map-get($spacers, 2); + align-self: center; + + .o_Message_headerCommand { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + + &.o-mobile { + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); + + &:first-child { + padding-left: map-get($spacers, 2); + } + + &:last-child { + padding-right: map-get($spacers, 2); + } + } + } +} + +.o_Message_headerDate { + margin-inline-end: map-get($spacers, 2); + font-size: 0.8em; +} + +.o_Message_moderationAction { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationPending { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationSubHeader { + display: flex; + flex-flow: row wrap; + align-items: center; +} + +.o_Message_originThread { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_partnerImStatusIcon { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +.o_Message_prettyBody { + + > p:last-of-type { + margin-bottom: 0; + } + +} + +.o_Message_readMoreLess { + display: block; +} + +.o_Message_seenIndicator { + margin-inline-end: map-get($spacers, 1); +} + +.o_Message_sidebar { + flex: 0 0 $o-mail-message-sidebar-width; + max-width: $o-mail-message-sidebar-width; + display: flex; + margin-inline-end: map-get($spacers, 2); + justify-content: center; + + &.o-message-squashed { + align-items: flex-start; + } +} + +.o_Message_sidebarItem { + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); + + &.o-message-squashed { + display: flex; + } +} + +.o_Message_trackingValues { + margin-top: map-get($spacers, 2); +} + +.o_Message_trackingValue { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.o_Message_trackingValueItem { + margin-inline-end: map-get($spacers, 1); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Message { + background-color: white; + + &:hover, &.o-clicked { + + .o_Message_commands { + opacity: 1; + } + + .o_Message_sidebarItem.o-message-squashed { + display: flex; + } + + .o_Message_seenIndicator.o-message-squashed { + display: none; + } + } + + .o_Message_partnerImStatusIcon { + color: white; + } + + &.o-not-discussion { + background-color: lighten(gray('300'), 5%); + border-bottom: 1px solid darken(gray('300'), 5%); + + .o_Message_partnerImStatusIcon { + color: lighten(gray('300'), 5%); + } + + &.o-selected { + border-bottom: 1px solid darken(gray('400'), 5%); + } + } + + &.o-selected { + background-color: gray('400'); + + .o_Message_partnerImStatusIcon { + color: gray('400'); + } + } + + &.o-starred { + + .o_Message_commandStar { + display: flex; + } + + .o_Message_commands { + display: flex; + } + } +} + +.o_Message_authorName { + font-weight: bold; +} + +.o_Message_authorRedirect { + cursor: pointer; +} + +.o_Message_command { + cursor: pointer; + color: gray('400'); + + &:not(.o-mobile) { + &:hover { + filter: brightness(0.8); + } + } + + &.o-mobile { + filter: brightness(0.8); + + &:hover { + filter: brightness(0.75); + } + } + + &.o-message-selected { + color: gray('500'); + } +} + +.o_Message_commandStar { + + &.o-message-starred { + color: gold; + + &:hover { + filter: brightness(0.9); + } + } +} + +.o_Message_content .o_mention { + color: $o-brand-primary; + cursor: pointer; + + &:hover { + color: darken($o-brand-primary, 15%); + } +} + +.o_Message_date { + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_headerCommands:not(.o-mobile) { + opacity: 0; +} + +.o_Message_originThread { + font-size: 0.8em; + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_originThreadLink { + font-size: 1.25em; // original size +} + +.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) { + font-size: x-small; +} + +.o_Message_moderationAction { + font-weight: bold; + font-style: italic; + + &.o-accept, + &.o-allow { + color: $o-mail-moderation-accept-color; + @include hover-focus { + color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage); + } + } + + &.o-ban, + &.o-discard, + &.o-reject { + color: $o-mail-moderation-reject-color; + @include hover-focus { + color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage); + } + } +} + +.o_Message_moderationPending { + font-style: italic; + + &.o-author { + color: theme-color('danger'); + font-weight: bold; + } +} + +.o_Message_notificationIconClickable { + color: gray('600'); + cursor: pointer; + + &.o-error { + color: $red; + } +} + +.o_Message_sidebarCommands { + display: none; +} + +.o_Message_sidebarItem.o-message-squashed { + display: none; +} + +.o_Message_subject { + font-style: italic; +} + +// Used to hide buttons on rating emails in chatter +// FIXME: should use a better approach for not having such buttons +// in chatter of such messages, but keep having them in emails. +.o_Message_content [summary~="o_mail_notification"] { + display: none; +} diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml new file mode 100644 index 00000000..32687ea6 --- /dev/null +++ b/addons/mail/static/src/components/message/message.xml @@ -0,0 +1,210 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Message" owl="1"> + <div class="o_Message" + t-att-class="{ + 'o-clicked': state.isClicked, + 'o-discussion': message and (message.is_discussion or message.is_notification), + 'o-mobile': env.messaging.device.isMobile, + 'o-not-discussion': message and !(message.is_discussion or message.is_notification), + 'o-notification': message and message.message_type === 'notification', + 'o-selected': props.isSelected, + 'o-squashed': props.isSquashed, + 'o-starred': message and message.isStarred, + }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId" + > + <t t-if="message" name="rootCondition"> + <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }"> + <t t-if="!props.isSquashed"> + <div class="o_Message_authorAvatarContainer o_Message_sidebarItem"> + <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorOpenChat, o_redirect: hasAuthorOpenChat }" t-att-src="avatar" t-on-click="_onClickAuthorAvatar" t-att-title="hasAuthorOpenChat ? OPEN_CHAT : ''" alt="Avatar"/> + <t t-if="message.author and message.author.im_status"> + <PartnerImStatusIcon + class="o_Message_partnerImStatusIcon" + t-att-class="{ + 'o-message-not-discussion': !(message.is_discussion or message.is_notification), + 'o-message-selected': props.isSelected, + 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile, + }" + hasOpenChat="hasAuthorOpenChat" + partnerLocalId="message.author.localId" + /> + </t> + </div> + </t> + <t t-else=""> + <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-esc="shortTime"/> + </div> + <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="message.message_type !== 'notification'"> + <div class="o_Message_command o_Message_commandStar fa" + t-att-class="{ + 'fa-star': message.isStarred, + 'fa-star-o': !message.isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': message.isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" + /> + </t> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators"> + <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/> + </t> + </t> + </div> + <div class="o_Message_core"> + <t t-if="!props.isSquashed"> + <div class="o_Message_header"> + <t t-if="message.author"> + <div class="o_Message_authorName o_Message_authorRedirect o_redirect" t-on-click="_onClickAuthorName" title="Open profile"> + <t t-esc="message.author.nameOrDisplayName"/> + </div> + </t> + <t t-elif="message.email_from"> + <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}"> + <t t-esc="message.email_from"/> + </a> + </t> + <t t-else=""> + <div class="o_Message_authorName"> + Anonymous + </div> + </t> + <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime"> + - <t t-esc="message.dateFromNow"/> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators"> + <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/> + </t> + <t t-if="threadView and message.originThread and message.originThread !== threadView.thread"> + <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-if="message.originThread.model === 'mail.channel'"> + (from <a class="o_Message_originThreadLink" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name">#<t t-esc="message.originThread.name"/></t><t t-else="">channel</t></a>) + </t> + <t t-else=""> + on <a class="o_Message_originThreadLink" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name"><t t-esc="message.originThread.name"/></t><t t-else="">document</t></a> + </t> + </div> + </t> + <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner"> + <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span> + </t> + <t t-if="threadView and message.originThread and message.originThread === threadView.thread and message.notifications.length > 0"> + <t t-if="message.failureNotifications.length > 0"> + <span class="o_Message_notificationIconClickable o-error" t-on-click="_onClickFailure"> + <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/> + </span> + </t> + <t t-else=""> + <Popover> + <span class="o_Message_notificationIconClickable"> + <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/> + </span> + <t t-set="opened"> + <NotificationPopover + notificationLocalIds="message.notifications.map(notification => notification.localId)" + /> + </t> + </Popover> + </t> + </t> + <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'"> + <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa" + t-att-class="{ + 'fa-star': message.isStarred, + 'fa-star-o': !message.isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': message.isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" title="Mark as Todo" + /> + </t> + <t t-if="props.hasReplyIcon"> + <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickReply" title="Reply" + /> + </t> + <t t-if="props.hasMarkAsReadIcon"> + <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickMarkAsRead" title="Mark as Read" + /> + </t> + </div> + </div> + <t t-if="message.isModeratedByCurrentPartner"> + <div class="o_Message_moderationSubHeader"> + <t t-if="threadView and props.hasCheckbox and message.hasCheckbox"> + <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadView.thread, threadView.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/> + </t> + <span class="o_Message_moderationPending">Pending moderation:</span> + <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a> + <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a> + <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a> + <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a> + <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a> + </div> + </t> + </t> + <div class="o_Message_content" t-ref="content"> + <div class="o_Message_prettyBody" t-ref="prettyBody"/><!-- message.prettyBody is inserted here from _update() --> + <t t-if="message.subtype_description and !message.isBodyEqualSubtypeDescription"> + <p t-esc="message.subtype_description"/> + </t> + <t t-if="trackingValues.length > 0"> + <ul class="o_Message_trackingValues"> + <t t-foreach="trackingValues" t-as="value" t-key="value.id"> + <li> + <div class="o_Message_trackingValue"> + <div class="o_Message_trackingValueFieldName o_Message_trackingValueItem" t-esc="value.changed_field"/> + <t t-if="value.old_value"> + <div class="o_Message_trackingValueOldValue o_Message_trackingValueItem" t-esc="value.old_value"/> + </t> + <div class="o_Message_trackingValueSeparator o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/> + <t t-if="value.new_value"> + <div class="o_Message_trackingValueNewValue o_Message_trackingValueItem" t-esc="value.new_value"/> + </t> + </div> + </li> + </t> + </ul> + </t> + </div> + <t t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and threadView and threadView.thread and (threadView.thread.mass_mailing or [env.messaging.inbox, env.messaging.history].includes(threadView.thread))"> + <p class="o_Message_subject">Subject: <t t-esc="message.subject"/></p> + </t> + <t t-if="message.attachments and message.attachments.length > 0"> + <div class="o_Message_footer"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="message.author === env.messaging.currentPartner" + attachmentLocalIds="message.attachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </div> + </t> + </div> + <t t-if="state.hasModerationBanDialog"> + <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/> + </t> + <t t-if="state.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="state.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js new file mode 100644 index 00000000..67fa9b96 --- /dev/null +++ b/addons/mail/static/src/components/message/message_tests.js @@ -0,0 +1,1580 @@ +odoo.define('mail/static/src/components/message/message_tests.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + beforeEach(this); + + this.createMessageComponent = async (message, otherProps) => { + const props = Object.assign({ messageLocalId: message.localId }, otherProps); + await createRootComponent(this, components.Message, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(12); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message component" + ); + const messageEl = document.querySelector('.o_Message'); + assert.strictEqual( + messageEl.dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "message component should be linked to message store model" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "message should have a sidebar" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length, + 1, + "message should have author avatar in the sidebar" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName, + 'IMG', + "message author avatar should be an image" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + '/web/image/res.partner/7/image_128', + "message author avatar should GET image of the related partner" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "message should display author name" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo User", + "message should display correct author name" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_date`).length, + 1, + "message should display date" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_commands`).length, + 1, + "message should display list of commands" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message should display the content" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_prettyBody`).innerHTML, + "<p>Test</p>", + "message should display the correct content" + ); +}); + +QUnit.test('moderation: as author, moderated channel with pending moderation message', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 1, display_name: "Admin" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + }); + await this.createMessageComponent(message); + + assert.strictEqual( + document.querySelectorAll(`.o_Message_moderationPending.o-author`).length, + 1, + "should have the message pending moderation" + ); +}); + +QUnit.test('moderation: as moderator, moderated channel with pending moderation message', async function (assert) { + assert.expect(9); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + moderators: [['link', this.env.messaging.currentPartner]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + }); + await this.createMessageComponent(message); + const messageEl = document.querySelector('.o_Message'); + assert.ok(messageEl, "should display a message"); + assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`, + "should have the message pending moderation" + ); + assert.containsNone(messageEl, `.o_Message_checkbox`, + "should not have the moderation checkbox by default" + ); + assert.containsN(messageEl, '.o_Message_moderationAction', 5, + "there should be 5 contextual moderation decisions next to the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept', + "there should be a contextual moderation decision to accept the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject', + "there should be a contextual moderation decision to reject the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard', + "there should be a contextual moderation decision to discard the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow', + "there should be a contextual moderation decision to allow the user of the message)" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban', + "there should be a contextual moderation decision to ban the user of the message" + ); + // The actions are tested as part of discuss tests. +}); + +QUnit.test('Notification Sent', async function (assert) { + assert.expect(9); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'sent', + notification_type: 'email', + partner: [['insert', { id: 12, name: "Someone" }]], + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope-o', + "icon should represent email success" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconClickable').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be open" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "popover should have one icon" + ); + assert.hasClass( + document.querySelector('.o_NotificationPopover_notificationIcon'), + 'fa-check', + "popover should have the sent icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "popover should have the partner name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name should be correct" + ); +}); + +QUnit.test('Notification Error', async function (assert) { + assert.expect(8); + + const openResendActionDef = makeDeferred(); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_message_action', + "action should be the one to resend email" + ); + assert.strictEqual( + payload.options.additional_context.mail_message_to_resend, + 10, + "action should have correct message id" + ); + openResendActionDef.resolve(); + }); + + await this.start({ env: { bus } }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope', + "icon should represent email error" + ); + document.querySelector('.o_Message_notificationIconClickable').click(); + await openResendActionDef; + assert.verifySteps( + ['do_action'], + "should do an action to display the resend email dialog" + ); +}); + +QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); +}); + +QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel seen notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is seen" + ); +}); + +QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) { + assert.expect(4); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + let notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); + + // Simulate received channel seen notification + notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is now seen" + ); +}); + +QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) { + assert.expect(2); + + await this.start(); + const author = this.env.models['mail.partner'].create({ + id: 100, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: author.id, + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', author]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { threadViewLocalId: threadViewer.threadView.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_Message_seenIndicator', + "message component should not have any message seen indicator" + ); +}); + +QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + messageSeenIndicators: [['insert', { + channelId: 11, + messageId: 99, + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const lastSeenMessage = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>You already saw me</p>", + id: 100, + originThread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 99, + originThread: [['link', thread]], + }); + thread.update({ + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastSeenMessage: [['link', lastSeenMessage]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastSeenMessage: [['link', lastSeenMessage]], + partnerId: 100, + }, + ]]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V)" + ); +}); + +QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 99 }]], + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 11, + messageId: 100, + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 1, + "message component should have one check (V) because the message was fetched by everyone but no other member than author has seen the message" + ); +}); + +QUnit.test('allow attachment delete on authored message', async function (assert) { + assert.expect(5); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['link', this.env.messaging.currentPartner]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment", + ); + assert.containsOnce( + document.body, + '.o_Attachment_asideItemUnlink', + "should have delete attachment button" + ); + + await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.containsOnce( + document.body, + '.o_AttachmentDeleteConfirmDialog', + "An attachment delete confirmation dialog should have been opened" + ); + assert.strictEqual( + document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent, + `Do you really want to delete "BLAH"?`, + "Confirmation dialog should contain the attachment delete confirmation text" + ); + + await afterNextRender(() => + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click() + ); + assert.containsNone( + document.body, + '.o_Attachment', + "should no longer have an attachment", + ); +}); + +QUnit.test('prevent attachment delete on non-authored message', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['insert', { id: 11, display_name: "Guy" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment", + ); + assert.containsNone( + document.body, + '.o_Attachment_asideItemUnlink', + "delete attachment button should not be printed" + ); +}); + +QUnit.test('subtype description should be displayed if it is different than body', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + body: "<p>Hello</p>", + id: 100, + subtype_description: 'Bonjour', + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "HelloBonjour", + "message content should display both body and subtype description when they are different" + ); +}); + +QUnit.test('subtype description should not be displayed if it is similar to body', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + body: "<p>Hello</p>", + id: 100, + subtype_description: 'hello', + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "Hello", + "message content should display only body when subtype description is similar" + ); +}); + +QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should open view" + ); + assert.strictEqual( + payload.action.res_model, + 'some.model', + "action should open view on 'some.model' model" + ); + assert.strictEqual( + payload.action.res_id, + 250, + "action should open view on 250" + ); + assert.step('do-action:openFormView_some.model_250'); + }); + await this.start({ env: { bus } }); + const message = this.env.models['mail.message'].create({ + body: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`, + id: 100, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.containsOnce( + document.querySelector('.o_Message_content'), + 'a', + "message content should have a link" + ); + + document.querySelector(`.o_Message_content a`).click(); + assert.verifySteps( + ['do-action:openFormView_some.model_250'], + "should have open form view on related record after click on link" + ); +}); + +QUnit.test('chat with author should be opened after clicking on his avatar', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 10 }]], + id: 10, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_authorAvatar', + "message should have the author avatar" + ); + assert.hasClass( + document.querySelector('.o_Message_authorAvatar'), + 'o_redirect', + "author avatar should have the redirect style" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_authorAvatar').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window with thread should be opened after clicking on author avatar" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow_thread').dataset.correspondentId, + message.author.id.toString(), + "chat with author should be opened after clicking on his avatar" + ); +}); + +QUnit.test('chat with author should be opened after clicking on his im status icon', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 10, im_status: 'online' }]], + id: 10, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_partnerImStatusIcon', + "message should have the author im status icon" + ); + assert.hasClass( + document.querySelector('.o_Message_partnerImStatusIcon'), + 'o-has-open-chat', + "author im status icon should have the open chat style" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_partnerImStatusIcon').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window with thread should be opened after clicking on author im status icon" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow_thread').dataset.correspondentId, + message.author.id.toString(), + "chat with author should be opened after clicking on his im status icon" + ); +}); + +QUnit.test('open chat with author on avatar click should be disabled when currently chatting with the author', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ + channel_type: 'chat', + members: [this.data.currentPartnerId, 10], + public: 'private', + }); + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const correspondent = this.env.models['mail.partner'].insert({ id: 10 }); + const message = this.env.models['mail.message'].create({ + author: [['link', correspondent]], + id: 10, + }); + const thread = await correspondent.getChat(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + assert.containsOnce( + document.body, + '.o_Message_authorAvatar', + "message should have the author avatar" + ); + assert.doesNotHaveClass( + document.querySelector('.o_Message_authorAvatar'), + 'o_redirect', + "author avatar should not have the redirect style" + ); + + document.querySelector('.o_Message_authorAvatar').click(); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have no thread opened after clicking on author avatar when currently chatting with the author" + ); +}); + +QUnit.test('basic rendering of tracking value (float type)', async function (assert) { + assert.expect(8); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 45.67, + old_value: 12.3, + }], + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_trackingValue', + "should display a tracking value" + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueFieldName', + "should display the name of the tracked field" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueFieldName').textContent, + "Total:", + "should display the correct tracked field name (Total)", + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueOldValue', + "should display the old value" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueOldValue').textContent, + "12.30", + "should display the correct old value (12.30)", + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueSeparator', + "should display the separator" + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueNewValue', + "should display the new value" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueNewValue').textContent, + "45.67", + "should display the correct new value (45.67)", + ); +}); + +QUnit.test('rendering of tracked field of type integer: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "integer", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:10", + "should display the correct content of tracked field of type integer: from non-0 to 0 (Total: 1 -> 0)" + ); +}); + +QUnit.test('rendering of tracked field of type integer: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "integer", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:01", + "should display the correct content of tracked field of type integer: from 0 to non-0 (Total: 0 -> 1)" + ); +}); + +QUnit.test('rendering of tracked field of type float: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:1.000.00", + "should display the correct content of tracked field of type float: from non-0 to 0 (Total: 1.00 -> 0.00)" + ); +}); + +QUnit.test('rendering of tracked field of type float: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:0.001.00", + "should display the correct content of tracked field of type float: from 0 to non-0 (Total: 0.00 -> 1.00)" + ); +}); + +QUnit.test('rendering of tracked field of type monetary: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "monetary", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:1.000.00", + "should display the correct content of tracked field of type monetary: from non-0 to 0 (Total: 1.00 -> 0.00)" + ); +}); + +QUnit.test('rendering of tracked field of type monetary: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "monetary", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:0.001.00", + "should display the correct content of tracked field of type monetary: from 0 to non-0 (Total: 0.00 -> 1.00)" + ); +}); + +QUnit.test('rendering of tracked field of type boolean: from true to false', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Is Ready", + field_type: "boolean", + id: 6, + new_value: false, + old_value: true, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Is Ready:TrueFalse", + "should display the correct content of tracked field of type boolean: from true to false (Is Ready: True -> False)" + ); +}); + +QUnit.test('rendering of tracked field of type boolean: from false to true', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Is Ready", + field_type: "boolean", + id: 6, + new_value: true, + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Is Ready:FalseTrue", + "should display the correct content of tracked field of type boolean: from false to true (Is Ready: False -> True)" + ); +}); + +QUnit.test('rendering of tracked field of type char: from a string to empty string', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "char", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type char: from a string to empty string (Name: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type char: from empty string to a string', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "char", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type char: from empty string to a string (Name: -> Marc)" + ); +}); + +QUnit.test('rendering of tracked field of type date: from no date to a set date', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "date", + id: 6, + new_value: "2018-12-14", + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018", + "should display the correct content of tracked field of type date: from no date to a set date (Deadline: -> 12/14/2018)" + ); +}); + +QUnit.test('rendering of tracked field of type date: from a set date to no date', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "date", + id: 6, + new_value: false, + old_value: "2018-12-14", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018", + "should display the correct content of tracked field of type date: from a set date to no date (Deadline: 12/14/2018 ->)" + ); +}); + +QUnit.test('rendering of tracked field of type datetime: from no date and time to a set date and time', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "datetime", + id: 6, + new_value: "2018-12-14 13:42:28", + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018 13:42:28", + "should display the correct content of tracked field of type datetime: from no date and time to a set date and time (Deadline: -> 12/14/2018 13:42:28)" + ); +}); + +QUnit.test('rendering of tracked field of type datetime: from a set date and time to no date and time', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "datetime", + id: 6, + new_value: false, + old_value: "2018-12-14 13:42:28", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018 13:42:28", + "should display the correct content of tracked field of type datetime: from a set date and time to no date and time (Deadline: 12/14/2018 13:42:28 ->)" + ); +}); + +QUnit.test('rendering of tracked field of type text: from some text to empty', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "text", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type text: from some text to empty (Name: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type text: from empty to some text', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "text", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type text: from empty to some text (Name: -> Marc)" + ); +}); + +QUnit.test('rendering of tracked field of type selection: from a selection to no selection', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "State", + field_type: "selection", + id: 6, + new_value: "", + old_value: "ok", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "State:ok", + "should display the correct content of tracked field of type selection: from a selection to no selection (State: ok ->)" + ); +}); + +QUnit.test('rendering of tracked field of type selection: from no selection to a selection', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "State", + field_type: "selection", + id: 6, + new_value: "ok", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "State:ok", + "should display the correct content of tracked field of type selection: from no selection to a selection (State: -> ok)" + ); +}); + +QUnit.test('rendering of tracked field of type many2one: from having a related record to no related record', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Author", + field_type: "many2one", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Author:Marc", + "should display the correct content of tracked field of type many2one: from having a related record to no related record (Author: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type many2one: from no related record to having a related record', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Author", + field_type: "many2one", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Author:Marc", + "should display the correct content of tracked field of type many2one: from no related record to having a related record (Author: -> Marc)" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on its author name', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorName`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author name" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on its author avatar', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorAvatar`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author avatar" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on notification failure icon', async function (assert) { + assert.expect(1); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + document.querySelector('.o_Message_notificationIconClickable.o-error').click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on notification failure icon" + ); +}); + +}); +}); +}); + +}); |
