diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 5e0872b694c..ba9966d591b 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -209,6 +209,62 @@ class DiscussController(http.Controller): raise NotFound() return attachment_sudo.unlink() + @http.route('/mail/message/add_reaction', methods=['POST'], type='json', auth='public') + def mail_message_add_reaction(self, message_id, content): + guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo() + message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists() + if not message_sudo: + raise NotFound() + if request.env.user.sudo()._is_public(): + if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids: + raise NotFound() + message_sudo._message_add_reaction(content=content) + guests = [('insert', {'id': guest_sudo.id})] + partners = [] + else: + message_sudo.sudo(False)._message_add_reaction(content=content) + guests = [] + partners = [('insert', {'id': request.env.user.partner_id.id})] + reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)]) + return { + 'id': message_sudo.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + } + + @http.route('/mail/message/remove_reaction', methods=['POST'], type='json', auth='public') + def mail_message_remove_reaction(self, message_id, content): + guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo() + message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists() + if not message_sudo: + raise NotFound() + if request.env.user.sudo()._is_public(): + if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids: + raise NotFound() + message_sudo._message_remove_reaction(content=content) + guests = [('insert-and-unlink', {'id': guest_sudo.id})] + partners = [] + else: + message_sudo.sudo(False)._message_remove_reaction(content=content) + guests = [] + partners = [('insert-and-unlink', {'id': request.env.user.partner_id.id})] + reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)]) + return { + 'id': message_sudo.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + } + # -------------------------------------------------------------------------- # Channel API # -------------------------------------------------------------------------- diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 858ae8d14f3..d3e66d511d5 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -719,6 +719,55 @@ class Channel(models.Model): 'body': message.body, }, }) + return super()._message_update_content_after_hook(message=message) + + def _message_add_reaction_after_hook(self, message, content): + self.ensure_one() + if self.env.user._is_public() and 'guest' in self.env.context: + guests = [('insert', {'id': self.env.context.get('guest').id})] + partners = [] + else: + guests = [] + partners = [('insert', {'id': self.env.user.partner_id.id})] + reactions = self.env['mail.message.reaction'].sudo().search([('message_id', '=', message.id), ('content', '=', content)]) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.message_update', + 'payload': { + 'id': message.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + }, + }) + return super()._message_add_reaction_after_hook(message=message, content=content) + + def _message_remove_reaction_after_hook(self, message, content): + self.ensure_one() + if self.env.user._is_public() and 'guest' in self.env.context: + guests = [('insert-and-unlink', {'id': self.env.context.get('guest').id})] + partners = [] + else: + guests = [] + partners = [('insert-and-unlink', {'id': self.env.user.partner_id.id})] + reactions = self.env['mail.message.reaction'].sudo().search([('message_id', '=', message.id), ('content', '=', content)]) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.message_update', + 'payload': { + 'id': message.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + }, + }) + return super()._message_remove_reaction_after_hook(message=message, content=content) def _message_subscribe(self, partner_ids=None, subtype_ids=None, customer_ids=None): """ Do not allow follower subscription on channels. Only members are diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 802af43d73f..0556b4061c7 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -176,6 +176,7 @@ class Message(models.Model): # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing. mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system") canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False) + reaction_ids = fields.One2many('mail.message.reaction', 'message_id', string="Reactions", groups="base.group_system") def _compute_description(self): for message in self: @@ -758,6 +759,40 @@ class Message(models.Model): notification = {'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) + def _message_add_reaction(self, content): + self.ensure_one() + self.check_access_rule('write') + self.check_access_rights('write') + if self.env.user._is_public() and 'guest' in self.env.context: + guest = self.env.context.get('guest') + partner = self.env['res.partner'] + else: + guest = self.env['mail.guest'] + partner = self.env.user.partner_id + reaction = self.env['mail.message.reaction'].sudo().search([('message_id', '=', self.id), ('partner_id', '=', partner.id), ('guest_id', '=', guest.id), ('content', '=', content)]) + if not reaction: + reaction = self.env['mail.message.reaction'].sudo().create({ + 'message_id': self.id, + 'content': content, + 'partner_id': partner.id, + 'guest_id': guest.id, + }) + self.env[self.model].browse(self.res_id)._message_add_reaction_after_hook(message=self, content=reaction.content) + + def _message_remove_reaction(self, content): + self.ensure_one() + self.check_access_rule('write') + self.check_access_rights('write') + if self.env.user._is_public() and 'guest' in self.env.context: + guest = self.env.context.get('guest') + partner = self.env['res.partner'] + else: + guest = self.env['mail.guest'] + partner = self.env.user.partner_id + reaction = self.env['mail.message.reaction'].sudo().search([('message_id', '=', self.id), ('partner_id', '=', partner.id), ('guest_id', '=', guest.id), ('content', '=', content)]) + reaction.unlink() + self.env[self.model].browse(self.res_id)._message_remove_reaction_after_hook(message=self, content=content) + # ------------------------------------------------------ # MESSAGE READ / FETCH / FAILURE API # ------------------------------------------------------ @@ -818,10 +853,21 @@ class Message(models.Model): })] else: vals['author_id'] = author + reactions_per_content = defaultdict(lambda: self.env['mail.message.reaction']) + for reaction in message_sudo.reaction_ids: + reactions_per_content[reaction.content] |= reaction + reaction_groups = [('insert-and-replace', [{ + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'partners': [('insert-and-replace', [{'id': partner.id, 'name': partner.name} for partner in reactions.partner_id])], + 'guests': [('insert-and-replace', [{'id': guest.id, 'name': guest.name} for guest in reactions.guest_id])], + } for content, reactions in reactions_per_content.items()])] vals.update({ 'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(), 'attachment_ids': attachments_formatted, 'tracking_value_ids': tracking_value_ids, + 'messageReactionGroups': reaction_groups, 'record_name': record_name, }) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index c8ae28c4ad4..456b37efba1 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1886,6 +1886,12 @@ class MailThread(models.AbstractModel): def _message_update_content_after_hook(self, message): """ Hook to add custom behavior after having updated the message content. """ + def _message_add_reaction_after_hook(self, message, content): + """ Hook to add custom behavior after having added a reaction to a message. """ + + def _message_remove_reaction_after_hook(self, message, content): + """ Hook to add custom behavior after having removed a reaction from a message. """ + def _check_can_update_message_content(self, message): """" Checks that the current user can update the content of the message. """ note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 2195199d9ac..49333a5a209 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -281,8 +281,7 @@ export class Composer extends Component { } /** - * Called when selection an emoji from the emoji popover (from the emoji - * button). + * Handles `o-emoji-selection` event from the emoji popover. * * @private * @param {CustomEvent} ev diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 163a5c56cf9..592c9be5782 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -1174,7 +1174,7 @@ QUnit.test('open channel from active_id as channel id', async function (assert) QUnit.test('basic rendering of message', async function (assert) { // AKU TODO: should be in message-only tests - assert.expect(13); + assert.expect(14); // channel expected to be rendered, with a random unique id that will be referenced in the test this.data['mail.channel'].records.push({ id: 20 }); @@ -1253,14 +1253,19 @@ QUnit.test('basic rendering of message', async function (assert) { ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_action`).length, - 1, - "should have a single action in action list of message" + 2, + "should have 2 actions in action list of message" ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, 1, "should have action to star message" ); + assert.containsOnce( + document.body, + '.o_MessageActionList_actionReaction', + "should have action to add a reaction" + ); assert.strictEqual( message.querySelectorAll(`:scope .o_Message_content`).length, 1, @@ -2876,7 +2881,7 @@ QUnit.test('do not post message on channel with "SHIFT-Enter" keyboard shortcut' }); QUnit.test('rendering of inbox message', async function (assert) { // AKU TODO: kinda message specific test - assert.expect(7); + assert.expect(8); this.data['mail.message'].records.push({ body: "not empty", @@ -2925,8 +2930,13 @@ QUnit.test('rendering of inbox message', async function (assert) { await afterNextRender(() => message.click()); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_action`).length, - 3, - "should display 3 actions" + 4, + "should display 4 actions" + ); + assert.containsOnce( + document.body, + '.o_MessageActionList_actionReaction', + "should have action to add a reaction" ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 4a184469bf9..655e067866c 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -132,7 +132,11 @@ export class Message extends Component { * @returns {boolean} */ get isActive() { - return this.state.isHovered || this.state.isClicked; + return Boolean( + this.state.isHovered || + this.state.isClicked || + (this.message && this.message.actionList.isReactionPopoverOpened) + ); } /** @@ -470,7 +474,8 @@ export class Message extends Component { !isEventHandled(ev, 'Message.ClickAuthorAvatar') && !isEventHandled(ev, 'Message.ClickAuthorName') && !isEventHandled(ev, 'Message.ClickFailure') && - !isEventHandled(ev, 'MessageActionList.Click') + !isEventHandled(ev, 'MessageActionList.Click') && + !isEventHandled(ev, 'MessageReactionGroup.Click') ) { this.state.isClicked = !this.state.isClicked; } diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml index a0b3af38aed..ee1f09eed7f 100644 --- a/addons/mail/static/src/components/message/message.xml +++ b/addons/mail/static/src/components/message/message.xml @@ -139,6 +139,11 @@ +
+ + + +

