[IMP] mail, web: add message reactions with emojis

task-2361194

closes odoo/odoo#75630

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Julien Giannone
2021-09-07 18:08:11 +00:00
committed by Sébastien Theys
parent bc8f4373c3
commit 63ff23c218
20 changed files with 523 additions and 20 deletions
+56
View File
@@ -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
# --------------------------------------------------------------------------
+49
View File
@@ -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
+46
View File
@@ -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,
})
+6
View File
@@ -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')
@@ -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
@@ -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,
@@ -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;
}
@@ -139,6 +139,11 @@
</ul>
</t>
</div>
<div class="d-flex flex-wrap ml-2">
<t t-foreach="message.messageReactionGroups" t-as="messageReactionGroup" t-key="messageReactionGroup.localId">
<MessageReactionGroup messageReactionGroupLocalId="messageReactionGroup.localId"/>
</t>
</div>
<t t-if="message.subject and !message.isSubjectSimilarToOriginThreadName">
<p class="o_Message_subject m-2 mt-0">Subject: <t t-esc="message.subject"/></p>
</t>
@@ -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}
*/
@@ -20,3 +20,7 @@
.o_MessageActionList_actionStar.o_MessageActionList_actionStar_active {
color: gold;
}
.o_MessageActionList_actionReaction {
display: inline; // override block from popover div
}
@@ -1,7 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="mail.MessageActionList" owl="1">
<div class="o_MessageActionList" t-on-click="actionList.onClick">
<div class="o_MessageActionList d-flex" t-on-click="actionList.onClick">
<Popover class="o_MessageActionList_action o_MessageActionList_actionReaction p-2 fa fa-lg fa-smile-o" position="'top'" titleAttribute="ADD_A_REACTION" t-on-click="actionList.onClickReaction" t-on-o-emoji-selection="actionList.onEmojiSelection" t-on-o-popover-closed="actionList.onReactionPopoverClosed" t-on-o-popover-opened="actionList.onReactionPopoverOpened" t-ref="reactionPopover">
<t t-set="opened">
<EmojisPopover/>
</t>
</Popover>
<span t-if="actionList.message.canStarBeToggled" class="o_MessageActionList_action o_MessageActionList_actionStar p-2" t-att-class="{
'o_MessageActionList_actionStar_active': actionList.message.isStarred,
'fa fa-lg fa-star': actionList.message.isStarred,
@@ -0,0 +1,21 @@
/** @odoo-module **/
const { Component } = owl;
import { registerMessagingComponent } from '@mail/utils/messaging_component';
export class MessageReactionGroup extends Component {
get messageReactionGroup() {
return this.messaging.models['mail.message_reaction_group'].get(this.props.messageReactionGroupLocalId);
}
}
Object.assign(MessageReactionGroup, {
props: {
messageReactionGroupLocalId: String,
},
template: 'mail.MessageReactionGroup',
});
registerMessagingComponent(MessageReactionGroup);
@@ -0,0 +1,37 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_MessageReactionGroup {
border-radius: $o-mail-rounded-rectangle-border-radius-sm;
cursor: pointer;
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o_MessageReactionGroup {
background: $white;
border-color: gray('200');
border-style: solid;
border-width: $border-width;
&.o-hasUserReacted, &.o-hasUserReacted:hover {
border-color: $o-brand-primary;
background-color: rgba($o-brand-primary, .1);
}
&:hover {
border-color: gray('400');
background-color: gray('200');
}
}
.o_MessageReactionGroup_count {
&.o-hasUserReacted {
color: $o-brand-primary;
font-weight: bold;
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.MessageReactionGroup" owl="1">
<div class="o_MessageReactionGroup d-flex mr-1 mb-2" t-att-class="{'o-hasUserReacted': messageReactionGroup and messageReactionGroup.hasUserReacted}" t-att-title="messageReactionGroup and messageReactionGroup.summary" t-on-click="messageReactionGroup and messageReactionGroup.onClick">
<t t-if="messageReactionGroup">
<span class="o_MessageReactionGroup_content mx-1">
<t t-esc="messageReactionGroup.content"/>
</span>
<span class="o_MessageReactionGroup_count mx-1" t-att-class="{'o-hasUserReacted': messageReactionGroup.hasUserReacted}">
<t t-esc="messageReactionGroup.count"/>
</span>
</t>
</div>
</t>
</templates>
@@ -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',
@@ -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
*/
@@ -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);
+2 -2
View File
@@ -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)
@@ -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);
+1 -1
View File
@@ -509,7 +509,7 @@
</t>
<t t-name="Popover" owl="1">
<div t-att-class="{ 'o_is_open': state.displayed }" t-on-click="_onClick" t-on-o-popover-compute="_onPopoverCompute" t-on-o-popover-close="_onPopoverClose">
<div t-att-class="{ 'o_is_open': state.displayed }" t-att-title="props.titleAttribute" t-on-click="_onClick" t-on-o-popover-compute="_onPopoverCompute" t-on-o-popover-close="_onPopoverClose">
<t t-slot="default"/>
<Portal t-if="state.displayed" target="'body'">
<div role="tooltip" class="o_popover" t-att-class="props.popoverClass" t-ref="popover">