summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/notification_list
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/notification_list
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/notification_list')
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.js226
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.scss37
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.xml47
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_item.scss179
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js546
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_tests.js162
6 files changed, 1197 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js
new file mode 100644
index 00000000..33737ba4
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.js
@@ -0,0 +1,226 @@
+odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'),
+ NotificationRequest: require('mail/static/src/components/notification_request/notification_request.js'),
+ ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
+ ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.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 { Component } = owl;
+
+class NotificationList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ this.storeProps = useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ // list + notification object created in useStore
+ notifications: 2,
+ },
+ });
+ }
+
+ mounted() {
+ this._loadPreviews();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object[]}
+ */
+ get notifications() {
+ const { notifications } = this.storeProps;
+ return notifications;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Load previews of given thread. Basically consists of fetching all missing
+ * last messages of each thread.
+ *
+ * @private
+ */
+ async _loadPreviews() {
+ const threads = this.notifications
+ .filter(notification => notification.thread && notification.thread.exists())
+ .map(notification => notification.thread);
+ this.env.models['mail.thread'].loadPreviews(threads);
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ */
+ _useStoreSelector(props) {
+ const threads = this._useStoreSelectorThreads(props);
+ let threadNeedactionNotifications = [];
+ if (props.filter === 'all') {
+ // threads with needactions
+ threadNeedactionNotifications = this.env.models['mail.thread']
+ .all(t => t.model !== 'mail.box' && t.needactionMessagesAsOriginThread.length > 0)
+ .sort((t1, t2) => {
+ if (t1.needactionMessagesAsOriginThread.length > 0 && t2.needactionMessagesAsOriginThread.length === 0) {
+ return -1;
+ }
+ if (t1.needactionMessagesAsOriginThread.length === 0 && t2.needactionMessagesAsOriginThread.length > 0) {
+ return 1;
+ }
+ if (t1.lastNeedactionMessageAsOriginThread && t2.lastNeedactionMessageAsOriginThread) {
+ return t1.lastNeedactionMessageAsOriginThread.date.isBefore(t2.lastNeedactionMessageAsOriginThread.date) ? 1 : -1;
+ }
+ if (t1.lastNeedactionMessageAsOriginThread) {
+ return -1;
+ }
+ if (t2.lastNeedactionMessageAsOriginThread) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread_needaction',
+ uniqueId: thread.localId + '_needaction',
+ };
+ });
+ }
+ // thread notifications
+ const threadNotifications = threads
+ .sort((t1, t2) => {
+ if (t1.localMessageUnreadCounter > 0 && t2.localMessageUnreadCounter === 0) {
+ return -1;
+ }
+ if (t1.localMessageUnreadCounter === 0 && t2.localMessageUnreadCounter > 0) {
+ return 1;
+ }
+ if (t1.lastMessage && t2.lastMessage) {
+ return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1;
+ }
+ if (t1.lastMessage) {
+ return -1;
+ }
+ if (t2.lastMessage) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread',
+ uniqueId: thread.localId,
+ };
+ });
+ let notifications = threadNeedactionNotifications.concat(threadNotifications);
+ if (props.filter === 'all') {
+ const notificationGroups = this.env.messaging.notificationGroupManager.groups;
+ notifications = Object.values(notificationGroups)
+ .sort((group1, group2) =>
+ group1.date.isAfter(group2.date) ? -1 : 1
+ ).map(notificationGroup => {
+ return {
+ notificationGroup,
+ uniqueId: notificationGroup.localId,
+ };
+ }).concat(notifications);
+ }
+ // native notification request
+ if (props.filter === 'all' && this.env.messaging.isNotificationPermissionDefault()) {
+ notifications.unshift({
+ type: 'odoobotRequest',
+ uniqueId: 'odoobotRequest',
+ });
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ notifications,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @throws {Error} in case `props.filter` is not supported
+ * @returns {mail.thread[]}
+ */
+ _useStoreSelectorThreads(props) {
+ if (props.filter === 'mailbox') {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ } else if (props.filter === 'channel') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'chat') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.isChatChannel &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'all') {
+ // "All" filter is for channels and chats
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.channel')
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else {
+ throw new Error(`Unsupported filter ${props.filter}`);
+ }
+ }
+
+}
+
+Object.assign(NotificationList, {
+ _allowedFilters: ['all', 'mailbox', 'channel', 'chat'],
+ components,
+ defaultProps: {
+ filter: 'all',
+ },
+ props: {
+ filter: {
+ type: String,
+ validate: prop => NotificationList._allowedFilters.includes(prop),
+ },
+ },
+ template: 'mail.NotificationList',
+});
+
+return NotificationList;
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss
new file mode 100644
index 00000000..18e31149
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.scss
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+ .o_NotificationList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ justify-content: center;
+ }
+}
+
+.o_NotificationList_noConversation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: map-get($spacers, 4) map-get($spacers, 2);
+}
+
+.o_NotificationList_separator {
+ flex: 0 0 auto;
+ width: map-get($sizes, 100);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationList_separator {
+ border-bottom: $border-width solid $border-color;
+}
+
+.o_NotificationList_noConversation {
+ color: $text-muted;
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml
new file mode 100644
index 00000000..e3bfbf38
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationList" owl="1">
+ <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }">
+ <t t-if="notifications.length === 0">
+ <div class="o_NotificationList_noConversation">
+ No conversation yet...
+ </div>
+ </t>
+ <t t-else="">
+ <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId">
+ <t t-if="notification.type === 'thread' and notification.thread">
+ <ThreadPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.type === 'thread_needaction' and notification.thread">
+ <ThreadNeedactionPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.notificationGroup">
+ <NotificationGroup
+ class="o_NotificationList_group"
+ notificationGroupLocalId="notification.notificationGroup.localId"
+ />
+ </t>
+ <t t-if="notification.type === 'odoobotRequest'">
+ <NotificationRequest
+ class="o_NotificationList_notificationRequest"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ />
+ </t>
+ <t t-if="!notification_last">
+ <div class="o_NotificationList_separator"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss
new file mode 100644
index 00000000..af98a9fb
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss
@@ -0,0 +1,179 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+@mixin o-mail-notification-list-item-layout {
+ display: flex;
+ flex: 0 0 auto; // Without this, Safari shrinks parent regardless of child content
+ align-items: center;
+ padding: map-get($spacers, 1);
+
+ &.o-mobile {
+ padding: map-get($spacers, 2);
+ }
+}
+
+@mixin o-mail-notification-list-item-content-layout {
+ display: flex;
+ flex-flow: column;
+ flex: 1 1 auto;
+ align-self: flex-start;
+ min-width: 0; // needed for flex to work correctly
+ margin: map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-core-layout {
+ display: flex;
+}
+
+@mixin o-mail-notification-list-item-core-item-layout {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+
+ &:first-child {
+ margin-inline-start: map-get($spacers, 0);
+ }
+
+ &:last-child {
+ margin-inline-end: map-get($spacers, 0);
+ }
+}
+
+@mixin o-mail-notification-list-item-counter-layout() {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-date-layout() {
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-header-layout {
+ display: flex;
+ margin-bottom: map-get($spacers, 1);
+}
+
+@mixin o-mail-notification-list-item-image-layout {
+ width: map-get($sizes, 100);
+ height: map-get($sizes, 100);
+}
+
+@mixin o-mail-notification-list-item-image-container-layout {
+ position: relative;
+ width: 40px;
+ height: 40px;
+}
+
+@mixin o-mail-notification-list-item-inline-text-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-empty::before {
+ content: '\00a0'; // keep line-height as if it had content
+ }
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-layout() {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-name-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-mobile {
+ font-size: 1.1em;
+ }
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-layout {
+ @include o-position-absolute($bottom: 0, $right: 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin o-mail-notification-list-item-sidebar-layout {
+ margin: map-get($spacers, 1);
+}
+
+// -----------------------------------------------------------------------------
+// Style
+// -----------------------------------------------------------------------------
+
+$o-mail-notification-list-item-background-color: $white !default;
+$o-mail-notification-list-item-hover-background-color:
+ darken($o-mail-notification-list-item-background-color, 7%) !default;
+
+$o-mail-notification-list-item-muted-background-color: gray('100') !default;
+$o-mail-notification-list-item-muted-hover-background-color:
+ darken($o-mail-notification-list-item-muted-background-color, 7%) !default;
+
+@mixin o-mail-notification-list-item-style {
+ cursor: pointer;
+ user-select: none;
+ background-color: $o-mail-notification-list-item-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-hover-background-color;
+ }
+
+ &.o-muted {
+ background-color: $o-mail-notification-list-item-muted-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-muted-hover-background-color;
+ }
+ }
+}
+
+@mixin o-mail-notification-list-item-bold-style {
+ font-weight: bold;
+
+ &.o-muted {
+ font-weight: initial;
+ }
+}
+
+@mixin o-mail-notification-list-item-core-style {
+ color: gray('500');
+}
+
+@mixin o-mail-notification-list-item-date-style() {
+ @include o-mail-notification-list-item-bold-style();
+ font-size: x-small;
+ color: $o-brand-primary;
+}
+
+@mixin o-mail-notification-list-item-image-style {
+ object-fit: cover;
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-style() {
+ opacity: 0;
+
+ &:hover {
+ color: gray('600');
+ }
+}
+
+@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-muted-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-background-color;
+
+ &:not(.o-mobile) {
+ font-size: x-small;
+ }
+
+ &.o-muted {
+ color: $o-mail-notification-list-item-muted-background-color;
+ }
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
new file mode 100644
index 00000000..223ce363
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
@@ -0,0 +1,546 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_notification_group_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' } = {}) => {
+ await createRootComponent(this, components.NotificationList, {
+ props: { filter },
+ 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('notification group basic layout', async function (assert) {
+ assert.expect(10);
+
+ // 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
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ res_model_name: "Channel", // random res model name, will be asserted in the test
+ });
+ // 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
+ notification_type: 'email', // expected failure type for email message
+ });
+ await this.start();
+ await this.createNotificationListComponent();
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_name',
+ "should have 1 group name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_name').textContent,
+ "Channel",
+ "should have model name as group name"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have only 1 notification in the group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_date',
+ "should have 1 group date"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_date').textContent,
+ "a few seconds ago",
+ "should have the group date corresponding to now"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_inlineText',
+ "should have 1 group text"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(),
+ "An error occurred when sending an email.",
+ "should have the group text corresponding to email"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+});
+
+QUnit.test('mark as read', async function (assert) {
+ assert.expect(6);
+
+ // 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
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ res_model_name: "Channel", // random res model name, will be asserted in the test
+ });
+ // 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
+ notification_type: 'email', // expected failure type for email message
+ });
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'mail.mail_resend_cancel_action',
+ "action should be the one to cancel email"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.default_model,
+ 'mail.channel',
+ "action should have the group model as default_model"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.unread_counter,
+ 1,
+ "action should have the group notification length as unread_counter"
+ );
+ });
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+
+ document.querySelector('.o_NotificationGroup_markAsRead').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the cancel email dialog"
+ );
+});
+
+QUnit.test('grouped notifications by document', async function (assert) {
+ // If some failures linked to a document refers to a same document, a single
+ // notification should group all those failures.
+ assert.expect(5);
+
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as second message (and not `mail.channel`)
+ res_id: 31, // same res_id as second message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as first message (and not `mail.channel`)
+ res_id: 31, // same res_id as first message
+ res_model_name: "Partner", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ await this.start({ hasChatWindow: true });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have no chat window initially"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_NotificationGroup').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the thread in a chat window after clicking on it"
+ );
+});
+
+QUnit.test('grouped notifications by document model', async function (assert) {
+ // If all failures linked to a document model refers to different documents,
+ // a single notification should group all failures that are linked to this
+ // document model.
+ assert.expect(12);
+
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as second message (and not `mail.channel`)
+ res_id: 31, // different res_id from second message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as first message (and not `mail.channel`)
+ res_id: 32, // different res_id from first message
+ res_model_name: "Partner", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.name,
+ "Mail Failures",
+ "action should have 'Mail Failures' as name",
+ );
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should have the type act_window"
+ );
+ assert.strictEqual(
+ payload.action.view_mode,
+ 'kanban,list,form',
+ "action should have 'kanban,list,form' as view_mode"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.views),
+ JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]),
+ "action should have correct views"
+ );
+ assert.strictEqual(
+ payload.action.target,
+ 'current',
+ "action should have 'current' as target"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "action should have the group model as res_model"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.domain),
+ JSON.stringify([['message_has_error', '=', true]]),
+ "action should have 'message_has_error' as domain"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+
+ document.querySelector('.o_NotificationGroup').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the related records"
+ );
+});
+
+QUnit.test('different mail.channel are not grouped', async function (assert) {
+ // `mail.channel` is a special case where notifications are not grouped when
+ // they are linked to different channels, even though the model is the same.
+ assert.expect(6);
+
+ this.data['mail.channel'].records.push({ id: 31 }, { id: 32 });
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // testing a channel is the goal of the test
+ res_id: 31, // different res_id from second message
+ res_model_name: "Channel", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // testing a channel is the goal of the test
+ res_id: 32, // different res_id from first message
+ res_model_name: "Channel", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ await this.start({
+ hasChatWindow: true, // needed to assert thread.open
+ });
+ await this.createNotificationListComponent();
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+
+ await afterNextRender(() => groups[0].click());
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the channel related to the first group in a chat window"
+ );
+});
+
+QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) {
+ assert.expect(9);
+
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), // random date
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // different model from second message
+ res_id: 31, // random unique id, useful to link failure to message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ // random date, later than first message
+ date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"),
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.company', // different model from first message
+ res_id: 32, // random unique id, useful to link failure to message
+ res_model_name: "Company", // random related model name
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ await this.start();
+ await this.createNotificationListComponent();
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_name').textContent,
+ "Company",
+ "should have first model name as group name"
+ );
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_name').textContent,
+ "Partner",
+ "should have second model name as group name"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+});
+
+QUnit.test('non-failure notifications are ignored', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push(
+ // message that is expected to have a notification
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // random model
+ res_id: 31, // random unique id, useful to link failure to message
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // notification that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'ready', // non-failure status
+ notification_type: 'email', // expected notification type for email message
+ },
+ );
+ await this.start();
+ await this.createNotificationListComponent();
+ assert.containsNone(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 0 notification group"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js
new file mode 100644
index 00000000..24df5b22
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js
@@ -0,0 +1,162 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' }) => {
+ await createRootComponent(this, components.NotificationList, {
+ props: { filter },
+ 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('marked as read thread notifications are ordered by last message date', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push(
+ { id: 100, name: "Channel 2019" },
+ { id: 200, name: "Channel 2020" }
+ );
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ model: 'mail.channel',
+ res_id: 100,
+ },
+ {
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ model: 'mail.channel',
+ res_id: 200,
+ }
+ );
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "First channel in the list should be the channel of 2020 (more recent last message)"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "Second channel in the list should be the channel of 2019 (least recent last message)"
+ );
+});
+
+QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push(
+ { id: 100, name: "Channel 2019" },
+ { id: 200, name: "Channel 2020" }
+ );
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ model: 'mail.channel',
+ res_id: 100,
+ },
+ {
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ model: 'mail.channel',
+ res_id: 200,
+ }
+ );
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>New message !</p>",
+ channel_ids: [100],
+ date: "2020-03-23 10:00:00",
+ id: 44,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ };
+ this.widget.call('bus_service', 'trigger', 'notification', [
+ [['my-db', 'mail.channel', 100], messageData]
+ ]);
+ });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should still be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "First channel in the list should now be 'Channel 2019'"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "Second channel in the list should now be 'Channel 2020'"
+ );
+});
+
+});
+});
+});
+
+});