summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/widgets
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/widgets
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/widgets')
-rw-r--r--addons/mail/static/src/widgets/common.xml25
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.js397
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.scss36
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.xml27
-rw-r--r--addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js124
-rw-r--r--addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml13
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer.js188
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer.scss17
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js982
-rw-r--r--addons/mail/static/src/widgets/messaging_menu/messaging_menu.js56
-rw-r--r--addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml8
-rw-r--r--addons/mail/static/src/widgets/notification_alert/notification_alert.js45
-rw-r--r--addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js103
13 files changed, 2021 insertions, 0 deletions
diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml
new file mode 100644
index 00000000..9106f96f
--- /dev/null
+++ b/addons/mail/static/src/widgets/common.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ AKU FIXME: use mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js component instead
+ @param {string} status
+ -->
+ <t t-name="mail.widgets.UserStatus">
+ <span>
+ <t t-if="status == 'online'">
+ <i class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
+ </t>
+ <t t-if="status == 'away'">
+ <i class="fa fa-circle o_mail_user_status o_user_idle" title="Idle" role="img" aria-label="User is idle"/>
+ </t>
+ <t t-if="status == 'offline'">
+ <i class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
+ </t>
+ <t t-if="status == 'bot'">
+ <i class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js
new file mode 100644
index 00000000..86f3ba32
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.js
@@ -0,0 +1,397 @@
+odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ Discuss: require('mail/static/src/components/discuss/discuss.js'),
+};
+const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js');
+
+const AbstractAction = require('web.AbstractAction');
+const { action_registry, qweb } = require('web.core');
+
+const { Component } = owl;
+
+const DiscussWidget = AbstractAction.extend({
+ template: 'mail.widgets.Discuss',
+ hasControlPanel: true,
+ loadControlPanel: true,
+ withSearchBar: true,
+ searchMenuTypes: ['filter', 'favorite'],
+ /**
+ * @override {web.AbstractAction}
+ * @param {web.ActionManager} parent
+ * @param {Object} action
+ * @param {Object} [action.context]
+ * @param {string} [action.context.active_id]
+ * @param {Object} [action.params]
+ * @param {string} [action.params.default_active_id]
+ * @param {Object} [options={}]
+ */
+ init(parent, action, options={}) {
+ this._super(...arguments);
+
+ // render buttons in control panel
+ this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons'));
+ this.$buttons.find('button').css({ display: 'inline-block' });
+ this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead',
+ ev => this._onClickMarkAllAsRead(ev)
+ );
+ this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev));
+ this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev));
+ this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev));
+
+ // control panel attributes
+ this.action = action;
+ this.actionManager = parent;
+ this.searchModelConfig.modelName = 'mail.message';
+ this.discuss = undefined;
+ this.options = options;
+
+ this.component = undefined;
+
+ this._lastPushStateActiveThread = null;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ this.env = Component.env;
+ await this.env.messagingCreatedPromise;
+ const initActiveId = this.options.active_id ||
+ (this.action.context && this.action.context.active_id) ||
+ (this.action.params && this.action.params.default_active_id) ||
+ 'mail.box_inbox';
+ this.discuss = this.env.messaging.discuss;
+ this.discuss.update({ initActiveId });
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.$buttons) {
+ this.$buttons.off().remove();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_attach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ // prevent twice call to on_attach_callback (FIXME)
+ return;
+ }
+ const DiscussComponent = components.Discuss;
+ this.component = new DiscussComponent();
+ this._pushStateActionManagerEventListener = ev => {
+ ev.stopPropagation();
+ if (this._lastPushStateActiveThread === this.discuss.thread) {
+ return;
+ }
+ this._pushStateActionManager();
+ this._lastPushStateActiveThread = this.discuss.thread;
+ };
+ this._showRainbowManEventListener = ev => {
+ ev.stopPropagation();
+ this._showRainbowMan();
+ };
+ this._updateControlPanelEventListener = ev => {
+ ev.stopPropagation();
+ this._updateControlPanel();
+ };
+
+ this.el.addEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.addEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.addEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ return this.component.mount(this.el);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_detach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ this.component.destroy();
+ }
+ this.component = undefined;
+ this.el.removeEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.removeEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.removeEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _pushStateActionManager() {
+ this.actionManager.do_push_state({
+ action: this.action.id,
+ active_id: this.discuss.activeId,
+ });
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _shouldHaveInviteButton() {
+ return (
+ this.discuss.thread &&
+ this.discuss.thread.channel_type === 'channel'
+ );
+ },
+ /**
+ * @private
+ */
+ _showRainbowMan() {
+ this.trigger_up('show_effect', {
+ message: this.env._t("Congratulations, your inbox is empty!"),
+ type: 'rainbow_man',
+ });
+ },
+ /**
+ * @private
+ */
+ _updateControlPanel() {
+ // Invite
+ if (this._shouldHaveInviteButton()) {
+ this.$buttons.find('.o_invite').removeClass('o_hidden');
+ } else {
+ this.$buttons.find('.o_invite').addClass('o_hidden');
+ }
+ // Mark All Read
+ if (
+ this.discuss.threadView &&
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox
+ ) {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadView.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .addClass('o_hidden');
+ }
+ // Unstar All
+ if (
+ this.discuss.threadView &&
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.starred
+ ) {
+ this.$buttons
+ .find('.o_unstar_all')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadView.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_unstar_all')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add channel
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'channel'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add message
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'chat'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .addClass('o_hidden');
+ }
+ // Select All & Unselect All
+ const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll');
+ const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll');
+
+ if (
+ this.discuss.threadView &&
+ (
+ this.discuss.threadView.checkedMessages.length > 0 ||
+ this.discuss.threadView.uncheckedMessages.length > 0
+ )
+ ) {
+ $selectAll.removeClass('o_hidden');
+ $selectAll.toggleClass('disabled', this.discuss.threadView.uncheckedMessages.length === 0);
+ $unselectAll.removeClass('o_hidden');
+ $unselectAll.toggleClass('disabled', this.discuss.threadView.checkedMessages.length === 0);
+ } else {
+ $selectAll.addClass('o_hidden');
+ $selectAll.addClass('disabled');
+ $unselectAll.addClass('o_hidden');
+ $unselectAll.addClass('disabled');
+ }
+
+ // Moderation Actions
+ const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration');
+ if (
+ this.discuss.threadView &&
+ this.discuss.threadView.checkedMessages.length > 0 &&
+ this.discuss.threadView.checkedMessages.filter(
+ message => !message.isModeratedByCurrentPartner
+ ).length === 0
+ ) {
+ $moderationButtons.removeClass('o_hidden');
+ } else {
+ $moderationButtons.addClass('o_hidden');
+ }
+
+ let title;
+ if (this.env.messaging.device.isMobile || !this.discuss.thread) {
+ title = this.env._t("Discuss");
+ } else {
+ const prefix =
+ this.discuss.thread.channel_type === 'channel' &&
+ this.discuss.thread.public !== 'private'
+ ? '#'
+ : '';
+ title = `${prefix}${this.discuss.thread.displayName}`;
+ }
+
+ this.updateControlPanel({
+ cp_content: {
+ $buttons: this.$buttons,
+ },
+ title,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickInvite() {
+ new InvitePartnerDialog(this, {
+ activeThreadLocalId: this.discuss.thread.localId,
+ messagingEnv: this.env,
+ }).open();
+ },
+ /**
+ * @private
+ */
+ _onClickMarkAllAsRead() {
+ this.env.models['mail.message'].markAllAsRead(this.domain);
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewChannel() {
+ this.discuss.update({ isAddingChannel: true });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewMessage() {
+ this.discuss.update({ isAddingChat: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationAccept() {
+ this.env.models['mail.message'].moderate(
+ this.discuss.threadView.checkedMessages,
+ 'accept'
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickSelectAll() {
+ this.env.models['mail.message'].checkAll(
+ this.discuss.thread,
+ this.discuss.stringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnselectAll() {
+ this.env.models['mail.message'].uncheckAll(
+ this.discuss.thread,
+ this.discuss.stringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnstarAll() {
+ this.env.models['mail.message'].unstarAll();
+ },
+ /**
+ * @private
+ * @param {Object} searchQuery
+ */
+ _onSearch: function (searchQuery) {
+ this.discuss.update({
+ stringifiedDomain: JSON.stringify(searchQuery.domain),
+ });
+ },
+});
+
+action_registry.add('mail.widgets.discuss', DiscussWidget);
+
+return DiscussWidget;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss
new file mode 100644
index 00000000..2bed9b7c
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.scss
@@ -0,0 +1,36 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+ display: flex;
+ flex: 0 0 100%;
+ flex-flow: column;
+ height: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+
+ .o_control_panel {
+ border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content
+ }
+
+ .o_Discuss_content {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ .o_Discuss.o-mobile {
+
+ &:not(.o-adding-item) {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ &.o-adding-item .o_Discuss_mobileAddItemHeader {
+ border-bottom: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+ }
+}
diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml
new file mode 100644
index 00000000..f8e6de37
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ @param {mail/static/src/widgets/discuss/discuss.js} widget
+ -->
+ <t t-name="mail.widgets.Discuss">
+ <div class="o_widget_Discuss"/>
+ </t>
+
+ <!-- @param {boolean} isMobile -->
+ <t t-name="mail.widgets.Discuss.DiscussControlButtons">
+ <div>
+ <button class="o_widget_Discuss_controlPanelButtonInvite o_invite o_hidden btn btn-primary" type="button" title="Invite people">Invite</button>
+ <button class="o_widget_Discuss_controlPanelButtonMarkAllRead o_hidden btn btn-secondary" type="button" title="Mark all as read">Mark all read</button>
+ <button class="o_widget_Discuss_controlPanelButtonUnstarAll o_unstar_all o_hidden btn btn-secondary" type="button" title="Unstar all messages">Unstar all</button>
+ <button class="o_widget_Discuss_controlPanelButtonMobileNewMessage o_mobile_new_message o_hidden btn btn-secondary" type="button" title="New Message">New Message</button>
+ <button class="o_widget_Discuss_controlPanelButtonMobileNewChannel o_mobile_new_channel o_hidden btn btn-secondary" title="New Channel" type="button">New Channel</button>
+ <button class="o_widget_Discuss_controlPanelButtonSelectAll btn btn-secondary o_hidden" title="Select all messages">Select All</button>
+ <button class="o_widget_Discuss_controlPanelButtonUnselectAll btn btn-secondary o_hidden" title="Unselect all messages">Unselect All</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-accept" title="Accept selected messages">Accept</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-reject" title="Reject selected messages">Reject</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-discard" title="Discard selected messages">Discard</button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
new file mode 100644
index 00000000..6d9a051b
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
@@ -0,0 +1,124 @@
+odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+
+const _lt = core._lt;
+const QWeb = core.qweb;
+
+/**
+ * Widget : Invite People to Channel Dialog
+ *
+ * Popup containing a 'many2many_tags' custom input to select multiple partners.
+ * Searches user according to the input, and triggers event when selection is
+ * validated.
+ */
+const PartnerInviteDialog = Dialog.extend({
+ dialog_title: _lt("Invite people"),
+ template: 'mail.widgets.DiscussInvitePartnerDialog',
+ /**
+ * @override {web.Dialog}
+ * @param {mail/static/src/widgets/discuss/discuss.js} parent
+ * @param {Object} param1
+ * @param {string} param1.activeThreadLocalId
+ * @param {Object} param1.messagingEnv
+ * @param {Object} param1.messagingEnv.store
+ */
+ init(parent, { activeThreadLocalId, messagingEnv }) {
+ const env = messagingEnv;
+ const channel = env.models['mail.thread'].get(activeThreadLocalId);
+ this.channelId = channel.id;
+ this.env = env;
+ this._super(parent, {
+ title: _.str.sprintf(this.env._t("Invite people to #%s"), owl.utils.escape(channel.displayName)),
+ size: 'medium',
+ buttons: [{
+ text: this.env._t("Invite"),
+ close: true,
+ classes: 'btn-primary',
+ click: ev => this._invite(ev),
+ }],
+ });
+ },
+ /**
+ * @override {web.Dialog}
+ * @returns {Promise}
+ */
+ start() {
+ this.$input = this.$('.o_input');
+ this.$input.select2({
+ width: '100%',
+ allowClear: true,
+ multiple: true,
+ formatResult: item => {
+ let status;
+ // TODO FIXME fix this, why do we even have an old widget here
+ if (item.id === 'odoobot') {
+ status = 'bot';
+ } else {
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({
+ id: item.id,
+ });
+ status = partner.im_status;
+ }
+ const $status = QWeb.render('mail.widgets.UserStatus', { status });
+ return $('<span>').text(item.text).prepend($status);
+ },
+ query: query => {
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ let results = partners.map(partner => {
+ return {
+ id: partner.id,
+ label: partner.nameOrDisplayName,
+ text: partner.nameOrDisplayName,
+ value: partner.nameOrDisplayName,
+ };
+ });
+ results = _.sortBy(results, 'label');
+ query.callback({ results });
+ },
+ keyword: query.term,
+ limit: 20,
+ });
+ }
+ });
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _invite() {
+ const data = this.$input.select2('data');
+ if (data.length === 0) {
+ return;
+ }
+ await this._rpc({
+ model: 'mail.channel',
+ method: 'channel_invite',
+ args: [this.channelId],
+ kwargs: {
+ partner_ids: _.pluck(data, 'id')
+ },
+ });
+ const names = _.escape(_.pluck(data, 'text').join(', '));
+ const notification = _.str.sprintf(
+ this.env._t("You added <b>%s</b> to the conversation."),
+ names
+ );
+ this.env.services['notification'].notify({
+ message: notification,
+ type: 'warning',
+ });
+ },
+});
+
+return PartnerInviteDialog;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
new file mode 100644
index 00000000..82553476
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ @param {mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js} widget
+ -->
+ <t t-name="mail.widgets.DiscussInvitePartnerDialog">
+ <div>
+ <input class="o_dialog o_input o_invite_partner" type="text"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
new file mode 100644
index 00000000..cf147656
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
@@ -0,0 +1,188 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) {
+"use strict";
+
+const components = {
+ ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'),
+};
+
+const FormRenderer = require('web.FormRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+
+class ChatterContainerWrapperComponent extends ComponentWrapper {}
+
+/**
+ * Include the FormRenderer to instantiate the chatter area containing (a
+ * subset of) the mail widgets (mail_thread, mail_followers and mail_activity).
+ */
+FormRenderer.include({
+ /**
+ * @override
+ */
+ init(parent, state, params) {
+ this._super(...arguments);
+ this.chatterFields = params.chatterFields;
+ this.mailFields = params.mailFields;
+ this._chatterContainerComponent = undefined;
+ /**
+ * The target of chatter, if chatter has to be appended to the DOM.
+ * This is set when arch contains `div.oe_chatter`.
+ */
+ this._chatterContainerTarget = undefined;
+ // Do not load chatter in form view dialogs
+ this._isFromFormViewDialog = params.isFromFormViewDialog;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this._chatterContainerComponent = undefined;
+ this.off('o_attachments_changed', this);
+ this.off('o_chatter_rendered', this);
+ this.off('o_message_posted', this);
+ owl.Component.env.bus.off('mail.thread:promptAddFollower-closed', this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the form renderer has a chatter to display or not.
+ * This is based on arch, which should have `div.oe_chatter`.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasChatter() {
+ return !!this._chatterContainerTarget;
+ },
+ /**
+ * @private
+ */
+ _makeChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ this._chatterContainerComponent = new ChatterContainerWrapperComponent(
+ this,
+ components.ChatterContainer,
+ props
+ );
+ // Not in custom_events because other modules may remove this listener
+ // while attempting to extend them.
+ this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev));
+ if (this.chatterFields.hasRecordReloadOnMessagePosted) {
+ this.on('o_message_posted', this, ev => {
+ this.trigger_up('reload', { keepChanges: true });
+ });
+ }
+ if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
+ this.on('o_attachments_changed', this, ev => this.trigger_up('reload', { keepChanges: true }));
+ }
+ if (this.chatterFields.hasRecordReloadOnFollowersUpdate) {
+ owl.Component.env.bus.on('mail.thread:promptAddFollower-closed', this, ev => this.trigger_up('reload', { keepChanges: true }));
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _makeChatterContainerProps() {
+ return {
+ hasActivities: this.chatterFields.hasActivityIds,
+ hasFollowers: this.chatterFields.hasMessageFollowerIds,
+ hasMessageList: this.chatterFields.hasMessageIds,
+ isAttachmentBoxVisibleInitially: this.chatterFields.isAttachmentBoxVisibleInitially,
+ threadId: this.state.res_id,
+ threadModel: this.state.model,
+ };
+ },
+ /**
+ * Create the DOM element that will contain the chatter. This is made in
+ * a separate method so it can be overridden (like in mail_enterprise for
+ * example).
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _makeChatterContainerTarget() {
+ const $el = $('<div class="o_FormRenderer_chatterContainer"/>');
+ this._chatterContainerTarget = $el[0];
+ return $el;
+ },
+ /**
+ * Mount the chatter
+ *
+ * Force re-mounting chatter component in DOM. This is necessary
+ * because each time `_renderView` is called, it puts old content
+ * in a fragment.
+ *
+ * @private
+ */
+ async _mountChatterContainerComponent() {
+ try {
+ await this._chatterContainerComponent.mount(this._chatterContainerTarget);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ _renderNode(node) {
+ if (node.tag === 'div' && node.attrs.class === 'oe_chatter') {
+ if (this._isFromFormViewDialog) {
+ return $('<div/>');
+ }
+ return this._makeChatterContainerTarget();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Overrides the function to render the chatter once the form view is
+ * rendered.
+ *
+ * @override
+ */
+ async __renderView() {
+ await this._super(...arguments);
+ if (this._hasChatter()) {
+ if (!this._chatterContainerComponent) {
+ this._makeChatterContainerComponent();
+ } else {
+ await this._updateChatterContainerComponent();
+ }
+ await this._mountChatterContainerComponent();
+ }
+ },
+ /**
+ * @private
+ */
+ async _updateChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ try {
+ await this._chatterContainerComponent.update(props);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {mail.attachment[]} ev.data.attachments
+ * @param {mail.thread} ev.data.thread
+ */
+ _onChatterRendered(ev) {},
+});
+
+});
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
new file mode 100644
index 00000000..3092055b
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FormRenderer_chatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+ margin: 0 auto;
+ max-width: $o-form-view-sheet-max-width;
+ padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5);
+ width: 100%;
+}
+
+// FIX to hide chatter in dialogs when they are opened from an action returned by python code
+.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer {
+ display: none;
+}
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
new file mode 100644
index 00000000..90cdb169
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
@@ -0,0 +1,982 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) {
+"use strict";
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const config = require('web.config');
+const FormView = require('web.FormView');
+const {
+ dom: { triggerEvent },
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('form_renderer', {}, function () {
+QUnit.module('form_renderer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ // FIXME archs could be removed once task-2248306 is done
+ // The mockServer will try to get the list view
+ // of every relational fields present in the main view.
+ // In the case of mail fields, we don't really need them,
+ // but they still need to be defined.
+ this.createView = async (viewParams, ...args) => {
+ await afterNextRender(async () => {
+ const viewArgs = Object.assign(
+ {
+ archs: {
+ 'mail.activity,false,list': '<tree/>',
+ 'mail.followers,false,list': '<tree/>',
+ 'mail.message,false,list': '<tree/>',
+ },
+ },
+ viewParams,
+ );
+ const { afterEvent, env, widget } = await start(viewArgs, ...args);
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ });
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('[technical] spinner when messaging is 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.
+ */
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ messagingBeforeCreationDeferred: makeDeferred(), // block messaging creation
+ waitUntilMessagingCondition: 'none',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer',
+ "should display chatter container even when messaging is not created yet"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not created"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet created"
+ );
+});
+
+QUnit.test('[technical] keep spinner on transition from messaging non-created to messaging created (and non-initialized)', 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(4);
+
+ const messagingBeforeCreationDeferred = makeDeferred();
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ messagingBeforeCreationDeferred,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await new Promise(() => {}); // simulate messaging never initialized
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'none',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet created"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not created"
+ );
+
+ // simulate messaging become created
+ messagingBeforeCreationDeferred.resolve();
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should still display spinner when messaging is created but not initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should still not display any chatter when messaging not initialized"
+ );
+});
+
+QUnit.test('spinner when messaging is created but not initialized', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await new Promise(() => {}); // simulate messaging never initialized
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'created',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer',
+ "should display chatter container even when messaging is not fully initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not initialized"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet initialized"
+ );
+});
+
+QUnit.test('transition non-initialized messaging to initialized messaging: display spinner then chatter', async function (assert) {
+ assert.expect(3);
+
+ const messagingBeforeInitializationDeferred = makeDeferred();
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingBeforeInitializationDeferred;
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'created',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not initialized"
+ );
+
+ // Simulate messaging becomes initialized
+ await afterNextRender(() => messagingBeforeInitializationDeferred.resolve());
+ assert.containsNone(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should now display chatter when messaging becomes initialized"
+ );
+});
+
+QUnit.test('basic chatter rendering', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12, });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "there should be a chatter"
+ );
+});
+
+QUnit.test('basic chatter rendering without followers', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="activity_ids"/>
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be no followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without activities', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids"/>
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without messages', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids"/>
+ <field name="activity_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('chatter updating', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({ body: "not empty", model: 'res.partner', res_id: 12 });
+ this.data['res.partner'].records.push(
+ { display_name: "first partner", id: 11 },
+ { display_name: "second partner", id: 12 }
+ );
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 11,
+ viewOptions: {
+ ids: [11, 12],
+ index: 0,
+ },
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-thread-view-hint-processed',
+ message: "should wait until partner 11 thread loaded messages initially",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'res.partner' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ }
+ });
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector('.o_pager_next').click(),
+ message: "should wait until partner 12 thread loaded messages after clicking on next",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'res.partner' &&
+ threadViewer.thread.id === 12
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message in partner 12 thread"
+ );
+});
+
+QUnit.test('chatter should become enabled when creation done', async function (assert) {
+ assert.expect(10);
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonSendMessage',
+ "there should be a send message button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be a log note button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be an attachments button"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should be disabled"
+ );
+
+ document.querySelectorAll('.o_field_char')[0].focus();
+ document.execCommand('insertText', false, "hello");
+ await afterNextRender(() => {
+ document.querySelector('.o_form_button_save').click();
+ });
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should now be enabled"
+ );
+});
+
+QUnit.test('read more/less links are not duplicated when switching from read to edit mode', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.message'].records.push({
+ author_id: 100,
+ // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks
+ body: `
+ <div>
+ Dear Joel Willis,<br>
+ Thank you for your enquiry.<br>
+ If you have any questions, please let us know.
+ <br><br>
+ Thank you,<br>
+ <span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
+ System
+ </span>
+ </div>
+ `,
+ id: 1000,
+ model: 'res.partner',
+ res_id: 2,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Someone",
+ id: 100,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 2,
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-component-message-read-more-less-inserted',
+ message: "should wait until read more/less is inserted initially",
+ predicate: ({ message }) => message.id === 1000,
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should be only one read more"
+ );
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_edit').click(),
+ message: "should wait until read more/less is inserted after clicking on edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should still be only one read more after switching to edit mode"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_cancel').click(),
+ message: "should wait until read more/less is inserted after canceling edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should still be only one read more after switching back to read mode"
+ );
+});
+
+QUnit.test('read more links becomes read less after being clicked', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.message'].records = [{
+ author_id: 100,
+ // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks
+ body: `
+ <div>
+ Dear Joel Willis,<br>
+ Thank you for your enquiry.<br>
+ If you have any questions, please let us know.
+ <br><br>
+ Thank you,<br>
+ <span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
+ System
+ </span>
+ </div>
+ `,
+ id: 1000,
+ model: 'res.partner',
+ res_id: 2,
+ }];
+ this.data['res.partner'].records.push({
+ display_name: "Someone",
+ id: 100,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 2,
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-component-message-read-more-less-inserted',
+ message: "should wait until read more/less is inserted initially",
+ predicate: ({ message }) => message.id === 1000,
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should be a read more"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read more',
+ "read more/less link should contain 'read more' as text"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_edit').click(),
+ message: "should wait until read more/less is inserted after clicking on edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read more',
+ "read more/less link should contain 'read more' as text"
+ );
+
+ document.querySelector('.o_Message_readMoreLess').click();
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read less',
+ "read more/less link should contain 'read less' as text after it has been clicked"
+ );
+});
+
+QUnit.test('Form view not scrolled when switching record', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push(
+ {
+ id: 11,
+ display_name: "Partner 1",
+ description: [...Array(60).keys()].join('\n'),
+ },
+ {
+ id: 12,
+ display_name: "Partner 2",
+ }
+ );
+
+ const messages = [...Array(60).keys()].map(id => {
+ return {
+ model: 'res.partner',
+ res_id: id % 2 ? 11 : 12,
+ };
+ });
+ this.data['mail.message'].records = messages;
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ <field name="description"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ viewOptions: {
+ currentId: 11,
+ ids: [11, 12],
+ },
+ config: {
+ device: { size_class: config.device.SIZES.LG },
+ },
+ env: {
+ device: { size_class: config.device.SIZES.LG },
+ },
+ });
+
+ const controllerContentEl = document.querySelector('.o_content');
+
+ assert.strictEqual(
+ document.querySelector('.breadcrumb-item.active').textContent,
+ 'Partner 1',
+ "Form view should display partner 'Partner 1'"
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "The top of the form view is visible"
+ );
+
+ await afterNextRender(async () => {
+ controllerContentEl.scrollTop = controllerContentEl.scrollHeight - controllerContentEl.clientHeight;
+ await triggerEvent(
+ document.querySelector('.o_ThreadView_messageList'),
+ 'scroll'
+ );
+ });
+ assert.strictEqual(
+ controllerContentEl.scrollTop,
+ controllerContentEl.scrollHeight - controllerContentEl.clientHeight,
+ "The controller container should be scrolled to its bottom"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_next').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.breadcrumb-item.active').textContent,
+ 'Partner 2',
+ "The form view should display partner 'Partner 2'"
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "The top of the form view should be visible when switching record from pager"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_previous').click()
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "Form view's scroll position should have been reset when switching back to first record"
+ );
+});
+
+QUnit.test('Attachments that have been unlinked from server should be visually unlinked from record', async function (assert) {
+ // Attachments that have been fetched from a record at certain time and then
+ // removed from the server should be reflected on the UI when the current
+ // partner accesses this record again.
+ assert.expect(2);
+
+ this.data['res.partner'].records.push(
+ { display_name: "Partner1", id: 11 },
+ { display_name: "Partner2", id: 12 }
+ );
+ this.data['ir.attachment'].records.push(
+ {
+ id: 11,
+ mimetype: 'text.txt',
+ res_id: 11,
+ res_model: 'res.partner',
+ },
+ {
+ id: 12,
+ mimetype: 'text.txt',
+ res_id: 11,
+ res_model: 'res.partner',
+ }
+ );
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 11,
+ viewOptions: {
+ ids: [11, 12],
+ index: 0,
+ },
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
+ '2',
+ "Partner1 should have 2 attachments initially"
+ );
+
+ // The attachment links are updated on (re)load,
+ // so using pager is a way to reload the record "Partner1".
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_next').click()
+ );
+ // Simulate unlinking attachment 12 from Partner 1.
+ this.data['ir.attachment'].records.find(a => a.id === 11).res_id = 0;
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_previous').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
+ '1',
+ "Partner1 should now have 1 attachment after it has been unlinked from server"
+ );
+});
+
+QUnit.test('chatter just contains "creating a new record" message during the creation of a new record after having displayed a chatter for an existing record', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ View: FormView,
+ model: 'res.partner',
+ res_id: 12,
+ arch: `
+ <form>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ });
+
+ await afterNextRender(() => {
+ document.querySelector('.o_form_button_create').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "Should have a single message when creating a new record"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_content').textContent,
+ 'Creating a new record...',
+ "the message content should be in accord to the creation of this record"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000..edfef630
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
@@ -0,0 +1,56 @@
+odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'),
+};
+
+const SystrayMenu = require('web.SystrayMenu');
+const Widget = require('web.Widget');
+
+/**
+ * Odoo Widget, necessary to instantiate component.
+ */
+const MessagingMenu = Widget.extend({
+ template: 'mail.widgets.MessagingMenu',
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ this._super(...arguments);
+ },
+ async on_attach_callback() {
+ const MessagingMenuComponent = components.MessagingMenu;
+ this.component = new MessagingMenuComponent(null);
+ await this.component.mount(this.el);
+ // unwrap
+ this.el.parentNode.insertBefore(this.component.el, this.el);
+ this.el.parentNode.removeChild(this.el);
+ },
+});
+
+// Systray menu items display order matches order in the list
+// lower index comes first, and display is from right to left.
+// For messagin menu, it should come before activity menu, if any
+// otherwise, it is the next systray item.
+const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem =>
+ SystrayMenuItem.prototype.name === 'activity_menu');
+if (activityMenuIndex > 0) {
+ SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
+} else {
+ SystrayMenu.Items.push(MessagingMenu);
+}
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000..308c1f31
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.widgets.MessagingMenu">
+ <li class="o_widget_SystrayMessagingItem"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert.js b/addons/mail/static/src/widgets/notification_alert/notification_alert.js
new file mode 100644
index 00000000..27055cd7
--- /dev/null
+++ b/addons/mail/static/src/widgets/notification_alert/notification_alert.js
@@ -0,0 +1,45 @@
+odoo.define('mail/static/src/widgets/notification_alert/notification_alert.js', function (require) {
+"use strict";
+
+const components = {
+ NotificationAlert: require('mail/static/src/components/notification_alert/notification_alert.js'),
+};
+
+const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+const Widget = require('web.Widget');
+const widgetRegistry = require('web.widget_registry');
+
+class NotificationAlertWrapper extends ComponentWrapper {}
+
+// -----------------------------------------------------------------------------
+// Display Notification alert on user preferences form view
+// -----------------------------------------------------------------------------
+const NotificationAlert = Widget.extend(WidgetAdapterMixin, {
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ this.component = new NotificationAlertWrapper(
+ this,
+ components.NotificationAlert,
+ {}
+ );
+ await this.component.mount(this.el);
+ },
+});
+
+widgetRegistry.add('notification_alert', NotificationAlert);
+
+return NotificationAlert;
+
+});
diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js
new file mode 100644
index 00000000..20297c85
--- /dev/null
+++ b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js
@@ -0,0 +1,103 @@
+odoo.define('mail/static/src/widgets/notification_alert/notification_alert_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+const FormView = require('web.FormView');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('notification_alert', {}, function () {
+QUnit.module('notification_alert_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ let { widget } = await start(Object.assign({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'mail.message',
+ arch: `
+ <form>
+ <widget name="notification_alert"/>
+ </form>
+ `,
+ }, params));
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.skip('notification_alert widget: display blocked notification alert', async function (assert) {
+ // FIXME: Test should work, but for some reasons OWL always flags the
+ // component as not mounted, even though it is in the DOM and it's state
+ // is good for rendering... task-227947
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'denied',
+ },
+ },
+ },
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should be displayed"
+ );
+});
+
+QUnit.test('notification_alert widget: no notification alert when granted', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'granted',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should not be displayed"
+ );
+});
+
+QUnit.test('notification_alert widget: no notification alert when default', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'default',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should not be displayed"
+ );
+});
+
+});
+});
+});
+
+});