From 810d5fd8aa701c1412ef976bb859c39da0f5a950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 30 Jul 2021 15:52:33 +0000 Subject: [PATCH] [IMP] mail, im_livechat: add new UI for inviting members on channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/discuss/discuss_tests.js | 3 +- .../static/src/models/thread/thread.js | 9 + addons/mail/__manifest__.py | 5 + addons/mail/models/ir_attachment.py | 2 +- addons/mail/models/mail_channel.py | 120 ++++----- addons/mail/models/res_partner.py | 30 +++ .../use_component_to_model.js | 42 +++ .../channel_invitation_form.js | 75 ++++++ .../channel_invitation_form.scss | 51 ++++ .../channel_invitation_form.xml | 62 +++++ .../components/discuss/tests/discuss_tests.js | 7 +- .../thread_view_topbar/thread_view_topbar.js | 9 +- .../thread_view_topbar.scss | 23 ++ .../thread_view_topbar/thread_view_topbar.xml | 10 + .../channel_invitation_form.js | 253 ++++++++++++++++++ .../mail/static/src/models/discuss/discuss.js | 13 +- .../messaging_notification_handler.js | 27 +- .../mail/static/src/models/thread/thread.js | 58 ++-- .../src/models/thread_view/thread_view.js | 38 ++- .../thread_view_topbar/thread_view_topbar.js | 17 ++ .../mail/static/tests/helpers/mock_server.js | 70 ++--- addons/mail/tests/test_mail_channel.py | 22 +- .../mail/tests/test_mail_channel_partner.py | 32 +-- addons/mail/views/mail_channel_views.xml | 2 +- .../static/tests/legacy/views/kanban_tests.js | 2 +- 25 files changed, 810 insertions(+), 172 deletions(-) create mode 100644 addons/mail/static/src/component_hooks/use_component_to_model/use_component_to_model.js create mode 100644 addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.js create mode 100644 addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.scss create mode 100644 addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml create mode 100644 addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/discuss_tests.js index 270348184db..da14f7592e8 100644 --- a/addons/im_livechat/static/src/components/discuss/discuss_tests.js +++ b/addons/im_livechat/static/src/components/discuss/discuss_tests.js @@ -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( diff --git a/addons/im_livechat/static/src/models/thread/thread.js b/addons/im_livechat/static/src/models/thread/thread.js index ffa29f0fa87..a9f7b267fc4 100644 --- a/addons/im_livechat/static/src/models/thread/thread.js +++ b/addons/im_livechat/static/src/models/thread/thread.js @@ -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 */ diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 6f03f1a1a93..4fb1aead3b4 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -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', diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index ced20c98d36..7fe59ec8ee4 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -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, diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 1f6ce400937..9f42fde5f45 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -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 = _('
left #%s
', self.id, self.name) + notification = _('
left the channel
') # 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 = _('
joined the channel
') + else: + notification = _( + '
invited %(new_partner_name)s to the channel
', + 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 = _('
%(author)s invited %(new_partner)s to #%(channel_name)s
', - author=self.env.user.display_name, - new_partner=partner.display_name, - channel_id=channel.id, - channel_name=channel.name, - ) - else: - notification = _('
joined #%s
', 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 = _('
joined #%s
', 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'): diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index ed5c4af0e60..1dac2c273d4 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -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. diff --git a/addons/mail/static/src/component_hooks/use_component_to_model/use_component_to_model.js b/addons/mail/static/src/component_hooks/use_component_to_model/use_component_to_model.js new file mode 100644 index 00000000000..a31c95903a3 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_component_to_model/use_component_to_model.js @@ -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); + }; +} diff --git a/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.js b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.js new file mode 100644 index 00000000000..701e208549d --- /dev/null +++ b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.js @@ -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', +}); diff --git a/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.scss b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.scss new file mode 100644 index 00000000000..3d872d30d18 --- /dev/null +++ b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.scss @@ -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'); + } +} diff --git a/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml new file mode 100644 index 00000000000..1b331ad698f --- /dev/null +++ b/addons/mail/static/src/components/channel_invitation_form/channel_invitation_form.xml @@ -0,0 +1,62 @@ + + + + +
+

Invite people

+
+ +
+
+ +
+
+ Avatar + + + +
+
+ + + +
+ +
+ + +
No user found that is not already a member of this channel.
+
+ +
+ Showing results out of . Narrow your search to see more choices. +
+
+
+ +
+

Selected users:

+
+ + + +
+
+
+
+ +
+
+
+ + diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 6723267f64d..7ce1ab2d1c4 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -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) { diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js index 2ccaa4a7d80..ef19a5e01f0 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js @@ -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, diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss index 4e1a38a4846..dee5b2cc7da 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss @@ -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; } diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml index 751487cb5bf..4fd3f879932 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml @@ -37,6 +37,16 @@ + + + + + + + +
diff --git a/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js new file mode 100644 index 00000000000..67bdfbc72b9 --- /dev/null +++ b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js @@ -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); diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js index c294ebbcebb..a0981638c7d 100644 --- a/addons/mail/static/src/models/discuss/discuss.js +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -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(); } } diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index e4090c77909..60e7e3262ac 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -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 diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index ffc31090196..50a5f80a87a 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -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. diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js index 499c4ab493c..1f8ee77995c 100644 --- a/addons/mail/static/src/models/thread_view/thread_view.js +++ b/addons/mail/static/src/models/thread_view/thread_view.js @@ -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. */ diff --git a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js index 0c10340768b..1acbfb4310c 100644 --- a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js @@ -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; } diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index a9fbe24002e..30230820978 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -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 = ``; + 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 = ``; - 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`. * diff --git a/addons/mail/tests/test_mail_channel.py b/addons/mail/tests/test_mail_channel.py index c2846f55f99..05c211a7e15 100644 --- a/addons/mail/tests/test_mail_channel.py +++ b/addons/mail/tests/test_mail_channel.py @@ -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([ diff --git a/addons/mail/tests/test_mail_channel_partner.py b/addons/mail/tests/test_mail_channel_partner.py index ae16dd4195d..e777a13f5c5 100644 --- a/addons/mail/tests/test_mail_channel_partner.py +++ b/addons/mail/tests/test_mail_channel_partner.py @@ -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) diff --git a/addons/mail/views/mail_channel_views.xml b/addons/mail/views/mail_channel_views.xml index e24cc7b697c..44580b87918 100644 --- a/addons/mail/views/mail_channel_views.xml +++ b/addons/mail/views/mail_channel_views.xml @@ -27,7 +27,7 @@

- + diff --git a/addons/web/static/tests/legacy/views/kanban_tests.js b/addons/web/static/tests/legacy/views/kanban_tests.js index 62f4bbe4088..8eac85a0dd5 100644 --- a/addons/web/static/tests/legacy/views/kanban_tests.js +++ b/addons/web/static/tests/legacy/views/kanban_tests.js @@ -5568,7 +5568,7 @@ QUnit.module('Views', { '' + '
' + '' + - '' + + '' + '
' + '
' + '
' +