Subject:

diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.js b/addons/mail/static/src/components/message_action_list/message_action_list.js index 7679c6b15f8..f2372b0d110 100644 --- a/addons/mail/static/src/components/message_action_list/message_action_list.js +++ b/addons/mail/static/src/components/message_action_list/message_action_list.js @@ -1,10 +1,18 @@ /** @odoo-module */ + import { registerMessagingComponent } from '@mail/utils/messaging_component'; +import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to_model'; const { Component } = owl; export class MessageActionList extends Component { + setup() { + super.setup(); + useRefToModel({ fieldName: 'reactionPopoverRef', modelName: 'mail.message_action_list', propNameAsRecordLocalId: 'actionListLocalId', refName: 'reactionPopover' }); + this.ADD_A_REACTION = this.env._t("Add a Reaction"); + } + /** * @returns {mail.message} */ diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.scss b/addons/mail/static/src/components/message_action_list/message_action_list.scss index 002fe0aa4e9..e6986491667 100644 --- a/addons/mail/static/src/components/message_action_list/message_action_list.scss +++ b/addons/mail/static/src/components/message_action_list/message_action_list.scss @@ -20,3 +20,7 @@ .o_MessageActionList_actionStar.o_MessageActionList_actionStar_active { color: gold; } + +.o_MessageActionList_actionReaction { + display: inline; // override block from popover div +} diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.xml b/addons/mail/static/src/components/message_action_list/message_action_list.xml index a18fc877ed0..6180a98bb1e 100644 --- a/addons/mail/static/src/components/message_action_list/message_action_list.xml +++ b/addons/mail/static/src/components/message_action_list/message_action_list.xml @@ -1,7 +1,12 @@ -
+
+ + + + + + + + +
+ + + + + + + + +
+
+ +
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js index ef1793aa204..5a7f204ad33 100644 --- a/addons/mail/static/src/models/message/message.js +++ b/addons/mail/static/src/models/message/message.js @@ -74,6 +74,9 @@ function factory(dependencies) { if ('is_notification' in data) { data2.is_notification = data.is_notification; } + if ('messageReactionGroups' in data) { + data2.messageReactionGroups = data.messageReactionGroups; + } if ('message_type' in data) { data2.message_type = data.message_type; } @@ -200,6 +203,22 @@ function factory(dependencies) { }); } + /** + * Adds the given reaction on this message. + * + * @param {string} content + */ + async addReaction(content) { + const messageData = await this.env.services.rpc({ + route: '/mail/message/add_reaction', + params: { content, message_id: this.id }, + }); + if (!this.exists()) { + return; + } + this.update(messageData); + } + /** * Mark this message as read, so that it no longer appears in current * partner Inbox. @@ -233,6 +252,22 @@ function factory(dependencies) { this.update({ dateFromNow: this._computeDateFromNow() }); } + /** + * Removes the given reaction from this message. + * + * @param {string} content + */ + async removeReaction(content) { + const messageData = await this.env.services.rpc({ + route: '/mail/message/remove_reaction', + params: { content, message_id: this.id }, + }); + if (!this.exists()) { + return; + } + this.update(messageData); + } + /** * Action to initiate reply to current message in Discuss Inbox. Assumes * that Discuss and Inbox are already opened. @@ -639,6 +674,14 @@ function factory(dependencies) { isStarred: attr({ default: false, }), + /** + * Groups of reactions per content allowing to know the number of + * reactions for each. + */ + messageReactionGroups: one2many('mail.message_reaction_group', { + inverse: 'message', + isCausal: true, + }), message_type: attr(), notifications: one2many('mail.notification', { inverse: 'message', diff --git a/addons/mail/static/src/models/message_action_list/message_action_list.js b/addons/mail/static/src/models/message_action_list/message_action_list.js index 37ff7c82297..dea2d07feb7 100644 --- a/addons/mail/static/src/models/message_action_list/message_action_list.js +++ b/addons/mail/static/src/models/message_action_list/message_action_list.js @@ -14,18 +14,17 @@ function factory(dependencies) { _created() { // bind handlers so they can be used in templates this.onClick = this.onClick.bind(this); - this.onClickReplyTo = this.onClickReplyTo.bind(this); this.onClickConfirmDelete = this.onClickConfirmDelete.bind(this); this.onClickDelete = this.onClickDelete.bind(this); this.onClickMarkAsRead = this.onClickMarkAsRead.bind(this); + this.onReactionPopoverOpened = this.onReactionPopoverOpened.bind(this); + this.onReactionPopoverClosed = this.onReactionPopoverClosed.bind(this); + this.onClickReplyTo = this.onClickReplyTo.bind(this); this.onClickToggleStar = this.onClickToggleStar.bind(this); this.onDeleteConfirmDialogClosed = this.onDeleteConfirmDialogClosed.bind(this); + this.onEmojiSelection = this.onEmojiSelection.bind(this); } - //---------------------------------------------------------------------- - // Private - //---------------------------------------------------------------------- - /** * @private * @param {MouseEvent} ev @@ -58,6 +57,22 @@ function factory(dependencies) { this.message.markAsRead(); } + /** + * @private + * @param {Event} ev + */ + onReactionPopoverClosed(ev) { + this.update({ isReactionPopoverOpened: false }); + } + + /** + * @private + * @param {Event} ev + */ + onReactionPopoverOpened(ev) { + this.update({ isReactionPopoverOpened: true }); + } + /** * Opens the reply composer for this message (or closes it if it was * already opened). @@ -83,15 +98,48 @@ function factory(dependencies) { * @param {CustomEvent} ev */ onDeleteConfirmDialogClosed(ev) { - this.update({ showDeleteConfirm: false }) + this.update({ showDeleteConfirm: false }); } + /** + * Handles `o-emoji-selection` event from the emoji popover. + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.unicode + */ + onEmojiSelection(ev) { + this.message.addReaction(ev.detail.unicode); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + _computeIsReactionPopoverOpened() { + return Boolean( + this.reactionPopoverRef && + this.reactionPopoverRef.comp && + this.reactionPopoverRef.comp.state.displayed + ); + } } MessageActionList.fields = { + /** + * States whether the reaction popover is currently opened. + */ + isReactionPopoverOpened: attr({ + compute: '_computeIsReactionPopoverOpened', + }), message: one2one('mail.message', { inverse: 'actionList' }), + /** + * States the reference to the reaction popover component (if any). + */ + reactionPopoverRef: attr(), /** * Whether to show the message delete-confirm dialog */ diff --git a/addons/mail/static/src/models/message_reaction_group/message_reaction_group.js b/addons/mail/static/src/models/message_reaction_group/message_reaction_group.js new file mode 100644 index 00000000000..7e759ed99eb --- /dev/null +++ b/addons/mail/static/src/models/message_reaction_group/message_reaction_group.js @@ -0,0 +1,137 @@ +/** @odoo-module **/ + +import { registerNewModel } from '@mail/model/model_core'; +import { attr, many2many, many2one } from '@mail/model/model_field'; +import { insertAndReplace } from '@mail/model/model_field_command'; +import { markEventHandled } from '@mail/utils/utils'; + +function factory(dependencies) { + + class MessageReactionGroup extends dependencies['mail.model'] { + + /** + * @override + */ + _created() { + super._created(); + this.onClick = this.onClick.bind(this); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Handles click on the reaction group. + * + * @param {MouseEvent} ev + */ + onClick(ev) { + markEventHandled(ev, 'MessageReactionGroup.Click'); + if (this.hasUserReacted) { + this.message.removeReaction(this.content); + } else { + this.message.addReaction(this.content); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.messageId}_${data.content}`; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasUserReacted() { + return Boolean( + (this.messaging.currentPartner && this.partners.includes(this.messaging.currentPartner)) || + (this.messaging.currentGuest && this.guests.includes(this.messaging.currentGuest)) + ); + } + + /** + * @private + * @returns {FieldCommand} + */ + _computeMessage() { + return insertAndReplace({ id: this.messageId }); + } + + /** + * @private + * @returns {string} + */ + _computeSummary() { + const { + length, + 0: firstUser, + 1: secondUser, + 2: thirdUser, + } = [ + ...this.partners.map(partner => partner.name), + ...this.guests.map(guest => guest.name), + ]; + switch (length) { + case 1: + return _.str.sprintf(this.env._t('%s has reacted with %s'), firstUser, this.content); + case 2: + return _.str.sprintf(this.env._t('%s and %s have reacted with %s'), firstUser, secondUser, this.content); + case 3: + return _.str.sprintf(this.env._t('%s, %s, %s have reacted with %s'), firstUser, secondUser, thirdUser, this.content); + case 4: + return _.str.sprintf(this.env._t('%s, %s, %s and 1 other person have reacted with %s'), firstUser, secondUser, thirdUser, this.content); + default: + return _.str.sprintf(this.env._t('%s, %s, %s and %s other persons have reacted with %s'), firstUser, secondUser, thirdUser, length - 3, this.content); + } + } + + } + + MessageReactionGroup.fields = { + content: attr({ + readonly: true, + required: true, + }), + count: attr({ + required: true, + }), + /** + * States the guests that have used this reaction on this message. + */ + guests: many2many('mail.guest'), + hasUserReacted: attr({ + compute: '_computeHasUserReacted', + default: false, + }), + message: many2one('mail.message', { + compute: '_computeMessage', + inverse: 'messageReactionGroups', + required: true, + }), + messageId: attr({ + readonly: true, + required: true, + }), + /** + * States the partners that have used this reaction on this message. + */ + partners: many2many('mail.partner'), + summary: attr({ + compute: '_computeSummary', + }), + }; + + MessageReactionGroup.modelName = 'mail.message_reaction_group'; + + return MessageReactionGroup; +} + +registerNewModel('mail.message_reaction_group', factory); diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index ad8ffd105aa..3e32a0e287f 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -835,7 +835,7 @@ class TestMailComplexPerformance(BaseMailPerformance): messages.flush() messages.invalidate_cache() - with self.assertQueryCount(emp=16): + with self.assertQueryCount(emp=17): res = messages.message_format() self.assertEqual(len(res), 2) for message in res: @@ -863,7 +863,7 @@ class TestMailComplexPerformance(BaseMailPerformance): messages.flush() messages.invalidate_cache() - with self.assertQueryCount(emp=12): + with self.assertQueryCount(emp=13): res = messages.message_format() self.assertEqual(len(res), 6) diff --git a/addons/web/static/src/legacy/js/core/popover.js b/addons/web/static/src/legacy/js/core/popover.js index e5cd2cb4368..3c6a4386364 100644 --- a/addons/web/static/src/legacy/js/core/popover.js +++ b/addons/web/static/src/legacy/js/core/popover.js @@ -81,6 +81,7 @@ odoo.define('web.Popover', function (require) { _close() { this.state.displayed = false; + this.trigger('o-popover-closed'); } /** @@ -171,6 +172,11 @@ odoo.define('web.Popover', function (require) { */ _onClick(ev) { this.state.displayed = !this.state.displayed; + if (this.state.displayed) { + this.trigger('o-popover-opened'); + } else { + this.trigger('o-popover-closed'); + } } /** @@ -322,6 +328,7 @@ odoo.define('web.Popover', function (require) { validate: (p) => ['top', 'bottom', 'left', 'right'].includes(p), }, title: { type: String, optional: true }, + titleAttribute: { type: String, optional: true }, }; QWeb.registerComponent('Popover', Popover); diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index 87f31711859..1566f937931 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -509,7 +509,7 @@ -
+