diff options
Diffstat (limited to 'addons/mail/static/src/widgets')
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" + ); +}); + +}); +}); +}); + +}); |
