summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/messaging_menu
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/components/messaging_menu')
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.js234
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.scss143
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.xml83
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js1039
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>&lt;em&gt;&shoulnotberaised&lt;/em&gt;&lt;script&gt;throw new Error('CodeInjectionError');&lt;/script&gt;</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"
+ );
+});
+
+});
+});
+});
+
+});