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/messaging_menu | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/messaging_menu')
4 files changed, 1499 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js new file mode 100644 index 00000000..9eb7fd71 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js @@ -0,0 +1,234 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.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 patchMixin = require('web.patchMixin'); + +const { Component } = owl; + +class MessagingMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + /** + * global JS generated ID for this component. Useful to provide a + * custom class to autocomplete input, so that click in an autocomplete + * item is not considered as a click away from messaging menu in mobile. + */ + this.id = _.uniqueId('o_messagingMenu_'); + useShouldUpdateBasedOnProps(); + useStore(props => { + return { + isDeviceMobile: this.env.messaging && this.env.messaging.device.isMobile, + isDiscussOpen: this.env.messaging && this.env.messaging.discuss.isOpen, + isMessagingInitialized: this.env.isMessagingInitialized(), + messagingMenu: this.env.messaging && this.env.messaging.messagingMenu.__state, + }; + }); + + // bind since passed as props + this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this); + this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + this._constructor(...args); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.messaging_menu} + */ + get messagingMenu() { + return this.env.messaging && this.env.messaging.messagingMenu; + } + + /** + * @returns {string} + */ + get mobileNewMessageInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {Object[]} + */ + get tabs() { + return [{ + icon: 'fa fa-envelope', + id: 'all', + label: this.env._t("All"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Closes the menu when clicking outside, if appropriate. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (!this.env.messaging) { + /** + * Messaging not created, which means essential models like + * messaging menu are not ready, so user interactions are omitted + * during this (short) period of time. + */ + return; + } + // ignore click inside the menu + if (this.el.contains(ev.target)) { + return; + } + // in all other cases: close the messaging menu when clicking outside + this.messagingMenu.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDesktopTabButton(ev) { + this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickNewMessage(ev) { + if (!this.env.messaging.device.isMobile) { + this.env.messaging.chatWindowManager.openNewMessage(); + this.messagingMenu.close(); + } else { + this.messagingMenu.toggleMobileNewMessage(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggler(ev) { + // avoid following dummy href + ev.preventDefault(); + if (!this.env.messaging) { + /** + * Messaging not created, which means essential models like + * messaging menu are not ready, so user interactions are omitted + * during this (short) period of time. + */ + return; + } + this.messagingMenu.toggleOpen(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileNewMessage(ev) { + ev.stopPropagation(); + this.messagingMenu.toggleMobileNewMessage(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileNewMessageInputSelect(ev, ui) { + this.env.messaging.openChat({ partnerId: ui.item.id }); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileNewMessageInputSource(req, res) { + const value = _.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.detail.tabId }); + } + +} + +Object.assign(MessagingMenu, { + components, + props: {}, + template: 'mail.MessagingMenu', +}); + +return patchMixin(MessagingMenu); + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss new file mode 100644 index 00000000..e578218a --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss @@ -0,0 +1,143 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessagingMenu_counter { + position: relative; + transform: translate(-5px, -5px); + margin-right: -10px; // "cancel" right padding of systray items +} + +.o_MessagingMenu_dropdownMenu { + display: flex; + flex-flow: column; + padding-top: 0; + padding-bottom: 0; + overflow-y: auto; + /** + * Override from bootstrap .dropdown-menu to fix top alignment with other + * systray menu. + */ + margin-top: map-get($spacers, 0); + + &.o-messaging-not-initialized { + align-items: center; + justify-content: center; + } + + &:not(.o-mobile) { + flex: 0 1 auto; + width: 350px; + min-height: 50px; + max-height: 400px; + z-index: 1100; // on top of chat windows + } + + &.o-mobile { + flex: 1 1 auto; + position: fixed; + top: $o-mail-chat-window-header-height-mobile; + bottom: 0; + left: 0; + right: 0; + width: 100%; + margin: 0; + max-height: none; + } +} + +.o_MessagingMenu_dropdownMenuHeader { + + &:not(.o-mobile) { + display: flex; + flex-shrink: 0; // Forces Safari to not shrink below fit content + } + + &.o-mobile { + display: grid; + grid-template-areas: + "top" + "bottom"; + grid-template-rows: auto auto; + padding: 5px + } +} + +.o_MessagingMenu_dropdownLoadingIcon { + margin-right: 3px; +} + +.o_MessagingMenu_icon { + font-size: larger +} + +.o_MessagingMenu_loading { + font-size: small; + position: absolute; + bottom: 50%; + right: 0; +} + +.o_MessagingMenu_newMessageButton.o-mobile { + grid-area: top; + justify-self: start; +} + +.o_MessagingMenu_mobileNewMessageInput { + grid-area: bottom; + padding: 8px; + margin-top: 10px +} + +.o_MessagingMenu_notificationList.o-mobile { + flex: 1 1 auto; + overflow-y: auto; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile +.o_MessagingMenu.o-is-open { + background-color: rgba(black, 0.1); +} + +.o_MessagingMenu_counter { + background-color: $o-enterprise-primary-color; +} + +.o_MessagingMenu_dropdownMenu.o-mobile { + border: 0; +} + +.o_MessagingMenu_dropdownMenuHeader { + border-bottom: 1px solid gray('400'); + z-index: 1; +} + +.o_MessagingMenu_mobileNewMessageInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} + +.o_MessagingMenu_tabButton.o-desktop { + + &.o-active { + font-weight: bold; + } + + &:not(:hover) { + + &:not(.o-active) { + color: gray('500'); + } + } +} + +.o_MessagingMenu_toggler.o-no-notification { + @include o-mail-systray-no-notification-style(); +} diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000..fc779231 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessagingMenu" owl="1"> + <li class="o_MessagingMenu" t-att-class="{ 'o-is-open': messagingMenu ? messagingMenu.isOpen : false, 'o-mobile': env.messaging ? env.messaging.device.isMobile : false }"> + <a class="o_MessagingMenu_toggler" t-att-class="{ 'o-no-notification': messagingMenu ? !messagingMenu.counter : false }" href="#" title="Conversations" role="button" t-att-aria-expanded="messagingMenu and messagingMenu.isOpen ? 'true' : 'false'" aria-haspopup="true" t-on-click="_onClickToggler"> + <i class="o_MessagingMenu_icon fa fa-comments" role="img" aria-label="Messages"/> + <t t-if="!env.isMessagingInitialized()"> + <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/> + </t> + <t t-elif="messagingMenu.counter > 0"> + <span class="o_MessagingMenu_counter badge badge-pill"> + <t t-esc="messagingMenu.counter"/> + </span> + </t> + </a> + <t t-if="messagingMenu and messagingMenu.isOpen"> + <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu"> + <t t-if="!env.messaging.isInitialized"> + <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span> + </t> + <t t-else=""> + <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!env.messaging.device.isMobile"> + <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId"> + <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId"> + <t t-if="tabId === 'all'">All</t> + <t t-elif="tabId === 'chat'">Chat</t> + <t t-elif="tabId === 'channel'">Channels</t> + </button> + </t> + </t> + <t t-if="env.messaging.device.isMobile"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <div class="o-autogrow"/> + <t t-if="!env.messaging.device.isMobile and !discuss.isOpen"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled"> + <AutocompleteInput + class="o_MessagingMenu_mobileNewMessageInput" + customClass="id + '_mobileNewMessageInputAutocomplete'" + isFocusOnMount="true" + placeholder="mobileNewMessageInputPlaceholder" + select="_onMobileNewMessageInputSelect" + source="_onMobileNewMessageInputSource" + t-on-o-hide="_onHideMobileNewMessage" + /> + </t> + </div> + <NotificationList + class="o_MessagingMenu_notificationList" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + filter="messagingMenu.activeTabId" + /> + <t t-if="env.messaging.device.isMobile"> + <MobileMessagingNavbar + class="o_MessagingMenu_mobileNavbar" + activeTabId="messagingMenu.activeTabId" + tabs="tabs" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + </t> + </div> + </t> + </li> + </t> + + <t t-name="mail.MessagingMenu.newMessageButton" owl="1"> + <button class="o_MessagingMenu_newMessageButton btn" + t-att-class="{ + 'btn-link': !env.messaging.device.isMobile, + 'btn-secondary': env.messaging.device.isMobile, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickNewMessage" type="button" + > + New message + </button> + </t> + +</templates> diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js new file mode 100644 index 00000000..d049ab7a --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js @@ -0,0 +1,1039 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('messaging_menu', {}, function () { +QUnit.module('messaging_menu_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + let { discussWidget, env, widget } = await start(Object.assign({}, params, { + data: this.data, + hasMessagingMenu: true, + })); + this.discussWidget = discussWidget; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('[technical] messaging not created then becomes created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(2); + + const messagingBeforeCreationDeferred = makeTestPromise(); + await this.start({ + messagingBeforeCreationDeferred, + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should have messaging menu even when messaging is not yet created" + ); + + // simulate messaging becoming created + messagingBeforeCreationDeferred.resolve(); + await nextAnimationFrame(); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should still contain messaging menu after messaging has been created" + ); +}); + +QUnit.test('[technical] no crash on attempting opening messaging menu when messaging not created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + * + * Messaging menu is not expected to be open on click because state of + * messaging menu requires messaging being created. + */ + assert.expect(2); + + await this.start({ + messagingBeforeCreationDeferred: new Promise(() => {}), // keep messaging not created + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should have messaging menu even when messaging is not yet created" + ); + + let error; + try { + document.querySelector('.o_MessagingMenu_toggler').click(); + await nextAnimationFrame(); + } catch (err) { + error = err; + } + assert.notOk( + !!error, + "Should not crash on attempt to open messaging menu when messaging not created" + ); + if (error) { + throw error; + } +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingCondition: 'created', + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 1, + "should display loading icon on messaging menu when messaging not yet initialized" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent, + "Please wait...", + "should prompt loading when opening messaging menu" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + + // simulate messaging becomes initialized + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 0, + "should no longer display loading icon on messaging menu when messaging becomes initialized" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."), + "should no longer prompt loading when opening messaging menu when messaging becomes initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(21); + + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu').length, + 1, + "should have messaging menu" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should not mark messaging menu item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_toggler`).length, + 1, + "should have clickable element on messaging menu" + ); + assert.notOk( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should not mark messaging menu clickable item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_icon`).length, + 1, + "should have icon on clickable element in messaging menu" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments'), + "should have 'comments' icon on clickable element in messaging menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "should not display any messaging menu dropdown by default" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.hasClass( + document.querySelector('.o_MessagingMenu'), + "o-is-open", + "should mark messaging menu as opened" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 1, + "should display messaging menu dropdown after click" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length, + 1, + "should have dropdown menu header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenuHeader + .o_MessagingMenu_tabButton + `).length, + 3, + "should have 3 tab buttons to filter items in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have button to make a new message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList + `).length, + 1, + "should display thread preview list" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_noConversation + `).length, + 1, + "should display no conversation in thread preview list" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.doesNotHaveClass( + document.querySelector('.o_MessagingMenu'), + "o-is-open", + "should mark messaging menu as closed" + ); +}); + +QUnit.test('counter is taking into account failure notification', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 31, + seen_message_id: 11, + }); + // message that is expected to have a failure + this.data['mail.message'].records.push({ + id: 11, // random unique id, will be used to link failure to message + model: 'mail.channel', // expected value to link message to channel + res_id: 31, // id of a random channel + }); + // failure that is expected to be used in the test + this.data['mail.notification'].records.push({ + mail_message_id: 11, // id of the related message + notification_status: 'exception', // necessary value to have a failure + }); + await this.start(); + + assert.containsOnce( + document.body, + '.o_MessagingMenu_counter', + "should display a notification counter next to the messaging menu for one notification" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); +}); + +QUnit.test('switch tab', async function (assert) { + assert.expect(15); + + await this.start(); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should stay inactive" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should stay active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click() + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should stay inactive" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become inactive" + ); +}); + +QUnit.test('new message', async function (assert) { + assert.expect(3); + + await this.start({ + hasChatWindow: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'), + "chat window should be for new message" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); +}); + +QUnit.test('no new message when discuss is open', async function (assert) { + assert.expect(3); + + await this.start({ + autoOpenDiscuss: true, + hasDiscuss: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open" + ); + + // simulate closing discuss app + await afterNextRender(() => this.discussWidget.on_detach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have 'new message' when discuss is closed" + ); + + // simulate opening discuss app + await afterNextRender(() => this.discussWidget.on_attach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open again" + ); +}); + +QUnit.test('channel preview: basic rendering', async function (assert) { + assert.expect(9); + + this.data['res.partner'].records.push({ + id: 7, // random unique id, to link message author + name: "Demo", // random name, will be asserted in the test + }); + // channel that is expected to be found in the test + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message to channel + name: "General", // random name, will be asserted in the test + }); + // message that is expected to be displayed in the test + this.data['mail.message'].records.push({ + author_id: 7, // not current partner, will be asserted in the test + body: "<p>test</p>", // random body, will be asserted in the test + channel_ids: [20], // id of related channel + model: 'mail.channel', // necessary to link message to channel + res_id: 20, // id of related channel + }); + await this.start(); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_sidebar + `).length, + 1, + "preview should have a sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + `).length, + 1, + "preview should have some content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + `).length, + 1, + "preview should have header in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + .o_ThreadPreview_name + `).length, + 1, + "preview should have name in header of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_name + `).textContent, + "General", "preview should have name of channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + .o_ThreadPreview_core + `).length, + 1, + "preview should have core in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).length, + 1, + "preview should have inline text in core of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).textContent.trim(), + "Demo: test", + "preview should have message content as inline text of core content" + ); +}); + +QUnit.test('filtered previews', async function (assert) { + assert.expect(12); + + // chat and channel expected to be found in the menu + this.data['mail.channel'].records.push( + { channel_type: "chat", id: 10 }, + { id: 20 }, + ); + this.data['mail.message'].records.push( + { + channel_ids: [10], // id of related channel + model: 'mail.channel', // to link message to channel + res_id: 10, // id of related channel + }, + { + channel_ids: [20], // id of related channel + model: 'mail.channel', // to link message to channel + res_id: 20, // id of related channel + }, + ); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 0, + "should not have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 0, + "should not have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); +}); + +QUnit.test('open chat window from preview', async function (assert) { + assert.expect(1); + + // channel expected to be found in the menu, only its existence matters, data are irrelevant + this.data['mail.channel'].records.push({}); + await this.start({ + hasChatWindow: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); +}); + +QUnit.test('no code injection in message body preview', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "You:&shoulnotberaisedthrownewError('CodeInjectionError');", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +QUnit.test('no code injection in message body preview from sanitized message', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "You:<em>&shoulnotberaised</em><script>thrownewError('CodeInjectionError');</script>", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +QUnit.test('<br/> tags in message body preview are transformed in spaces', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p>a<br/>b<br>c<br />d<br ></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText').textContent, + "You: a b c d", + "should display correct last message inline content with brs replaced by spaces" + ); +}); + +QUnit.test('rendering with OdooBot has a request (default)', async function (assert) { + assert.expect(4); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'default', + }, + }, + }, + }); + + assert.ok( + document.querySelector('.o_MessagingMenu_counter'), + "should display a notification counter next to the messaging menu for OdooBot request" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsOnce( + document.body, + '.o_NotificationRequest', + "should display a notification in the messaging menu" + ); + assert.strictEqual( + document.querySelector('.o_NotificationRequest_name').textContent.trim(), + 'OdooBot has a request', + "notification should display that OdooBot has a request" + ); +}); + +QUnit.test('rendering without OdooBot has a request (denied)', async function (assert) { + assert.expect(2); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'denied', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +QUnit.test('rendering without OdooBot has a request (accepted)', async function (assert) { + assert.expect(2); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'granted', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +QUnit.test('respond to notification prompt (denied)', async function (assert) { + assert.expect(3); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'default', + async requestPermission() { + this.permission = 'denied'; + return this.permission; + }, + }, + }, + }, + }); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + await afterNextRender(() => + document.querySelector('.o_NotificationRequest').click() + ); + assert.containsOnce( + document.body, + '.toast .o_notification_content', + "should display a toast notification with the deny confirmation" + ); + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +}); +}); +}); + +}); |
