[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:
Sébastien Theys
2021-07-30 15:52:33 +00:00
parent eb1262960f
commit 810d5fd8aa
25 changed files with 810 additions and 172 deletions
@@ -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
*/
+5
View File
@@ -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',
+1 -1
View File
@@ -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,
+49 -71
View File
@@ -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'):
+30
View File
@@ -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.
@@ -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',
});
@@ -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();
}
}
@@ -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
+31 -27
View File
@@ -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;
}
+38 -32
View File
@@ -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`.
*
+11 -11
View File
@@ -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([
+16 -16
View File
@@ -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)
+1 -1
View File
@@ -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>' +