From d9c25be92f18ddffd557cd1815bafb23cc644278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Mon, 25 Apr 2022 13:50:14 +0000 Subject: [PATCH] [IMP] mail: introduce Channel and ChannelMember model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task-2832456 closes odoo/odoo#91918 Signed-off-by: Sébastien Theys (seb) --- addons/mail/models/mail_channel.py | 22 +++- .../channel_member/channel_member.js | 10 +- .../channel_member/channel_member.xml | 14 +-- .../channel_member_list.js | 2 +- .../channel_member_list.xml | 4 +- .../channel_member_list_category.xml | 2 +- .../src/model/model_field_relation_set.js | 10 +- addons/mail/static/src/models/channel.js | 99 +++++++++++++++++ .../mail/static/src/models/channel_member.js | 63 +++++++++++ .../channel_member_list_category_view.js | 8 +- .../src/models/channel_member_list_view.js | 17 ++- .../static/src/models/channel_member_view.js | 26 ++++- addons/mail/static/src/models/partner.js | 2 +- addons/mail/static/src/models/thread.js | 104 +++--------------- .../mail/static/tests/helpers/mock_server.js | 34 ++++++ .../components/channel_member_list_tests.js | 65 ++++++++++- 16 files changed, 351 insertions(+), 131 deletions(-) create mode 100644 addons/mail/static/src/models/channel.js create mode 100644 addons/mail/static/src/models/channel_member.js diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 0f4c702d999..2815bb3d69a 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -1169,12 +1169,24 @@ class Channel(models.Model): def load_more_members(self, known_member_ids): self.ensure_one() - partners = self.env['res.partner'].with_context(active_test=False).search_read( - domain=[('id', 'not in', known_member_ids), ('channel_ids', 'in', self.id)], - fields=['id', 'name', 'im_status'], - limit=30 + channel_partners = self.env['mail.channel.partner'].search( + domain=[('id', 'not in', known_member_ids), ('channel_id', '=', self.id)], + limit=100 ) - return [('insert', partners)] + count = self.env['mail.channel.partner'].search_count( + domain=[('channel_id', '=', self.id)], + ) + return { + 'channelMembers': [('insert', [{ + 'id': member.id, + 'partner': [('insert', { + 'id': member.partner_id.id, + 'name': member.partner_id.name, + 'im_status': member.partner_id.im_status, + })] + } for member in channel_partners])], + 'memberCount': count, + } def _get_avatar_cache_key(self): if not self.avatar_128: diff --git a/addons/mail/static/src/components/channel_member/channel_member.js b/addons/mail/static/src/components/channel_member/channel_member.js index 91a6d439def..c256d5acee0 100644 --- a/addons/mail/static/src/components/channel_member/channel_member.js +++ b/addons/mail/static/src/components/channel_member/channel_member.js @@ -6,17 +6,10 @@ const { Component } = owl; export class ChannelMember extends Component { - /** - * @returns {Thread} - */ - get channel() { - return this.props.channel; - } - /** * @returns {ChannelMemberView} */ - get record() { + get channelMemberView() { return this.props.record; } @@ -24,7 +17,6 @@ export class ChannelMember extends Component { Object.assign(ChannelMember, { props: { - channel: Object, record: Object, }, template: 'mail.ChannelMember', diff --git a/addons/mail/static/src/components/channel_member/channel_member.xml b/addons/mail/static/src/components/channel_member/channel_member.xml index 657661de698..0468595565a 100644 --- a/addons/mail/static/src/components/channel_member/channel_member.xml +++ b/addons/mail/static/src/components/channel_member/channel_member.xml @@ -2,23 +2,23 @@ -
-
- Avatar +
+
+ Avatar - +
- - + +
diff --git a/addons/mail/static/src/components/channel_member_list/channel_member_list.js b/addons/mail/static/src/components/channel_member_list/channel_member_list.js index 23538762039..48f0fed485b 100644 --- a/addons/mail/static/src/components/channel_member_list/channel_member_list.js +++ b/addons/mail/static/src/components/channel_member_list/channel_member_list.js @@ -9,7 +9,7 @@ export class ChannelMemberList extends Component { /** * @returns {ChannelMemberListView} */ - get channelMemberListView() { + get channelMemberListView() { return this.props.record; } diff --git a/addons/mail/static/src/components/channel_member_list/channel_member_list.xml b/addons/mail/static/src/components/channel_member_list/channel_member_list.xml index 9166b7d197f..884941b1583 100644 --- a/addons/mail/static/src/components/channel_member_list/channel_member_list.xml +++ b/addons/mail/static/src/components/channel_member_list/channel_member_list.xml @@ -16,9 +16,9 @@ And other members. - +
- +
diff --git a/addons/mail/static/src/components/channel_member_list_category/channel_member_list_category.xml b/addons/mail/static/src/components/channel_member_list_category/channel_member_list_category.xml index c60c1fb0ba9..1192855cb61 100644 --- a/addons/mail/static/src/components/channel_member_list_category/channel_member_list_category.xml +++ b/addons/mail/static/src/components/channel_member_list_category/channel_member_list_category.xml @@ -4,7 +4,7 @@
- + diff --git a/addons/mail/static/src/model/model_field_relation_set.js b/addons/mail/static/src/model/model_field_relation_set.js index ad59caf59f2..9ebc49c01ac 100644 --- a/addons/mail/static/src/model/model_field_relation_set.js +++ b/addons/mail/static/src/model/model_field_relation_set.js @@ -3,6 +3,7 @@ import { decrement, increment } from '@mail/model/model_field_command'; import { Listener } from '@mail/model/model_listener'; import { followRelations } from '@mail/model/model_utils'; +import { cleanSearchTerm } from '@mail/utils/utils'; /** * Defines a set containing the relation records of the given field on the given @@ -79,11 +80,14 @@ export class RelationSet { } break; } - case 'case-insensitive-asc': - if (valA.toLowerCase() === valB.toLowerCase()) { + case 'case-insensitive-asc': { + const cleanedValA = cleanSearchTerm(valA); + const cleanedValB = cleanSearchTerm(valB); + if (cleanedValA === cleanedValB) { break; } - return valA.toLowerCase() < valB.toLowerCase() ? -1 : 1; + return cleanedValA < cleanedValB ? -1 : 1; + } case 'smaller-first': if (valA === valB) { break; diff --git a/addons/mail/static/src/models/channel.js b/addons/mail/static/src/models/channel.js new file mode 100644 index 00000000000..62044776413 --- /dev/null +++ b/addons/mail/static/src/models/channel.js @@ -0,0 +1,99 @@ +/** @odoo-module **/ + +import { registerModel } from '@mail/model/model_core'; +import { attr, one, many } from '@mail/model/model_field'; +import { insertAndReplace } from '@mail/model/model_field_command'; + +registerModel({ + name: 'Channel', + identifyingFields: ['id'], + recordMethods: { + async fetchChannelMembers() { + const channelData = await this.messaging.rpc({ + model: 'mail.channel', + method: 'load_more_members', + args: [[this.id]], + kwargs: { + known_member_ids: this.channelMembers.map(channelMember => channelMember.id), + }, + }); + if (!this.exists()) { + return; + } + this.update(channelData); + }, + /** + * @private + * @returns {boolean} + */ + _computeAreAllMembersLoaded() { + return this.memberCount === this.channelMembers.length; + }, + /** + * @private + * @returns {FieldCommand} + */ + _computeThread() { + return insertAndReplace({ + id: this.id, + model: 'mail.channel', + }); + }, + /** + * @private + * @returns {integer} + */ + _computeUnknownMemberCount() { + return this.memberCount - this.channelMembers.length; + }, + /** + * @private + * @returns {Array[]} + */ + _sortMembers() { + return [ + ['defined-first', 'name'], + ['case-insensitive-asc', 'name'], + ]; + }, + }, + fields: { + areAllMembersLoaded: attr({ + compute: '_computeAreAllMembersLoaded', + }), + channelMembers: many('ChannelMember', { + inverse: 'channel', + isCausal: true, + }), + id: attr({ + readonly: true, + required: true, + }), + memberCount: attr({ + related: 'thread.memberCount', + }), + orderedOfflineMembers: many('ChannelMember', { + inverse: 'channelAsOfflineMember', + sort: '_sortMembers', + }), + orderedOnlineMembers: many('ChannelMember', { + inverse: 'channelAsOnlineMember', + sort: '_sortMembers', + }), + thread: one('Thread', { + compute: '_computeThread', + inverse: 'channel', + isCausal: true, + readonly: true, + required: true, + }), + /** + * States how many members are currently unknown on the client side. + * This is the difference between the total number of members of the + * channel as reported in memberCount and those actually in members. + */ + unknownMemberCount: attr({ + compute: '_computeUnknownMemberCount', + }), + }, +}); diff --git a/addons/mail/static/src/models/channel_member.js b/addons/mail/static/src/models/channel_member.js new file mode 100644 index 00000000000..747fe126e91 --- /dev/null +++ b/addons/mail/static/src/models/channel_member.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { registerModel } from '@mail/model/model_core'; +import { attr, one, many } from '@mail/model/model_field'; +import { clear, replace } from '@mail/model/model_field_command'; + +registerModel({ + name: 'ChannelMember', + identifyingFields: ['id'], + recordMethods: { + /** + * @private + * @returns {FieldCommand} + */ + _computeChannelAsOfflineMember() { + return this.partner && !this.partner.isOnline ? replace(this.channel) : clear(); + }, + /** + * @private + * @returns {FieldCommand} + */ + _computeChannelAsOnlineMember() { + return this.partner && this.partner.isOnline ? replace(this.channel) : clear(); + }, + /** + * @private + * @returns {string} + */ + _computeName() { + return this.partner.nameOrDisplayName; + }, + }, + fields: { + channel: one('Channel', { + inverse: 'channelMembers', + readonly: true, + required: true, + }), + channelAsOfflineMember: one('Channel', { + compute: '_computeChannelAsOfflineMember', + inverse: 'orderedOfflineMembers', + }), + channelAsOnlineMember: one('Channel', { + compute: '_computeChannelAsOnlineMember', + inverse: 'orderedOnlineMembers', + }), + channelMemberViews: many('ChannelMemberView', { + inverse: 'channelMember', + isCausal: true, + }), + id: attr({ + readonly: true, + required: true, + }), + name: attr({ + compute: '_computeName', + }), + partner: one('Partner', { + inverse: 'channelMembers', + readonly: true, + }), + }, +}); diff --git a/addons/mail/static/src/models/channel_member_list_category_view.js b/addons/mail/static/src/models/channel_member_list_category_view.js index 47a82e0021e..fbf88197ff1 100644 --- a/addons/mail/static/src/models/channel_member_list_category_view.js +++ b/addons/mail/static/src/models/channel_member_list_category_view.js @@ -26,12 +26,12 @@ registerModel({ * @private * @returns {FieldCommand} */ - _computeChannelMemberViews() { + _computeChannelMemberViews() { if (this.members.length === 0) { return clear(); } return insertAndReplace( - this.members.map(member => ({ partner: replace(member) })), + this.members.map(member => ({ channelMember: replace(member) })), ); }, /** @@ -72,7 +72,7 @@ registerModel({ }, }, fields: { - channel: one('Thread', { + channel: one('Channel', { compute: '_computeChannel', required: true, }), @@ -89,7 +89,7 @@ registerModel({ inverse: 'channelMemberListCategoryViewOwner', isCausal: true, }), - members: many('Partner', { + members: many('ChannelMember', { compute: '_computeMembers', }), title: attr({ diff --git a/addons/mail/static/src/models/channel_member_list_view.js b/addons/mail/static/src/models/channel_member_list_view.js index c52bd06de70..5bbe728cd7e 100644 --- a/addons/mail/static/src/models/channel_member_list_view.js +++ b/addons/mail/static/src/models/channel_member_list_view.js @@ -7,17 +7,28 @@ import { clear, insertAndReplace, replace } from '@mail/model/model_field_comman registerModel({ name: 'ChannelMemberListView', identifyingFields: [['chatWindowOwner', 'threadViewOwner']], + lifecycleHooks: { + _created() { + this.channel.fetchChannelMembers(); + }, + }, recordMethods: { + /** + * Handles click on the "load more members" button. + */ + async onClickLoadMoreMembers() { + this.channel.fetchChannelMembers(); + }, /** * @private * @returns {FieldCommand} */ _computeChannel() { if (this.chatWindowOwner) { - return replace(this.chatWindowOwner.thread); + return replace(this.chatWindowOwner.thread.channel); } if (this.threadViewOwner) { - return replace(this.threadViewOwner.thread); + return replace(this.threadViewOwner.thread.channel); } return clear(); }, @@ -43,7 +54,7 @@ registerModel({ }, }, fields: { - channel: one('Thread', { + channel: one('Channel', { compute: '_computeChannel', readonly: true, }), diff --git a/addons/mail/static/src/models/channel_member_view.js b/addons/mail/static/src/models/channel_member_view.js index 5e1e1909445..58db969bec5 100644 --- a/addons/mail/static/src/models/channel_member_view.js +++ b/addons/mail/static/src/models/channel_member_view.js @@ -5,14 +5,36 @@ import { one } from '@mail/model/model_field'; registerModel({ name: 'ChannelMemberView', - identifyingFields: ['channelMemberListCategoryViewOwner', 'partner'], + identifyingFields: ['channelMemberListCategoryViewOwner', 'channelMember'], + recordMethods: { + /** + * Handles click on the avatar of the channel member in the member list + * of this channel. + */ + onClickMemberAvatar() { + if (!this.channelMember.partner) { + return; + } + this.channelMember.partner.openChat(); + }, + /** + * Handles click on the name of the channel member in the member list of + * this channel. + */ + onClickMemberName() { + if (!this.channelMember.partner) { + return; + } + this.channelMember.partner.openProfile(); + }, + }, fields: { channelMemberListCategoryViewOwner: one('ChannelMemberListCategoryView', { inverse: 'channelMemberViews', readonly: true, required: true, }), - partner: one('Partner', { + channelMember: one('ChannelMember', { inverse: 'channelMemberViews', readonly: true, required: true, diff --git a/addons/mail/static/src/models/partner.js b/addons/mail/static/src/models/partner.js index 1e7bb3949c8..29e9df7885c 100644 --- a/addons/mail/static/src/models/partner.js +++ b/addons/mail/static/src/models/partner.js @@ -376,7 +376,7 @@ registerModel({ inverse: 'partner', isCausal: true, }), - channelMemberViews: many('ChannelMemberView', { + channelMembers: many('ChannelMember', { inverse: 'partner', isCausal: true, }), diff --git a/addons/mail/static/src/models/thread.js b/addons/mail/static/src/models/thread.js index 5097d693209..6b022af6820 100644 --- a/addons/mail/static/src/models/thread.js +++ b/addons/mail/static/src/models/thread.js @@ -797,24 +797,6 @@ registerModel({ pinned: this.isPendingPinned, }); }, - /** - * Handles click on the avatar of the given member in the member list of - * this channel. - * - * @param {Partner} member - */ - onClickMemberAvatar(member) { - member.openChat(); - }, - /** - * Handles click on the name of the given member in the member list of - * this channel. - * - * @param {Partner} member - */ - onClickMemberName(member) { - member.openProfile(); - }, /** * Opens this thread either as form view, in discuss app, or as a chat * window. The thread will be opened in an "active" matter, which will @@ -1053,6 +1035,13 @@ registerModel({ }); return replace(allAttachments); }, + /** + * @private + * @returns {FieldCommand} + */ + _computeChannel() { + return this.model === 'mail.channel' ? insertAndReplace({ id: this.id }) : clear(); + }, /** * @private * @returns {FieldCommand} @@ -1468,20 +1457,6 @@ registerModel({ } return replace(message); }, - /** - * @private - * @returns {Partner[]} - */ - _computeOrderedOfflineMembers() { - return replace(this._sortMembers(this.members.filter(member => !member.isOnline))); - }, - /** - * @private - * @returns {Partner[]} - */ - _computeOrderedOnlineMembers() { - return replace(this._sortMembers(this.members.filter(member => member.isOnline))); - }, /** * @private * @returns {Message[]} @@ -1565,13 +1540,6 @@ registerModel({ this.getMemberName(this.orderedOtherTypingMembers[1]) ); }, - /** - * @private - * @returns {integer} - */ - _computeUnknownMemberCount() { - return this.memberCount - this.members.length; - }, /** * Compute an url string that can be used inside a href attribute * @@ -1701,44 +1669,12 @@ registerModel({ ['case-insensitive-asc', 'nameOrDisplayName'], ]; }, - /** - * @private - * @param {Partner[]} members - * @returns {Partner[]} - */ - _sortMembers(members) { - return [...members].sort((a, b) => { - const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || ''); - const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || ''); - if (cleanedAName < cleanedBName) { - return -1; - } - if (cleanedAName > cleanedBName) { - return 1; - } - return a.id - b.id; - }); - }, /** * Event handler for clicking thread in discuss app. */ async onClick() { await this.open(); }, - /** - * Handles click on the "load more members" button. - */ - async onClickLoadMoreMembers() { - const members = await this.messaging.rpc({ - model: 'mail.channel', - method: 'load_more_members', - args: [[this.id]], - kwargs: { - 'known_member_ids': this.members.map(partner => partner.id), - }, - }); - this.update({ members }); - }, onCurrentPartnerInactiveTypingTimeout() { this.unregisterCurrentPartnerIsTyping(); }, @@ -1785,6 +1721,12 @@ registerModel({ readonly: true, required: true, }), + channel: one('Channel', { + compute: '_computeChannel', + inverse: 'thread', + isCausal: true, + readonly: true, + }), channel_type: attr(), /** * States the chat window related to this thread (if any). @@ -2174,18 +2116,6 @@ registerModel({ needactionMessagesAsOriginThread: many('Message', { compute: '_computeNeedactionMessagesAsOriginThread', }), - /** - * All offline members ordered like they are displayed. - */ - orderedOfflineMembers: many('Partner', { - compute: '_computeOrderedOfflineMembers', - }), - /** - * All online members ordered like they are displayed. - */ - orderedOnlineMembers: many('Partner', { - compute: '_computeOrderedOnlineMembers', - }), /** * All messages ordered like they are displayed. */ @@ -2385,14 +2315,6 @@ registerModel({ compute: '_computeTypingStatusText', default: '', }), - /** - * States how many members are currently unknown on the client side. - * This is the difference between the total number of members of the - * channel as reported in memberCount and those actually in members. - */ - unknownMemberCount: attr({ - compute: '_computeUnknownMemberCount', - }), /** * URL to access to the conversation. */ diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index a88f8df03fe..d6123b353a3 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -219,6 +219,11 @@ patch(MockServer.prototype, 'mail', { const ids = args.args[0]; return this._mockMailChannelWriteImage128(ids[0]); } + if (args.model === 'mail.channel' && args.method === 'load_more_members') { + const [channel_ids] = args.args; + const { known_member_ids } = args.kwargs; + return this._mockMailChannelLoadMoreMembers(channel_ids, known_member_ids); + } // mail.message methods if (args.model === 'mail.message' && args.method === 'mark_all_as_read') { const domain = args.args[0] || args.kwargs.domain; @@ -1286,6 +1291,35 @@ patch(MockServer.prototype, 'mail', { } return messageData; }, + /** + * Simulates `load_more_members` on `mail.channel`. + * + * @private + * @param {integer[]} channel_ids + * @param {integer[]} known_member_ids + */ + _mockMailChannelLoadMoreMembers(channel_ids, known_member_ids) { + const channelPartners = this.pyEnv['mail.channel.partner'].searchRead([ + ['id', 'not in', known_member_ids], + ['channel_id', 'in', channel_ids], + ], { limit: 100 }); + const memberCount = this.pyEnv['mail.channel.partner'].searchCount([ + ['channel_id', 'in', channel_ids], + ]); + return { + channelMembers: [['insert', channelPartners.map(channelPartner => { + const [partner] = this.pyEnv['res.partner'].searchRead( + [['id', '=', channelPartner.partner_id[0]]], + { fields: ['id', 'name', 'im_status'] } + ); + return { + id: channelPartner.id, + partner: [['insert', partner]], + }; + })]], + memberCount, + }; + }, /** * Simulates `notify_typing` on `mail.channel`. * diff --git a/addons/mail/static/tests/qunit_suite_tests/components/channel_member_list_tests.js b/addons/mail/static/tests/qunit_suite_tests/components/channel_member_list_tests.js index c1976dbb7c0..8e0f3826138 100644 --- a/addons/mail/static/tests/qunit_suite_tests/components/channel_member_list_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/components/channel_member_list_tests.js @@ -7,7 +7,7 @@ import { QUnit.module('mail', {}, function () { QUnit.module('components', {}, function () { -QUnit.module('discuss_tests.js', { +QUnit.module('channel_member_list_tests.js', { beforeEach() { this.start = async params => { return start(Object.assign({}, params, { @@ -113,7 +113,7 @@ QUnit.test('should have correct members in member list', async function (assert) }"]`, "should have 'Demo' in member list ('Demo' is a member)", ); - }); +}); QUnit.test('there should be a button to hide member list in the thread view topbar when the member list is visible', async function (assert) { assert.expect(1); @@ -143,5 +143,66 @@ QUnit.test('there should be a button to hide member list in the thread view topb ); }); +QUnit.test('should show a button to load more members if they are not all loaded', async function (assert) { + assert.expect(1); + + const pyEnv = await startServer(); + const channel_last_seen_partner_ids = [[0, 0, { partner_id: pyEnv.currentPartnerId }]]; + for (let i = 0; i < 101; i++) { + const resPartnerId = pyEnv['res.partner'].create({ name: "name" + i }); + channel_last_seen_partner_ids.push([0, 0, { partner_id: resPartnerId }]); + } + const mailChannelId = pyEnv['mail.channel'].create({ + channel_last_seen_partner_ids, + channel_type: 'group', + public: 'private', + }); + const { click } = await this.start({ + discuss: { + context: { + active_id: `mail.channel_${mailChannelId}`, + }, + }, + }); + await click('.o_ThreadViewTopbar_showMemberListButton'); + assert.containsOnce( + document.body, + '.o_ChannelMemberList_loadMoreButton', + "should have a load more button because 100 members were fetched initially and there are 102 members in total", + ); +}); + + +QUnit.test('Load more button should load more members', async function (assert) { + assert.expect(1); + + const pyEnv = await startServer(); + const channel_last_seen_partner_ids = [[0, 0, { partner_id: pyEnv.currentPartnerId }]]; + for (let i = 0; i < 101; i++) { + const resPartnerId = pyEnv['res.partner'].create({ name: "name" + i }); + channel_last_seen_partner_ids.push([0, 0, { partner_id: resPartnerId }]); + } + const mailChannelId = pyEnv['mail.channel'].create({ + channel_last_seen_partner_ids, + channel_type: 'group', + public: 'private', + }); + const { click } = await this.start({ + discuss: { + context: { + active_id: `mail.channel_${mailChannelId}`, + }, + }, + }); + await click('.o_ThreadViewTopbar_showMemberListButton'); + await click('.o_ChannelMemberList_loadMoreButton'); + assert.containsN( + document.body, + '.o_ChannelMember', + 102, + "should load all the members because 100 members were fetched initially, and load more fetched the remaining 2 members", + ); +}); + }); });