[IMP] mail, im_livechat: add new UI for inviting members on channels
Opportunity is taken to clean the various follow/join/invite methods into only one `add_members` method. Part of task-2360169 closes odoo/odoo#74425 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
@@ -410,8 +410,7 @@ QUnit.test('livechats with no messages are sorted by creation date in the sideba
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.skip('invite button should be present on livechat', async function (assert) {
|
||||
// Disabled until the new invite form is introduced.
|
||||
QUnit.test('invite button should be present on livechat', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
this.data['mail.channel'].records.push(
|
||||
|
||||
@@ -86,6 +86,15 @@ registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_computeHasInviteFeature() {
|
||||
if (this.channel_type === 'livechat') {
|
||||
return true;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
'mail/static/src/scss/m2x_avatar_user.scss',
|
||||
'mail/static/src/scss/activity_view.scss',
|
||||
'mail/static/src/scss/kanban_view.scss',
|
||||
'mail/static/src/component_hooks/use_component_to_model/use_component_to_model.js',
|
||||
'mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js',
|
||||
'mail/static/src/component_hooks/use_ref_to_model/use_ref_to_model.js',
|
||||
'mail/static/src/component_hooks/use_refs/use_refs.js',
|
||||
@@ -105,6 +106,8 @@
|
||||
'mail/static/src/components/attachment_list/attachment_list.js',
|
||||
'mail/static/src/components/attachment_viewer/attachment_viewer.js',
|
||||
'mail/static/src/components/autocomplete_input/autocomplete_input.js',
|
||||
'mail/static/src/components/channel_invitation_form/channel_invitation_form.js',
|
||||
'mail/static/src/components/channel_invitation_form/channel_invitation_form.scss',
|
||||
'mail/static/src/components/chat_window/chat_window.js',
|
||||
'mail/static/src/components/chat_window_header/chat_window_header.js',
|
||||
'mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js',
|
||||
@@ -165,6 +168,7 @@
|
||||
'mail/static/src/models/attachment_viewer/attachment_viewer.js',
|
||||
'mail/static/src/models/canned_response/canned_response.js',
|
||||
'mail/static/src/models/channel_command/channel_command.js',
|
||||
'mail/static/src/models/channel_invitation_form/channel_invitation_form.js',
|
||||
'mail/static/src/models/chat_window/chat_window.js',
|
||||
'mail/static/src/models/chat_window_manager/chat_window_manager.js',
|
||||
'mail/static/src/models/chatter/chatter.js',
|
||||
@@ -360,6 +364,7 @@
|
||||
'mail/static/src/components/attachment_list/attachment_list.xml',
|
||||
'mail/static/src/components/attachment_viewer/attachment_viewer.xml',
|
||||
'mail/static/src/components/autocomplete_input/autocomplete_input.xml',
|
||||
'mail/static/src/components/channel_invitation_form/channel_invitation_form.xml',
|
||||
'mail/static/src/components/chat_window/chat_window.xml',
|
||||
'mail/static/src/components/chat_window_header/chat_window_header.xml',
|
||||
'mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml',
|
||||
|
||||
@@ -37,7 +37,7 @@ class IrAttachment(models.Model):
|
||||
pass
|
||||
|
||||
def _attachment_format(self):
|
||||
safari = request and request.httprequest.user_agent.browser == 'safari'
|
||||
safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari'
|
||||
return [{
|
||||
'checksum': attachment.checksum,
|
||||
'id': attachment.id,
|
||||
|
||||
@@ -223,13 +223,6 @@ class Channel(models.Model):
|
||||
for channel in self
|
||||
)
|
||||
|
||||
def action_follow(self):
|
||||
self.ensure_one()
|
||||
self.check_access_rights('write')
|
||||
self.check_access_rule('write')
|
||||
self._action_add_members(self.env.user.partner_id)
|
||||
return False
|
||||
|
||||
def action_unfollow(self):
|
||||
return self._action_unfollow(self.env.user.partner_id)
|
||||
|
||||
@@ -243,24 +236,54 @@ class Channel(models.Model):
|
||||
# channel_info is called before actually unpinning the channel
|
||||
channel_info['is_pinned'] = False
|
||||
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner.id), channel_info)
|
||||
notification = _('<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', self.id, self.name)
|
||||
notification = _('<div class="o_mail_notification">left the channel</div>')
|
||||
# post 'channel left' message as root since the partner just unsubscribed from the channel
|
||||
self.sudo().message_post(body=notification, subtype_xmlid="mail.mt_comment", author_id=partner.id)
|
||||
return result
|
||||
|
||||
def _action_add_members(self, partners):
|
||||
""" Private implementation to add members to channels. Done as sudo to
|
||||
avoid ACLs issues with channel partners. """
|
||||
to_create = []
|
||||
def add_members(self, partner_ids):
|
||||
""" Adds the given partner_ids as member of self channels. """
|
||||
self.check_access_rights('write')
|
||||
self.check_access_rule('write')
|
||||
partners = self.env['res.partner'].browse(partner_ids)
|
||||
members_to_create = []
|
||||
for channel in self:
|
||||
channel_new = partners - channel.channel_partner_ids
|
||||
to_create += [
|
||||
{'partner_id': partner.id,
|
||||
'channel_id': channel.id,
|
||||
} for partner in channel_new]
|
||||
if to_create:
|
||||
self.env['mail.channel.partner'].sudo().create(to_create)
|
||||
self.invalidate_cache(fnames=['channel_partner_ids', 'channel_last_seen_partner_ids'])
|
||||
if channel.public == 'groups':
|
||||
invalid_partners = partners.filtered(lambda partner: channel.group_public_id not in partner.user_ids.groups_id)
|
||||
if invalid_partners:
|
||||
raise UserError(_(
|
||||
'Channel "%(channel_name)s" only accepts members of group "%(group_name)s". Forbidden for: %(partner_names)s',
|
||||
channel_name=channel.name,
|
||||
group_name=channel.group_public_id.name,
|
||||
partner_names=', '.join(partner.name for partner in invalid_partners)
|
||||
))
|
||||
existing_partners = self.env['res.partner'].search([('id', 'in', partner_ids), ('channel_ids', 'in', channel.id)])
|
||||
members_to_create += [{
|
||||
'partner_id': partner.id,
|
||||
'channel_id': channel.id,
|
||||
} for partner in partners - existing_partners]
|
||||
new_members = self.env['mail.channel.partner'].sudo().create(members_to_create)
|
||||
for channel_partner in new_members:
|
||||
user = channel_partner.partner_id.user_ids[0] if channel_partner.partner_id.user_ids else self.env['res.users']
|
||||
# notify invited members through the bus
|
||||
if user:
|
||||
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', channel_partner.partner_id.id), {
|
||||
'type': 'mail.channel_joined',
|
||||
'payload': {
|
||||
'channel': channel_partner.channel_id.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).channel_info()[0],
|
||||
'invited_by_user_id': self.env.user.id,
|
||||
},
|
||||
})
|
||||
# notify existing members with a new message in the channel
|
||||
if channel_partner.partner_id == self.env.user.partner_id:
|
||||
notification = _('<div class="o_mail_notification">joined the channel</div>')
|
||||
else:
|
||||
notification = _(
|
||||
'<div class="o_mail_notification">invited <a href="#" data-oe-model="res.partner" data-oe-id="%(new_partner_id)d">%(new_partner_name)s</a> to the channel</div>',
|
||||
new_partner_id=channel_partner.partner_id.id,
|
||||
new_partner_name=channel_partner.partner_id.name,
|
||||
)
|
||||
channel_partner.channel_id.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment", notify_by_email=False)
|
||||
|
||||
def _action_remove_members(self, partners):
|
||||
""" Private implementation to remove members from channels. Done as sudo
|
||||
@@ -271,46 +294,6 @@ class Channel(models.Model):
|
||||
]).unlink()
|
||||
self.invalidate_cache(fnames=['channel_partner_ids', 'channel_last_seen_partner_ids'])
|
||||
|
||||
def channel_invite(self, partner_ids):
|
||||
""" Add the given partner_ids to the current channels and broadcast the channel header to them.
|
||||
:param partner_ids : list of partner id to add
|
||||
"""
|
||||
partners = self.env['res.partner'].browse(partner_ids)
|
||||
self._invite_check_access(partners)
|
||||
|
||||
# add the partner
|
||||
for channel in self:
|
||||
partners_to_add = partners - channel.channel_partner_ids
|
||||
channel.write({'channel_last_seen_partner_ids': [Command.create({'partner_id': partner_id}) for partner_id in partners_to_add.ids]})
|
||||
for partner in partners_to_add:
|
||||
if partner.id != self.env.user.partner_id.id:
|
||||
notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>',
|
||||
author=self.env.user.display_name,
|
||||
new_partner=partner.display_name,
|
||||
channel_id=channel.id,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
else:
|
||||
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', channel.id, channel.name)
|
||||
self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment", author_id=partner.id, notify_by_email=False)
|
||||
|
||||
# broadcast the channel header to the added partner
|
||||
self._broadcast(partner_ids)
|
||||
|
||||
def _invite_check_access(self, partners):
|
||||
""" Check invited partners could match channel access """
|
||||
failed = []
|
||||
if any(channel.public == 'groups' for channel in self):
|
||||
for channel in self.filtered(lambda c: c.public == 'groups'):
|
||||
invalid_partners = [partner for partner in partners if channel.group_public_id not in partner.mapped('user_ids.groups_id')]
|
||||
failed += [(channel, partner) for partner in invalid_partners]
|
||||
|
||||
if failed:
|
||||
raise UserError(
|
||||
_('Following invites are invalid as user groups do not match: %s',
|
||||
', '.join('%s (channel %s)' % (partner.name, channel.name) for channel, partner in failed))
|
||||
)
|
||||
|
||||
def _can_invite(self, partner_id):
|
||||
"""Return True if the current user can invite the partner to the channel.
|
||||
|
||||
@@ -551,7 +534,7 @@ class Channel(models.Model):
|
||||
return []
|
||||
channel_infos = []
|
||||
# all relations partner_channel on those channels
|
||||
all_partner_channel = self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids)])
|
||||
all_partner_channel = self.env['mail.channel.partner'].sudo().search([('channel_id', 'in', self.ids)]).sudo(False)
|
||||
|
||||
# all partner infos on those channels
|
||||
channel_dict = {channel.id: channel for channel in self}
|
||||
@@ -881,16 +864,11 @@ class Channel(models.Model):
|
||||
domain = expression.AND([domain, [('name', 'ilike', '%'+name+'%')]])
|
||||
return self.search(domain).read(['name', 'public', 'uuid', 'channel_type'])
|
||||
|
||||
def channel_join_and_get_info(self):
|
||||
self.ensure_one()
|
||||
added = self.action_follow()
|
||||
if added and self.channel_type == 'channel':
|
||||
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', self.id, self.name)
|
||||
self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment")
|
||||
|
||||
channel_info = self.channel_info('join')[0]
|
||||
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
|
||||
return channel_info
|
||||
def channel_join(self):
|
||||
""" Shortcut to add the current user as member of self channels.
|
||||
Prefer calling add_members() directly when possible.
|
||||
"""
|
||||
self.add_members(self.env.user.partner_id.ids)
|
||||
|
||||
@api.model
|
||||
def channel_create(self, name, privacy='public'):
|
||||
|
||||
@@ -112,6 +112,36 @@ class Partner(models.Model):
|
||||
_logger.error('Call to starred_count without partner_id')
|
||||
return 0
|
||||
|
||||
@api.model
|
||||
def search_for_channel_invite(self, search_term, channel_id=None, limit=30):
|
||||
""" Returns partners matching search_term that can be invited to a channel.
|
||||
If the channel_id is specified, only partners that can actually be invited to the channel
|
||||
are returned (not already members, and in accordance to the channel configuration).
|
||||
"""
|
||||
domain = expression.AND([
|
||||
expression.OR([
|
||||
[('name', 'ilike', search_term)],
|
||||
[('email', 'ilike', search_term)],
|
||||
]),
|
||||
[('active', '=', True)],
|
||||
[('type', '!=', 'private')],
|
||||
[('user_ids', '!=', False)],
|
||||
[('user_ids.active', '=', True)],
|
||||
[('user_ids.share', '=', False)],
|
||||
])
|
||||
if channel_id:
|
||||
channel = self.env['mail.channel'].search([('id', '=', int(channel_id))])
|
||||
domain = expression.AND([domain, [('channel_ids', 'not in', channel.id)]])
|
||||
if channel.public == 'groups':
|
||||
domain = expression.AND([domain, [('user_ids.groups_id', 'in', channel.group_public_id.id)]])
|
||||
query = self.env['res.partner']._search(domain, order='name, id')
|
||||
query.order = 'LOWER("res_partner"."name"), "res_partner"."id"' # bypass lack of support for case insensitive order in search()
|
||||
query.limit = int(limit)
|
||||
return {
|
||||
'count': self.env['res.partner'].search_count(domain),
|
||||
'partners': [p.mail_partner_format() for p in self.env['res.partner'].browse(query)],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_mention_suggestions(self, search, limit=8, channel_id=None):
|
||||
""" Return 'limit'-first partners' such that the name or email matches a 'search' string.
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
const { onWillUpdateProps, useComponent } = owl.hooks;
|
||||
|
||||
/**
|
||||
* This hook provides support for saving the reference of the component directly
|
||||
* into the field of a record, and appropriately updates it when necessary
|
||||
* (props change or destroy).
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.fieldName Name of the field on the target record.
|
||||
* @param {string} param0.modelName Name of the model of the target record.
|
||||
* @param {string} param0.propNameAsRecordLocalId Name of the prop of this
|
||||
* component containing the localId of the target record.
|
||||
*/
|
||||
export function useComponentToModel({ fieldName, modelName, propNameAsRecordLocalId }) {
|
||||
const component = useComponent();
|
||||
const record = component.env.models[modelName].get(component.props[propNameAsRecordLocalId]);
|
||||
if (record) {
|
||||
record.update({ [fieldName]: component });
|
||||
}
|
||||
onWillUpdateProps(nextProps => {
|
||||
const currentRecord = component.env.models[modelName].get(component.props[propNameAsRecordLocalId]);
|
||||
if (currentRecord) {
|
||||
currentRecord.update({ [fieldName]: clear() });
|
||||
}
|
||||
const nextRecord = component.env.models[modelName].get(nextProps[propNameAsRecordLocalId]);
|
||||
if (nextRecord) {
|
||||
nextRecord.update({ [fieldName]: component });
|
||||
}
|
||||
});
|
||||
const __destroy = component.__destroy;
|
||||
component.__destroy = parent => {
|
||||
const record = component.env.models[modelName].get(component.props[propNameAsRecordLocalId]);
|
||||
if (record) {
|
||||
record.update({ [fieldName]: clear() });
|
||||
}
|
||||
__destroy.call(component, parent);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model/use_component_to_model';
|
||||
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to_model';
|
||||
import { useShouldUpdateBasedOnProps } from '@mail/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props';
|
||||
import { useStore } from '@mail/component_hooks/use_store/use_store';
|
||||
import { useUpdateToModel } from '@mail/component_hooks/use_update_to_model/use_update_to_model';
|
||||
import { PartnerImStatusIcon } from '@mail/components/partner_im_status_icon/partner_im_status_icon';
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
const components = { PartnerImStatusIcon };
|
||||
|
||||
export class ChannelInvitationForm extends Component {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useShouldUpdateBasedOnProps();
|
||||
useStore(props => {
|
||||
const channelInvitationForm = this.env.models['mail.channel_invitation_form'].get(this.props.localId);
|
||||
const selectablePartners = channelInvitationForm ? channelInvitationForm.selectablePartners : [];
|
||||
const selectedPartners = channelInvitationForm ? channelInvitationForm.selectedPartners : [];
|
||||
return {
|
||||
channelInvitationForm,
|
||||
channelInvitationFormInviteButtonText: channelInvitationForm && channelInvitationForm.inviteButtonText,
|
||||
channelInvitationFormSearchResultCount: channelInvitationForm && channelInvitationForm.searchResultCount,
|
||||
channelInvitationFormSearchTerm: channelInvitationForm && channelInvitationForm.searchTerm,
|
||||
selectablePartners: selectablePartners.map(selectablePartner => {
|
||||
return {
|
||||
avatarUrl: selectablePartner.avatarUrl,
|
||||
im_status: selectablePartner.im_status,
|
||||
nameOrDisplayName: selectablePartner.nameOrDisplayName,
|
||||
selectablePartner,
|
||||
};
|
||||
}),
|
||||
selectedPartners: selectedPartners.map(selectedPartner => {
|
||||
return {
|
||||
im_status: selectedPartner.im_status,
|
||||
selectedPartner,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, {
|
||||
compareDepth: {
|
||||
selectablePartners: 2, // array + data object
|
||||
selectedPartners: 2, // array + data object
|
||||
},
|
||||
});
|
||||
useComponentToModel({ fieldName: 'component', modelName: 'mail.channel_invitation_form', propNameAsRecordLocalId: 'localId' });
|
||||
useRefToModel({ fieldName: 'searchInputRef', modelName: 'mail.channel_invitation_form', propNameAsRecordLocalId: 'localId', refName: 'searchInput' });
|
||||
useUpdateToModel({ methodName: 'onComponentUpdate', modelName: 'mail.channel_invitation_form', propNameAsRecordLocalId: 'localId' });
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
get channelInvitationForm() {
|
||||
return this.env.models['mail.channel_invitation_form'].get(this.props.localId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ChannelInvitationForm, {
|
||||
components,
|
||||
props: {
|
||||
localId: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
template: 'mail.ChannelInvitationForm',
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Layout
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ChannelInvitationForm {
|
||||
width: Min(95vw, 400px);
|
||||
max-height: Min(calc(100vh - 140px), 530px);
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerAvatarContainer {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerAvatar {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerAvatarNameSeparator {
|
||||
min-width: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerNameSelectionSeparator {
|
||||
min-width: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerImStatusIcon {
|
||||
@include o-position-absolute($bottom: 0, $right: 0);
|
||||
|
||||
&:not(.o_ChannelInvitationForm_selectablePartnerImStatusIcon-mobile) {
|
||||
font-size: x-small;
|
||||
}
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartnerName {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.o_ChannelInvitationForm_selectedPartners {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Style
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_ChannelInvitationForm_selectablePartner {
|
||||
&:hover {
|
||||
background-color: gray('100');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ChannelInvitationForm" owl="1">
|
||||
<div class="o_ChannelInvitationForm d-flex flex-column" t-ref="channelInvitationForm">
|
||||
<h3 class="mx-3 mt-3 mb-2">Invite people</h3>
|
||||
<div class="mx-3 my-2">
|
||||
<input type="text" t-att-value="channelInvitationForm.searchTerm" placeholder="Type the name of a person" t-on-input="channelInvitationForm.onInputSearch" t-ref="searchInput"/>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 mx-0 py-2 overflow-auto">
|
||||
<t t-foreach="channelInvitationForm.selectablePartners" t-as="selectablePartner" t-key="selectablePartner.localId">
|
||||
<div class="o_ChannelInvitationForm_selectablePartner d-flex align-items-center px-3 py-1" t-on-click="channelInvitationForm.onClickSelectablePartner.bind(channelInvitationForm, selectablePartner)">
|
||||
<div class="o_ChannelInvitationForm_selectablePartnerAvatarContainer flex-shrink-0 position-relative">
|
||||
<img class="o_ChannelInvitationForm_selectablePartnerAvatar w-100 h-100 rounded-circle" t-att-src="selectablePartner.avatarUrl" alt="Avatar"/>
|
||||
<t t-if="selectablePartner.im_status and selectablePartner.im_status !== 'im_partner'">
|
||||
<PartnerImStatusIcon
|
||||
class="o_ChannelInvitationForm_selectablePartnerImStatusIcon d-flex align-items-center justify-content-center text-white"
|
||||
t-att-class="{
|
||||
'o_ChannelInvitationForm_selectablePartnerImStatusIcon-mobile': env.messaging.device.isMobile,
|
||||
}"
|
||||
partnerLocalId="selectablePartner.localId"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_ChannelInvitationForm_selectablePartnerAvatarNameSeparator"/>
|
||||
<span class="o_ChannelInvitationForm_selectablePartnerName flex-grow-1 text-truncate">
|
||||
<t t-esc="selectablePartner.nameOrDisplayName"/>
|
||||
</span>
|
||||
<div class="o_ChannelInvitationForm_selectablePartnerNameSelectionSeparator flex-grow-1 flex-shrink-0"/>
|
||||
<input class="flex-shrink-0" type="checkbox" t-att-checked="channelInvitationForm.selectedPartners.includes(selectablePartner) ? 'checked' : undefined" t-on-input="channelInvitationForm.onInputPartnerCheckbox.bind(channelInvitationForm, selectablePartner)" t-ref="selection-status"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="channelInvitationForm.selectablePartners.length === 0">
|
||||
<div class="mx-3">No user found that is not already a member of this channel.</div>
|
||||
</t>
|
||||
<t t-if="channelInvitationForm.searchResultCount > channelInvitationForm.selectablePartners.length">
|
||||
<div class="mx-3">
|
||||
Showing <t t-esc="channelInvitationForm.selectablePartners.length"/> results out of <t t-esc="channelInvitationForm.searchResultCount"/>. Narrow your search to see more choices.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="channelInvitationForm.selectedPartners.length > 0">
|
||||
<div class="mx-3 mt-3">
|
||||
<h4>Selected users:</h4>
|
||||
<div class="o_ChannelInvitationForm_selectedPartners overflow-auto">
|
||||
<t t-foreach="channelInvitationForm.selectedPartners" t-as="selectedPartner" t-key="selectedPartner.localId">
|
||||
<button class="btn btn-secondary" t-on-click="channelInvitationForm.onClickSelectedPartner.bind(channelInvitationForm, selectedPartner)">
|
||||
<t t-esc="selectedPartner.nameOrDisplayName"/> <i class="fa fa-times"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="mx-3 mt-2 mb-3">
|
||||
<button class="btn btn-primary w-100" t-att-disabled="channelInvitationForm.selectedPartners.length === 0" t-on-click="channelInvitationForm.onClickInvite">
|
||||
<t t-esc="channelInvitationForm.inviteButtonText"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2271,7 +2271,7 @@ QUnit.test('sidebar quick search', async function (assert) {
|
||||
});
|
||||
|
||||
QUnit.test('basic top bar rendering', async function (assert) {
|
||||
assert.expect(7);
|
||||
assert.expect(8);
|
||||
|
||||
// channel expected to be found in the sidebar
|
||||
// with a random unique id and name that will be referenced in the test
|
||||
@@ -2335,6 +2335,11 @@ QUnit.test('basic top bar rendering', async function (assert) {
|
||||
"General",
|
||||
"display general in the breadcrumb"
|
||||
);
|
||||
const inviteButton = document.querySelector(`.o_ThreadViewTopbar_inviteButton`);
|
||||
assert.isVisible(
|
||||
inviteButton,
|
||||
"should have button 'Invite' in the top bar of channel"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('inbox: mark all messages as read', async function (assert) {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to
|
||||
import { useShouldUpdateBasedOnProps } from '@mail/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props';
|
||||
import { useStore } from '@mail/component_hooks/use_store/use_store';
|
||||
import { useUpdateToModel } from '@mail/component_hooks/use_update_to_model/use_update_to_model';
|
||||
import { ChannelInvitationForm } from '@mail/components/channel_invitation_form/channel_invitation_form';
|
||||
import { ThreadIcon } from '@mail/components/thread_icon/thread_icon';
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
const components = { ThreadIcon };
|
||||
const components = { ChannelInvitationForm, ThreadIcon };
|
||||
|
||||
export class ThreadViewTopbar extends Component {
|
||||
|
||||
@@ -16,19 +17,23 @@ export class ThreadViewTopbar extends Component {
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useShouldUpdateBasedOnProps();
|
||||
useStore(props => {
|
||||
const threadViewTopBar = this.env.models['mail.thread_view_topbar'].get(props.localId);
|
||||
const thread = threadViewTopBar && threadViewTopBar.thread;
|
||||
const threadView = threadViewTopBar && threadViewTopBar.threadView;
|
||||
const channelInvitationForm = threadView && threadView.channelInvitationForm;
|
||||
return {
|
||||
channelInvitationForm,
|
||||
channelInvitationFormComponent: channelInvitationForm && channelInvitationForm.component,
|
||||
inbox: this.env.messaging.inbox,
|
||||
starred: this.env.messaging.starred,
|
||||
thread,
|
||||
threadChannelType: thread && thread.channel_type,
|
||||
threadDescription: thread && thread.description,
|
||||
threadDisplayName: thread && thread.displayName,
|
||||
threadModel: thread && thread.model,
|
||||
threadHasInviteFeature: thread && thread.hasInviteFeature,
|
||||
threadViewMessagesLength: threadView && threadView.messages.length,
|
||||
threadViewTopBar,
|
||||
threadViewTopBarIsEditingThreadName: threadViewTopBar && threadViewTopBar.isEditingThreadName,
|
||||
|
||||
@@ -27,6 +27,29 @@
|
||||
background-color: gray('100');
|
||||
}
|
||||
|
||||
.o_ThreadViewTopbar_button {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&.o-active {
|
||||
color: gray('700');
|
||||
}
|
||||
|
||||
&:not(.o-active) {
|
||||
color: gray('500');
|
||||
}
|
||||
|
||||
@include hover-focus () {
|
||||
outline: none;
|
||||
color: gray('600');
|
||||
}
|
||||
}
|
||||
|
||||
.o_ThreadViewTopbar_invitePopover .popover-body {
|
||||
padding: 0; // cancel unwanted padding
|
||||
}
|
||||
|
||||
.o_ThreadViewTopbar_threadDescriptionSeparator {
|
||||
background-color: $border-color;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@
|
||||
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread === env.messaging.starred">
|
||||
<button class="o_ThreadViewTopbar_unstarAllButton btn btn-secondary" t-att-disabled="threadViewTopBar.threadView.messages.length === 0" t-on-click="threadViewTopBar.onClickUnstarAll">Unstar all</button>
|
||||
</t>
|
||||
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread.hasInviteFeature">
|
||||
<Popover popoverClass="'o_ThreadViewTopbar_invitePopover'" position="'bottom'">
|
||||
<button class="o_ThreadViewTopbar_inviteButton o_ThreadViewTopbar_button" t-att-class="{ 'o-active': threadViewTopBar.threadView.channelInvitationForm.component }" title="Add users" t-on-click="threadViewTopBar.onClickInviteButton">
|
||||
<i class="fa fa-lg fa-user-plus"/>
|
||||
</button>
|
||||
<t t-set="opened">
|
||||
<ChannelInvitationForm localId="threadViewTopBar.threadView.channelInvitationForm.localId"/>
|
||||
</t>
|
||||
</Popover>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { attr, many2many, many2one, one2one } from '@mail/model/model_field';
|
||||
import { clear, insertAndReplace, link, unlink, unlinkAll } from '@mail/model/model_field_command';
|
||||
import { cleanSearchTerm } from '@mail/utils/utils';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
class ChannelInvitationForm extends dependencies['mail.model'] {
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_created() {
|
||||
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
|
||||
this.onClickInvite = this.onClickInvite.bind(this);
|
||||
this.onInputSearch = this.onInputSearch.bind(this);
|
||||
}
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Closes this channel invitation form.
|
||||
*/
|
||||
close() {
|
||||
this.component.trigger('o-popover-close');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click on the "invite" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickInvite(ev) {
|
||||
await this.env.services.rpc(({
|
||||
model: 'mail.channel',
|
||||
method: 'add_members',
|
||||
args: [this.thread.id],
|
||||
kwargs: {
|
||||
partner_ids: this.selectedPartners.map(partner => partner.id),
|
||||
},
|
||||
}));
|
||||
this.update({
|
||||
searchTerm: "",
|
||||
selectedPartners: unlinkAll(),
|
||||
});
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mail.partner} partner
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickSelectablePartner(partner, ev) {
|
||||
if (this.selectedPartners.includes(partner)) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
return;
|
||||
}
|
||||
this.update({ selectedPartners: link(partner) });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mail.partner} partner
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickSelectedPartner(partner, ev) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles OWL update on this channel invitation form component.
|
||||
*/
|
||||
onComponentUpdate() {
|
||||
if (this.doFocusOnSearchInput) {
|
||||
this.searchInputRef.el.focus();
|
||||
this.searchInputRef.el.setSelectionRange(this.searchTerm.length, this.searchTerm.length);
|
||||
this.update({ doFocusOnSearchInput: clear() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {mail.partner} partner
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
onInputPartnerCheckbox(partner, ev) {
|
||||
if (!ev.target.checked) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
return;
|
||||
}
|
||||
this.update({ selectedPartners: link(partner) });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
async onInputSearch(ev) {
|
||||
this.update({ searchTerm: ev.target.value });
|
||||
this.searchPartnersToInvite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for partners to invite based on the current search term. If
|
||||
* a search is already in progress, waits until it is done to start a
|
||||
* new one.
|
||||
*/
|
||||
async searchPartnersToInvite() {
|
||||
if (this.hasSearchRpcInProgress) {
|
||||
this.update({ hasPendingSearchRpc: true });
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
hasPendingSearchRpc: false,
|
||||
hasSearchRpcInProgress: true,
|
||||
});
|
||||
try {
|
||||
const channelId = (this.thread && this.thread.model === 'mail.channel') ? this.thread.id : undefined;
|
||||
const { count, partners: partnersData } = await this.env.services.rpc(
|
||||
{
|
||||
model: 'res.partner',
|
||||
method: 'search_for_channel_invite',
|
||||
kwargs: {
|
||||
channel_id: channelId,
|
||||
search_term: cleanSearchTerm(this.searchTerm),
|
||||
},
|
||||
},
|
||||
{ shadow: true }
|
||||
);
|
||||
this.update({
|
||||
searchResultCount: count,
|
||||
selectablePartners: insertAndReplace(partnersData.map(partnerData => this.env.models['mail.partner'].convertData(partnerData))),
|
||||
});
|
||||
} finally {
|
||||
this.update({ hasSearchRpcInProgress: false });
|
||||
if (this.hasPendingSearchRpc) {
|
||||
this.searchPartnersToInvite();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_computeInviteButtonText() {
|
||||
if (!this.thread || this.thread.channel_type !== 'channel') {
|
||||
return clear();
|
||||
}
|
||||
return this.env._t("Invite to Channel");
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.messaging}
|
||||
*/
|
||||
_computeMessaging() {
|
||||
return link(this.threadView.messaging);
|
||||
}
|
||||
}
|
||||
|
||||
ChannelInvitationForm.fields = {
|
||||
/**
|
||||
* States the OWL component of this channel invitation form.
|
||||
* Useful to be able to close it with popover trigger, or to know when
|
||||
* it is open to update the button active state.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* Determines whether this search input needs to be focused.
|
||||
*/
|
||||
doFocusOnSearchInput: attr(),
|
||||
/**
|
||||
* States whether there is a pending search RPC.
|
||||
*/
|
||||
hasPendingSearchRpc: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether there is search RPC in progress.
|
||||
*/
|
||||
hasSearchRpcInProgress: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines the text of the invite button.
|
||||
*/
|
||||
inviteButtonText: attr({
|
||||
compute: '_computeInviteButtonText',
|
||||
dependencies: ['thread', 'threadChannelType'],
|
||||
}),
|
||||
/**
|
||||
* Serves as compute dependency.
|
||||
*/
|
||||
messaging: many2one('mail.messaging', {
|
||||
compute: '_computeMessaging',
|
||||
}),
|
||||
/**
|
||||
* States the OWL ref of the "search" input of this channel invitation
|
||||
* form. Useful to be able to focus it.
|
||||
*/
|
||||
searchInputRef: attr(),
|
||||
/**
|
||||
* States the number of results of the last search.
|
||||
*/
|
||||
searchResultCount: attr({
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Determines the search term used to filter this list.
|
||||
*/
|
||||
searchTerm: attr({
|
||||
default: "",
|
||||
}),
|
||||
/**
|
||||
* States all partners that are potential choices according to this
|
||||
* search term.
|
||||
*/
|
||||
selectablePartners: many2many('mail.partner'),
|
||||
/**
|
||||
* Determines all partners that are currently selected.
|
||||
*/
|
||||
selectedPartners: many2many('mail.partner'),
|
||||
/**
|
||||
* States the thread on which this list operates (if any).
|
||||
*/
|
||||
thread: many2one('mail.thread', {
|
||||
related: 'threadView.thread',
|
||||
}),
|
||||
/**
|
||||
* States the thread view on which this list operates (if any).
|
||||
*/
|
||||
threadView: one2one('mail.thread_view', {
|
||||
inverse: 'channelInvitationForm',
|
||||
}),
|
||||
/**
|
||||
* Serves as compute dependency.
|
||||
*/
|
||||
threadChannelType: attr({
|
||||
related: 'thread.channel_type',
|
||||
}),
|
||||
};
|
||||
|
||||
ChannelInvitationForm.modelName = 'mail.channel_invitation_form';
|
||||
|
||||
return ChannelInvitationForm;
|
||||
}
|
||||
|
||||
registerNewModel('mail.channel_invitation_form', factory);
|
||||
@@ -60,11 +60,14 @@ function factory(dependencies) {
|
||||
);
|
||||
channel.open();
|
||||
} else {
|
||||
const channel = await this.async(() =>
|
||||
this.env.models['mail.thread'].performRpcJoinChannel({
|
||||
channelId: ui.item.id,
|
||||
})
|
||||
);
|
||||
const channel = this.env.models['mail.thread'].insert({
|
||||
id: ui.item.id,
|
||||
model: 'mail.channel',
|
||||
});
|
||||
await channel.join();
|
||||
// Channel must be pinned immediately to be able to open it before
|
||||
// the result of join is received on the bus.
|
||||
channel.update({ isServerPinned: true });
|
||||
channel.open();
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -76,6 +76,12 @@ function factory(dependencies) {
|
||||
// uuid notification, only for (livechat) public handler
|
||||
return;
|
||||
}
|
||||
if (typeof message === 'object') {
|
||||
switch (message.type) {
|
||||
case 'mail.channel_joined':
|
||||
return this._handleNotificationChannelJoined(message.payload);
|
||||
}
|
||||
}
|
||||
const [, model, id] = channel;
|
||||
switch (model) {
|
||||
case 'ir.needaction':
|
||||
@@ -169,6 +175,26 @@ function factory(dependencies) {
|
||||
this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} payload
|
||||
* @param {mail.thread} payload.channel
|
||||
* @param {integer} payload.invited_by_user_id
|
||||
*/
|
||||
_handleNotificationChannelJoined({ channel: channelData, invited_by_user_id: invitedByUserId }) {
|
||||
const channel = this.env.models['mail.thread'].insert(this.env.models['mail.thread'].convertData(channelData));
|
||||
if (invitedByUserId !== this.env.messaging.currentUser.id) {
|
||||
// Current user was invited by someone else.
|
||||
this.env.services['notification'].notify({
|
||||
message: _.str.sprintf(
|
||||
this.env._t("You have been invited to #%s"),
|
||||
channel.name
|
||||
),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} channelId
|
||||
@@ -181,7 +207,6 @@ function factory(dependencies) {
|
||||
});
|
||||
const wasChannelExisting = !!channel;
|
||||
const convertedData = this.env.models['mail.message'].convertData(messageData);
|
||||
const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData);
|
||||
|
||||
// Fetch missing info from channel before going further. Inserting
|
||||
// a channel with incomplete info can lead to issues. This is in
|
||||
|
||||
@@ -510,33 +510,6 @@ function factory(dependencies) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `channel_join_and_get_info` RPC on `mail.channel`.
|
||||
*
|
||||
* @static
|
||||
* @param {Object} param0
|
||||
* @param {integer} param0.channelId
|
||||
* @returns {mail.thread} the channel that was joined
|
||||
*/
|
||||
static async performRpcJoinChannel({ channelId }) {
|
||||
const device = this.env.messaging.device;
|
||||
const data = await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_join_and_get_info',
|
||||
args: [[channelId]],
|
||||
kwargs: {
|
||||
context: Object.assign({}, this.env.session.user_content, {
|
||||
// optimize the return value by avoiding useless queries
|
||||
// in non-mobile devices
|
||||
isMobile: device.isMobile,
|
||||
}),
|
||||
},
|
||||
});
|
||||
return this.env.models['mail.thread'].insert(
|
||||
this.env.models['mail.thread'].convertData(data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the `execute_command` RPC on `mail.channel`.
|
||||
*
|
||||
@@ -705,6 +678,18 @@ function factory(dependencies) {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins this thread. Only makes sense on channels.
|
||||
*/
|
||||
async join() {
|
||||
await this.env.services.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'add_members',
|
||||
args: [[this.id]],
|
||||
kwargs: { partner_ids: [this.env.messaging.currentPartner.id] }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load new messages on the main cache of this thread.
|
||||
*/
|
||||
@@ -1164,6 +1149,17 @@ function factory(dependencies) {
|
||||
return replace(this.activities.filter(activity => activity.state === 'planned'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_computeHasInviteFeature() {
|
||||
if (this.model !== 'mail.channel') {
|
||||
return false;
|
||||
}
|
||||
return this.channel_type === 'channel';
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
@@ -1796,6 +1792,14 @@ function factory(dependencies) {
|
||||
hasActivities: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether this thread should has the invite feature. Only makes
|
||||
* sense for channels.
|
||||
*/
|
||||
hasInviteFeature: attr({
|
||||
compute: '_computeHasInviteFeature',
|
||||
dependencies: ['channel_type', 'model'],
|
||||
}),
|
||||
/**
|
||||
* Determine whether this thread has the seen indicators (V and VV)
|
||||
* enabled or not.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { registerNewModel } from '@mail/model/model_core';
|
||||
import { RecordDeletedError } from '@mail/model/model_errors';
|
||||
import { attr, many2many, many2one, one2one } from '@mail/model/model_field';
|
||||
import { clear, create, link, unlink } from '@mail/model/model_field_command';
|
||||
import { clear, create, link, unlink, update } from '@mail/model/model_field_command';
|
||||
|
||||
function factory(dependencies) {
|
||||
|
||||
@@ -66,6 +66,25 @@ function factory(dependencies) {
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.channel_invitation_form}
|
||||
*/
|
||||
_computeChannelInvitationForm() {
|
||||
if (!this.thread || !this.thread.hasInviteFeature) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.channelInvitationForm) {
|
||||
return create();
|
||||
}
|
||||
return update({
|
||||
searchResultCount: clear(),
|
||||
searchTerm: clear(),
|
||||
selectablePartners: clear(),
|
||||
selectedPartners: clear(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {mail.messaging}
|
||||
@@ -209,6 +228,17 @@ function factory(dependencies) {
|
||||
}
|
||||
|
||||
ThreadView.fields = {
|
||||
/**
|
||||
* States which channel invitation form is operating this thread view.
|
||||
* Only applies if this thread is a channel.
|
||||
*/
|
||||
channelInvitationForm: one2one('mail.channel_invitation_form', {
|
||||
compute: '_computeChannelInvitationForm',
|
||||
dependencies: ['thread', 'threadHasInviteFeature'],
|
||||
inverse: 'threadView',
|
||||
isCausal: true,
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* List of component hints. Hints contain information that help
|
||||
* components make UI/UX decisions based on their UI state.
|
||||
@@ -404,6 +434,12 @@ function factory(dependencies) {
|
||||
default: {},
|
||||
related: 'threadViewer.threadCacheInitialScrollPositions',
|
||||
}),
|
||||
/**
|
||||
* Serves as compute dependency.
|
||||
*/
|
||||
threadHasInviteFeature: attr({
|
||||
related: 'thread.hasInviteFeature',
|
||||
}),
|
||||
/**
|
||||
* Serves as compute dependency.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ function factory(dependencies) {
|
||||
_created() {
|
||||
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
|
||||
this.onClickInboxMarkAllAsRead = this.onClickInboxMarkAllAsRead.bind(this);
|
||||
this.onClickInviteButton = this.onClickInviteButton.bind(this);
|
||||
this.onClickTopbarThreadName = this.onClickTopbarThreadName.bind(this);
|
||||
this.onClickUnstarAll = this.onClickUnstarAll.bind(this);
|
||||
this.onInputThreadNameInput = this.onInputThreadNameInput.bind(this);
|
||||
@@ -46,6 +47,19 @@ function factory(dependencies) {
|
||||
this.env.models['mail.message'].markAllAsRead();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click on the "invite" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickInviteButton(ev) {
|
||||
if (this.threadView.channelInvitationForm.component) {
|
||||
return;
|
||||
}
|
||||
this.threadView.channelInvitationForm.update({ doFocusOnSearchInput: true });
|
||||
this.threadView.channelInvitationForm.searchPartnersToInvite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click on the "thread name" of this top bar.
|
||||
*
|
||||
@@ -184,6 +198,9 @@ function factory(dependencies) {
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickCaptureGlobal(ev) {
|
||||
if (!this.threadNameInputRef) {
|
||||
return;
|
||||
}
|
||||
if (this.threadNameInputRef.el && this.threadNameInputRef.el.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,9 +167,10 @@ MockServer.include({
|
||||
const ids = args.args[0];
|
||||
return this._mockMailChannelChannelInfo(ids);
|
||||
}
|
||||
if (args.model === 'mail.channel' && args.method === 'channel_join_and_get_info') {
|
||||
if (args.model === 'mail.channel' && args.method === 'add_members') {
|
||||
const ids = args.args[0];
|
||||
return this._mockMailChannelChannelJoinAndGetInfo(ids);
|
||||
const partner_ids = args.args[1] || args.kwargs.partner_ids;
|
||||
return this._mockMailChannelAddMembers(ids, partner_ids);
|
||||
}
|
||||
if (args.model === 'mail.channel' && args.method === 'channel_minimize') {
|
||||
return;
|
||||
@@ -541,6 +542,41 @@ MockServer.include({
|
||||
grouped_activities: groupedActivities,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates `add_members` on `mail.channel`.
|
||||
* For simplicity only handles the current partner joining himself.
|
||||
*
|
||||
* @private
|
||||
* @param {integer[]} ids
|
||||
* @param {integer[]} partner_ids
|
||||
*/
|
||||
_mockMailChannelAddMembers(ids, partner_ids) {
|
||||
const id = ids[0]; // ensure one
|
||||
const channel = this._getRecords('mail.channel', [['id', '=', id]])[0];
|
||||
// channel.partner not handled here for simplicity
|
||||
if (!channel.is_pinned) {
|
||||
this._mockWrite('mail.channel', [
|
||||
[channel.id],
|
||||
{ is_pinned: true },
|
||||
]);
|
||||
const body = `<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="${channel.id}">#${channel.name}</a></div>`;
|
||||
const message_type = "notification";
|
||||
const subtype_xmlid = "mail.mt_comment";
|
||||
this._mockMailChannelMessagePost(
|
||||
'mail.channel',
|
||||
[channel.id],
|
||||
{ body, message_type, subtype_xmlid },
|
||||
);
|
||||
}
|
||||
const notification = [[false, 'res.partner', this.currentPartnerId], {
|
||||
type: 'mail.channel_joined',
|
||||
payload: {
|
||||
'channel': this._mockMailChannelChannelInfo([channel.id])[0],
|
||||
'invited_by_user_id': this.currentUserId,
|
||||
},
|
||||
}];
|
||||
this._widget.call('bus_service', 'trigger', 'notification', [notification]);
|
||||
},
|
||||
/**
|
||||
* Simulates `_broadcast` on `mail.channel`.
|
||||
*
|
||||
@@ -754,36 +790,6 @@ MockServer.include({
|
||||
return res;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Simulates `channel_join_and_get_info` on `mail.channel`.
|
||||
*
|
||||
* @private
|
||||
* @param {integer[]} ids
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_mockMailChannelChannelJoinAndGetInfo(ids) {
|
||||
const id = ids[0]; // ensure one
|
||||
const channel = this._getRecords('mail.channel', [['id', '=', id]])[0];
|
||||
// channel.partner not handled here for simplicity
|
||||
if (!channel.is_pinned) {
|
||||
this._mockWrite('mail.channel', [
|
||||
[channel.id],
|
||||
{ is_pinned: true },
|
||||
]);
|
||||
const body = `<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="${channel.id}">#${channel.name}</a></div>`;
|
||||
const message_type = "notification";
|
||||
const subtype_xmlid = "mail.mt_comment";
|
||||
this._mockMailChannelMessagePost(
|
||||
'mail.channel',
|
||||
[channel.id],
|
||||
{ body, message_type, subtype_xmlid },
|
||||
);
|
||||
}
|
||||
const channelInfo = this._mockMailChannelChannelInfo([channel.id], 'join')[0];
|
||||
const notification = [[false, 'res.partner', this.currentPartnerId], channelInfo];
|
||||
this._widget.call('bus_service', 'trigger', 'notification', [notification]);
|
||||
return channelInfo;
|
||||
},
|
||||
/**
|
||||
* Simulates the `channel_seen` method of `mail.channel`.
|
||||
*
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
from odoo import Command
|
||||
from odoo.addons.mail.tests.common import mail_new_test_user
|
||||
from odoo.addons.mail.tests.common import MailCommon
|
||||
from odoo.exceptions import AccessError, ValidationError, UserError
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.tests import tagged, Form
|
||||
from odoo.tests.common import users
|
||||
from odoo.tools import mute_logger, formataddr
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
@tagged('mail_channel')
|
||||
@@ -128,7 +128,7 @@ class TestChannelAccessRights(MailCommon):
|
||||
group_public.write({'name': 'Better Name'})
|
||||
|
||||
with self.assertRaises(AccessError):
|
||||
group_public.action_follow()
|
||||
group_public.add_members(self.env.user.partner_id.ids)
|
||||
|
||||
group_private = self.env['mail.channel'].browse(self.group_private.id)
|
||||
with self.assertRaises(AccessError):
|
||||
@@ -184,7 +184,7 @@ class TestChannelInternals(MailCommon):
|
||||
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
|
||||
self.assertEqual(channel.channel_partner_ids, self.env['res.partner'])
|
||||
|
||||
channel._action_add_members(self.test_partner)
|
||||
channel.add_members(self.test_partner.ids)
|
||||
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
|
||||
self.assertEqual(channel.channel_partner_ids, self.test_partner)
|
||||
|
||||
@@ -201,7 +201,7 @@ class TestChannelInternals(MailCommon):
|
||||
def test_channel_recipients_channel(self):
|
||||
""" Posting a message on a channel should not send emails """
|
||||
channel = self.env['mail.channel'].browse(self.test_channel.ids)
|
||||
channel._action_add_members(self.partner_employee | self.partner_admin | self.test_partner)
|
||||
channel.add_members((self.partner_employee | self.partner_admin | self.test_partner).ids)
|
||||
with self.mock_mail_gateway():
|
||||
new_msg = channel.message_post(body="Test", message_type='comment', subtype_xmlid='mail.mt_comment')
|
||||
self.assertNotSentEmail()
|
||||
@@ -216,7 +216,7 @@ class TestChannelInternals(MailCommon):
|
||||
self.test_channel.write({
|
||||
'channel_type': 'chat',
|
||||
})
|
||||
self.test_channel._action_add_members(self.partner_employee | self.partner_admin | self.test_partner)
|
||||
self.test_channel.add_members((self.partner_employee | self.partner_admin | self.test_partner).ids)
|
||||
with self.mock_mail_gateway():
|
||||
with self.with_user('employee'):
|
||||
channel = self.env['mail.channel'].browse(self.test_channel.ids)
|
||||
@@ -250,9 +250,9 @@ class TestChannelInternals(MailCommon):
|
||||
'public': 'groups',
|
||||
'group_public_id': self.env.ref('base.group_user').id})
|
||||
|
||||
self.test_channel._action_add_members(self.partner_employee | self.partner_employee_nomail)
|
||||
test_channel_private._action_add_members(self.partner_employee | self.partner_employee_nomail)
|
||||
test_channel_group._action_add_members(self.partner_employee | self.partner_employee_nomail)
|
||||
self.test_channel.add_members((self.partner_employee | self.partner_employee_nomail).ids)
|
||||
test_channel_private.add_members((self.partner_employee | self.partner_employee_nomail).ids)
|
||||
test_channel_group.add_members((self.partner_employee | self.partner_employee_nomail).ids)
|
||||
|
||||
# Unsubscribe archived user from the private channels, but not from public channels
|
||||
self.user_employee.active = False
|
||||
@@ -295,7 +295,7 @@ class TestChannelInternals(MailCommon):
|
||||
""" In case of concurrent channel_seen RPC, ensure the oldest call has no effect. """
|
||||
channel = self.env['mail.channel'].browse(self.test_channel.id)
|
||||
channel.write({'channel_type': 'chat'})
|
||||
channel.action_follow()
|
||||
channel.add_members(self.env.user.partner_id.ids)
|
||||
|
||||
msg_1 = self._add_messages(self.test_channel, 'Body1', author=self.user_employee.partner_id)
|
||||
msg_2 = self._add_messages(self.test_channel, 'Body2', author=self.user_employee.partner_id)
|
||||
@@ -370,7 +370,7 @@ class TestChannelInternals(MailCommon):
|
||||
even if `_action_unfollow()` is called again.
|
||||
'''
|
||||
channel = self.env['mail.channel'].browse(self.test_channel.id)
|
||||
channel._action_add_members(self.test_partner)
|
||||
channel.add_members(self.test_partner.ids)
|
||||
|
||||
# no message should be posted under test_partner's name
|
||||
messages_0 = self.env['mail.message'].search([
|
||||
|
||||
@@ -67,13 +67,13 @@ class TestMailChannelMembers(MailCommon):
|
||||
self.assertFalse(res)
|
||||
|
||||
# User 1 can join private channel with SUDO
|
||||
self.private_channel.with_user(self.user_1).sudo().action_follow()
|
||||
self.private_channel.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
|
||||
res = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(res.partner_id, self.user_1.partner_id)
|
||||
|
||||
# User 2 can not join private channel
|
||||
with self.assertRaises(AccessError):
|
||||
self.private_channel.with_user(self.user_2).action_follow()
|
||||
self.private_channel.with_user(self.user_2).add_members(self.user_2.partner_id.ids)
|
||||
|
||||
# User 2 can not create a `mail.channel.partner` to join the private channel
|
||||
with self.assertRaises(AccessError):
|
||||
@@ -105,7 +105,7 @@ class TestMailChannelMembers(MailCommon):
|
||||
|
||||
def test_channel_private_members(self):
|
||||
"""Test invitation in private channel part 1 (invite using crud methods)."""
|
||||
self.private_channel.with_user(self.user_1).sudo().action_follow()
|
||||
self.private_channel.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(len(channel_partners), 1)
|
||||
|
||||
@@ -134,25 +134,25 @@ class TestMailChannelMembers(MailCommon):
|
||||
|
||||
def test_channel_private_invite(self):
|
||||
"""Test invitation in private channel part 2 (use `invite` action)."""
|
||||
self.private_channel.with_user(self.user_1).sudo().action_follow()
|
||||
self.private_channel.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id)
|
||||
|
||||
# User 2 is not in the channel, he can not invite user_portal
|
||||
with self.assertRaises(AccessError):
|
||||
self.private_channel.with_user(self.user_2).channel_invite([self.user_portal.partner_id.id])
|
||||
self.private_channel.with_user(self.user_2).add_members(self.user_portal.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id)
|
||||
|
||||
# User 1 is in the channel, he can invite user_portal
|
||||
self.private_channel.with_user(self.user_1).channel_invite([self.user_portal.partner_id.id])
|
||||
self.private_channel.with_user(self.user_1).add_members(self.user_portal.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
|
||||
|
||||
def test_channel_private_leave(self):
|
||||
"""Test kick/leave channel."""
|
||||
self.private_channel.with_user(self.user_1).sudo().action_follow()
|
||||
self.private_channel.with_user(self.user_portal).sudo().action_follow()
|
||||
self.private_channel.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
|
||||
self.private_channel.with_user(self.user_portal).sudo().add_members(self.user_portal.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.private_channel.id)])
|
||||
self.assertEqual(len(channel_partners), 2)
|
||||
|
||||
@@ -173,13 +173,13 @@ class TestMailChannelMembers(MailCommon):
|
||||
self.assertFalse(channel_partners)
|
||||
|
||||
# user 1 is in the group, he can join the channel
|
||||
self.group_channel.with_user(self.user_1).action_follow()
|
||||
self.group_channel.with_user(self.user_1).add_members(self.user_1.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.group_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id)
|
||||
|
||||
# user 3 is not in the group, he can not join
|
||||
with self.assertRaises(AccessError):
|
||||
self.group_channel.with_user(self.user_portal).action_follow()
|
||||
self.group_channel.with_user(self.user_portal).add_members(self.user_portal.partner_id.ids)
|
||||
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.group_channel.id)])
|
||||
with self.assertRaises(AccessError):
|
||||
@@ -190,12 +190,12 @@ class TestMailChannelMembers(MailCommon):
|
||||
|
||||
# user 1 can not invite user 3 because he's not in the group
|
||||
with self.assertRaises(UserError):
|
||||
self.group_channel.with_user(self.user_1).channel_invite([self.user_portal.partner_id.id])
|
||||
self.group_channel.with_user(self.user_1).add_members(self.user_portal.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.group_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id)
|
||||
|
||||
# but user 2 is in the group and can be invited by user 1
|
||||
self.group_channel.with_user(self.user_1).channel_invite([self.user_2.partner_id.id])
|
||||
self.group_channel.with_user(self.user_1).add_members(self.user_2.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.group_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id)
|
||||
|
||||
@@ -208,16 +208,16 @@ class TestMailChannelMembers(MailCommon):
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.public_channel.id)])
|
||||
self.assertFalse(channel_partners)
|
||||
|
||||
self.public_channel.with_user(self.user_1).action_follow()
|
||||
self.public_channel.with_user(self.user_1).add_members(self.user_1.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.public_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id)
|
||||
|
||||
self.public_channel.with_user(self.user_2).action_follow()
|
||||
self.public_channel.with_user(self.user_2).add_members(self.user_2.partner_id.ids)
|
||||
channel_partners = self.env['mail.channel.partner'].search([('channel_id', '=', self.public_channel.id)])
|
||||
self.assertEqual(channel_partners.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id)
|
||||
|
||||
# portal/public users still cannot join a public channel, should go through dedicated controllers
|
||||
with self.assertRaises(AccessError):
|
||||
self.public_channel.with_user(self.user_portal).action_follow()
|
||||
self.public_channel.with_user(self.user_portal).add_members(self.user_portal.partner_id.ids)
|
||||
with self.assertRaises(AccessError):
|
||||
self.public_channel.with_user(self.user_public).action_follow()
|
||||
self.public_channel.with_user(self.user_public).add_members(self.user_public.partner_id.ids)
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<p class="oe_module_name">
|
||||
<field name="description"/>
|
||||
</p>
|
||||
<button type="object" attrs="{'invisible':['|', ('is_member','=',True), ('group_ids', '!=', [])]}" class="btn btn-primary float-right" name="channel_join_and_get_info">Join</button>
|
||||
<button type="object" attrs="{'invisible':['|', ('is_member','=',True), ('group_ids', '!=', [])]}" class="btn btn-primary float-right" name="channel_join">Join</button>
|
||||
<button type="object" attrs="{'invisible':['|', ('is_member','=',False), ('group_ids', '!=', [])]}" class="btn btn-secondary float-right" name="action_unfollow">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5568,7 +5568,7 @@ QUnit.module('Views', {
|
||||
'<t t-name="kanban-box">' +
|
||||
'<div>' +
|
||||
'<field name="foo"/>' +
|
||||
'<button type="object" attrs="{\'invisible\':[\'|\', (\'bar\',\'=\',True), (\'category_ids\', \'!=\', [])]}" class="btn btn-primary float-right" name="channel_join_and_get_info">Join</button>' +
|
||||
'<button type="object" attrs="{\'invisible\':[\'|\', (\'bar\',\'=\',True), (\'category_ids\', \'!=\', [])]}" class="btn btn-primary float-right" name="arbitrary">Join</button>' +
|
||||
'</div>' +
|
||||
'</t>' +
|
||||
'</templates>' +
|
||||
|
||||
Reference in New Issue
Block a user