[FIX] mail: fetch followers 100 at a time

Before this commit, the data of all followers of a chatter were
fetched whenever `mail/thread/data` was called. This route is
called when loading the chatter for the first time, and whenever
the chatter must be reloaded.

When there are at most dozens of followers, it's not a big deal.
However, with thousand of followers, the route `mail/thread/data`
may become quite long, which made chatter barely usable with a
long loading.

This commit fixes the issue by showing only 100 followers at once
when opening the follower menu. When scrolling down the list,
it loads data of more followers.

Task-3293718

closes odoo/odoo#127966

Signed-off-by: Didier Debondt (did) <did@odoo.com>
This commit is contained in:
Alexandre Kühn
2023-07-17 18:13:11 +02:00
parent 07f04e09f3
commit 8dc9d12905
19 changed files with 313 additions and 87 deletions
+11
View File
@@ -508,3 +508,14 @@ GROUP BY fol.id%s%s""" % (
update[fol_id] = {'subtype_ids': update_cmd}
return new, update
def _format_for_chatter(self):
return [{
'id': follower.id,
'partner_id': follower.partner_id.id,
'name': follower.name,
'display_name': follower.display_name,
'email': follower.email,
'is_active': follower.is_active,
'partner': follower.partner_id.mail_partner_format()[follower.partner_id],
} for follower in self]
+21 -9
View File
@@ -3988,6 +3988,16 @@ class MailThread(models.AbstractModel):
return True
def message_get_followers(self, after=None, limit=100):
self.ensure_one()
domain = [
("res_id", "=", self.id),
("res_model", "=", self._name),
]
if after:
domain = expression.AND([domain, [('id', '>', after)]])
return self.env["mail.followers"].search(domain, limit=limit, order='id ASC')._format_for_chatter()
# ------------------------------------------------------
# THREAD MESSAGE UPDATE
# ------------------------------------------------------
@@ -4146,15 +4156,17 @@ class MailThread(models.AbstractModel):
if 'attachments' in request_list:
res['attachments'] = self._get_mail_thread_data_attachments()._attachment_format()
if 'followers' in request_list:
res['followers'] = [{
'id': follower.id,
'partner_id': follower.partner_id.id,
'name': follower.name,
'display_name': follower.display_name,
'email': follower.email,
'is_active': follower.is_active,
'partner': follower.partner_id.mail_partner_format()[follower.partner_id],
} for follower in self.message_follower_ids]
res['followersCount'] = self.env['mail.followers'].search_count([
("res_id", "=", self.id),
("res_model", "=", self._name)
])
self_follower = self.env['mail.followers'].search([
("res_id", "=", self.id),
("res_model", "=", self._name),
['partner_id', '=', self.env.user.partner_id.id]
])._format_for_chatter()
res['selfFollower'] = self_follower[0] if len(self_follower) > 0 else None
res['followers'] = self.message_get_followers()
if 'suggestedRecipients' in request_list:
res['suggestedRecipients'] = self._message_get_suggested_recipients()[self.id]
return res
@@ -242,7 +242,7 @@ export class Message extends Component {
get showUnfollow() {
return Boolean(
this.message.originThread?.followerOfSelf && this.props.thread?.model === "mail.box"
this.message.originThread?.selfFollower && this.props.thread?.model === "mail.box"
);
}
@@ -158,7 +158,7 @@ export class MessageService {
await this.setDone(message);
}
const thread = message.originThread;
await this.env.services["mail.thread"].removeFollower(thread.followerOfSelf);
await this.env.services["mail.thread"].removeFollower(thread.selfFollower);
this.env.services.notification.add(
sprintf(_t('You are no longer following "%(thread_name)s".'), {
thread_name: thread.name,
@@ -327,7 +327,7 @@ export class MessageService {
)
);
if ("user_follower_id" in data && data.user_follower_id && this.store.self) {
this.env.services["mail.thread"].insertFollower({
message.originThread.selfFollower = this.env.services["mail.thread"].insertFollower({
followedThread: message.originThread,
id: data.user_follower_id,
isActive: true,
@@ -64,14 +64,13 @@ export class NavigableList extends Component {
return Boolean(this.state.open && (this.state.isLoading || this.state.options.length));
}
open() {
async open() {
if (this.state.isLoading) {
return;
}
this.load().then(() => {
this.state.open = true;
this.navigate("first");
});
await this.load();
this.state.open = true;
this.navigate("first");
}
close() {
@@ -81,23 +80,21 @@ export class NavigableList extends Component {
async load() {
this.state.options = [];
const makeOption = (opt) => {
return Object.assign(Object.create(opt), {
id: this.state.options.length,
});
};
if (this.props.options instanceof Promise) {
this.state.isLoading = true;
return this.props.options.then((opts) => {
opts.forEach((opt) => this.state.options.push(makeOption(opt)));
this.state.isLoading = false;
});
const options = await this.props.options;
this.state.options = options.map((option, index) => ({
...option,
id: index,
}));
this.state.isLoading = false;
return;
}
if (this.props.options instanceof Array) {
if (this.props.options.length === 0) {
return;
}
this.props.options.forEach((opt) => this.state.options.push(makeOption(opt)));
this.state.options = this.props.options.map((option, index) => ({
...option,
id: index,
}));
}
}
@@ -53,6 +53,10 @@ export class Thread {
description;
/** @type {Set<import("@mail/core/common/follower_model").Follower>} */
followers = new Set();
/** @type {import("@mail/core/common/follower_model").Follower} */
selfFollower;
/** @type {integer|undefined} */
followersCount;
isAdmin = false;
loadOlder = false;
loadNewer = false;
@@ -160,6 +164,13 @@ export class Thread {
return this.memberCount === this.channelMembers.length;
}
get followersFullyLoaded() {
return (
this.followersCount ===
(this.selfFollower ? this.followers.size + 1 : this.followers.size)
);
}
get attachmentsInWebClientView() {
const attachments = this.attachments.filter(
(attachment) => (attachment.isPdf || attachment.isImage) && !attachment.uploading
@@ -252,18 +263,6 @@ export class Thread {
return undefined;
}
/**
* @returns {import("@mail/core/common/follower_model").Follower}
*/
get followerOfSelf() {
for (const follower of this.followers) {
if (follower.partner === this._store.self) {
return follower;
}
}
return undefined;
}
get imgUrl() {
return this.module_icon ?? "/mail/static/src/img/smiley/avatar.jpg";
}
+11 -5
View File
@@ -10,6 +10,7 @@ import { Activity } from "@mail/core/web/activity";
import { SuggestedRecipientsList } from "@mail/core/web/suggested_recipient_list";
import { useHover, useScrollPosition } from "@mail/utils/common/hooks";
import { isDragSourceExternalFile } from "@mail/utils/common/misc";
import { RecipientList } from "./recipient_list";
import { FollowerList } from "./follower_list";
import {
@@ -32,7 +33,6 @@ import { useService } from "@web/core/utils/hooks";
import { escape } from "@web/core/utils/strings";
import { useThrottleForAnimation } from "@web/core/utils/timing";
import { FileUploader } from "@web/views/fields/file_handler";
import { RecipientList } from "./recipient_list";
/**
* @typedef {Object} Props
@@ -203,19 +203,25 @@ export class Chatter extends Component {
* @returns {string}
*/
get toRecipientsText() {
const followers = [...this.state.thread.followers].slice(0, 5).map(({ partner }) => {
const allFollowers = [];
if (this.state.thread.selfFollower) {
allFollowers.push(this.state.thread.selfFollower);
}
allFollowers.push(...this.state.thread.followers);
const followers = allFollowers.slice(0, 5).map(({ partner }) => {
if (partner === this.store.self) {
return `<span class="text-muted" title="${escape(partner.email)}">me</span>`;
}
const text = partner.email ? partner.emailWithoutDomain : partner.name;
return `<span class="text-muted" title="${escape(partner.email)}">${escape(
partner.emailWithoutDomain
text
)}</span>`;
});
const formatter = new Intl.ListFormat(
this.store.env.services["user"].lang?.replace("_", "-"),
{ type: "unit" }
);
if (this.state.thread.followers.size > 5) {
if (allFollowers.length > 5) {
followers.push("…");
}
return markup(formatter.format(followers));
@@ -264,7 +270,7 @@ export class Chatter extends Component {
}
async onClickUnfollow() {
await this.threadService.removeFollower(this.state.thread.followerOfSelf);
await this.threadService.removeFollower(this.state.thread.selfFollower);
this.onFollowerChanged();
}
+4 -3
View File
@@ -35,13 +35,14 @@
<Dropdown t-if="props.hasFollowers" position="'bottom-end'" disabled="isDisabled" class="'o-mail-Followers d-flex me-1'" menuClass="'o-mail-Followers-dropdown flex-column px-0 py-1'" menuDisplay="'d-flex'" title="followerButtonLabel" togglerClass="'o-mail-Followers-button btn btn-link text-action px-1 ' + (props.compactHeight ? '' : 'my-2')">
<t t-set-slot="toggler">
<i class="fa fa-user-o" role="img"/>
<span class="o-mail-Followers-counter ps-1" t-esc="state.thread.followers.size"/>
<i t-if="state.thread.followersCount === undefined" class="fa fa-circle-o-notch fa-spin"/>
<span t-else="" class="o-mail-Followers-counter ps-1" t-esc="state.thread.followersCount"/>
</t>
<t t-set-slot="default">
<FollowerList onAddFollowers.bind="onAddFollowers" onFollowerChanged.bind="onFollowerChanged" thread="state.thread"/>
</t>
</Dropdown>
<button t-if="props.hasFollowers and state.thread.followerOfSelf" class="btn px-0" t-att-class="{ 'text-success': !unfollowHover.isHover, 'text-warning': unfollowHover.isHover, 'my-2': !props.compactHeight }" t-att-disabled="!props.threadId" t-on-click="onClickUnfollow" t-ref="unfollow">
<button t-if="props.hasFollowers and state.thread.selfFollower" class="btn px-0" t-att-class="{ 'text-success': !unfollowHover.isHover, 'text-warning': unfollowHover.isHover, 'my-2': !props.compactHeight }" t-att-disabled="!props.threadId" t-on-click="onClickUnfollow" t-ref="unfollow">
<div class="position-relative">
<!-- Hidden element used to set the button maximum size -->
<span class="d-flex invisible text-nowrap">
@@ -52,7 +53,7 @@
</span>
</div>
</button>
<button t-if="!state.thread.followerOfSelf" class="o-mail-Chatter-follow btn btn-link px-0 text-600" t-on-click="onClickFollow">
<button t-if="!state.thread.selfFollower" class="o-mail-Chatter-follow btn btn-link px-0 text-600" t-on-click="onClickFollow">
<div class="position-relative">
<span class="d-flex invisible text-nowrap">
<i class="me-1 fa fa-fw fa-eye-slash"/><t t-out="followingText.length > unfollowText.length ? followingText : unfollowText"/>
@@ -5,6 +5,7 @@ import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { FollowerSubtypeDialog } from "./follower_subtype_dialog";
import { useMessaging } from "../common/messaging_hook";
import { useVisible } from "@mail/utils/common/hooks";
/**
* @typedef {Object} Props
@@ -22,6 +23,11 @@ export class FollowerList extends Component {
this.messaging = useMessaging();
/** @type {import("@mail/core/common/thread_service").ThreadService} */
this.threadService = useState(useService("mail.thread"));
this.loadMoreState = useVisible("load-more", () => {
if (this.loadMoreState.isVisible) {
this.threadService.loadMoreFollowers(this.props.thread);
}
});
}
onClickAddFollowers() {
@@ -8,25 +8,40 @@
</a>
<div t-if="props.thread.followers.size > 0" role="separator" class="dropdown-divider"/>
</t>
<t t-if="props.thread.selfFollower">
<t t-call="mail.Follower">
<t t-set="follower" t-value="props.thread.selfFollower"/>
</t>
<div t-if="props.thread.followers.size > 0" role="separator" class="dropdown-divider"/>
</t>
<t t-if="props.thread.followers.size > 0">
<div t-foreach="[...props.thread.followers]" t-as="follower" t-key="follower.id" class="dropdown-item o-mail-Follower d-flex justify-content-between p-0">
<a class="o-mail-Follower-details d-flex align-items-center flex-grow-1 px-3 text-700 o-min-width-0" t-att-class="{ 'o-inactive fst-italic opacity-25': !follower.isActive }" href="#" role="menuitem" t-on-click.prevent="(ev) => this.onClickDetails(ev, follower)">
<img class="o-mail-Follower-avatar me-2 rounded" t-att-src="threadService.avatarUrl(follower.partner, props.thread)" alt=""/>
<span class="flex-shrink text-truncate" t-esc="follower.partner.name"/>
</a>
<t t-if="follower.isEditable">
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Edit subscription" aria-label="Edit subscription" t-on-click="(ev) => this.onClickEdit(ev, follower)">
<i class="fa fa-pencil"/>
</button>
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Remove this follower" aria-label="Remove this follower" t-on-click="(ev) => this.onClickRemove(ev, follower)">
<i class="fa fa-remove"/>
</button>
<t t-foreach="[...props.thread.followers]" t-as="follower" t-key="follower.id">
<t t-call="mail.Follower">
<t t-set="follower" t-value="follower"/>
</t>
</div>
</t>
<span t-if="!props.thread.followersFullyLoaded" class="btn btn-link" t-on-click="() => threadService.loadMoreFollowers(props.thread)" t-ref="load-more">Load more</span>
</t>
<div t-elif="!props.thread.hasWriteAccess" class="dropdown-item disabled">
No Followers
</div>
</t>
<t t-name="mail.Follower" owl="1">
<div class="dropdown-item o-mail-Follower d-flex justify-content-between p-0">
<a class="o-mail-Follower-details d-flex align-items-center flex-grow-1 px-3 text-700 o-min-width-0" t-att-class="{ 'o-inactive fst-italic opacity-25': !follower.isActive }" href="#" role="menuitem" t-on-click.prevent="(ev) => this.onClickDetails(ev, follower)">
<img class="o-mail-Follower-avatar me-2 rounded" t-att-src="threadService.avatarUrl(follower.partner, props.thread)" alt=""/>
<span class="flex-shrink text-truncate" t-esc="follower.partner.name"/>
</a>
<t t-if="follower.isEditable">
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Edit subscription" aria-label="Edit subscription" t-on-click="(ev) => this.onClickEdit(ev, follower)">
<i class="fa fa-pencil"/>
</button>
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Remove this follower" aria-label="Remove this follower" t-on-click="(ev) => this.onClickRemove(ev, follower)">
<i class="fa fa-remove"/>
</button>
</t>
</div>
</t>
</templates>
@@ -1,6 +1,8 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
import { useVisible } from "@mail/utils/common/hooks";
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
/**
* @typedef {Object} Props
@@ -11,4 +13,13 @@ import { Component } from "@odoo/owl";
export class RecipientList extends Component {
static template = "mail.RecipientList";
static props = ["thread", "close?"];
setup() {
this.threadService = useState(useService("mail.thread"));
this.loadMoreState = useVisible("load-more", () => {
if (this.loadMoreState.isVisible) {
this.threadService.loadMoreFollowers(this.props.thread);
}
});
}
}
@@ -4,9 +4,11 @@
<t t-name="mail.RecipientList" owl="1">
<div class="o-mail-RecipientList p-2 overflow-auto">
<ul class="list-unstyled mb-0">
<li t-if="props.thread.selfFollower" t-out="props.thread.selfFollower.partner.email"/>
<li t-foreach="[...props.thread.followers]" t-as="follower" t-key="follower.id">
<t t-out="follower.partner.email"/>
</li>
<span t-if="!props.thread.followersFullyLoaded" class="btn btn-link w-100" t-on-click="() => threadService.loadMoreFollowers(props.thread)" t-ref="load-more">Load more</span>
</ul>
</div>
</t>
@@ -5,7 +5,7 @@ import { ThreadService, threadService } from "@mail/core/common/thread_service";
import { parseEmail } from "@mail/js/utils";
import { createLocalId } from "@mail/utils/common/misc";
import { markup } from "@odoo/owl";
import { markup, toRaw } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
@@ -79,11 +79,21 @@ patch(ThreadService.prototype, "mail/core/web", {
this.setMainAttachmentFromIndex(thread, 0);
}
if ("followers" in result) {
if (result.selfFollower) {
thread.selfFollower = this.insertFollower({
followedThread: thread,
...result.selfFollower,
});
}
thread.followersCount = result.followersCount;
for (const followerData of result.followers) {
this.insertFollower({
const follower = this.insertFollower({
followedThread: thread,
...followerData,
});
if (toRaw(follower) !== toRaw(thread.selfFollower)) {
thread.followers.add(follower);
}
}
}
if ("suggestedRecipients" in result) {
@@ -138,7 +148,6 @@ patch(ThreadService.prototype, "mail/core/web", {
partner: this.personaService.insert({ ...data.partner, type: "partner" }),
_store: this.store,
});
follower.followedThread.followers.add(follower);
return follower;
},
/**
@@ -174,6 +183,21 @@ patch(ThreadService.prototype, "mail/core/web", {
}
this._super(...arguments);
},
async loadMoreFollowers(thread) {
const followers = await this.orm.call(thread.model, "message_get_followers", [
[thread.id],
Array.from(thread.followers).at(-1).id,
]);
for (const data of followers) {
const follower = this.insertFollower({
followedThread: thread,
...data,
});
if (toRaw(follower) !== toRaw(thread.selfFollower)) {
thread.followers.add(follower);
}
}
},
open(thread, replaceNewMessageChatWindow) {
if (!this.store.discuss.isActive || this.ui.isSmall) {
this.chatWindowService.open(thread, replaceNewMessageChatWindow);
@@ -188,7 +212,12 @@ patch(ThreadService.prototype, "mail/core/web", {
[follower.followedThread.id],
[follower.partner.id],
]);
follower.followedThread.followers.delete(follower);
const thread = follower.followedThread;
if (toRaw(follower) === toRaw(thread.selfFollower)) {
thread.selfFollower = undefined;
} else {
thread.followers.delete(follower);
}
delete this.store.followers[follower.id];
},
unpin(thread) {
@@ -864,7 +864,7 @@ QUnit.test("Show a thread name in the recipient status text.", async (assert) =>
assert.containsOnce($, 'span[title="test@odoo.com"]');
});
QUnit.test("Show follower list when there is more than 5 followers.", async (assert) => {
QUnit.test("Show recipient list when there is more than 5 followers.", async (assert) => {
const pyEnv = await startServer();
const partnerIds = pyEnv["res.partner"].create([
{ name: "test name 1", email: "test1@odoo.com" },
@@ -77,7 +77,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", {
if (route === "/mail/link_preview") {
return this._mockRouteMailLinkPreview(args.message_id, args.clear);
}
if (route == "/mail/link_preview/delete") {
if (route === "/mail/link_preview/delete") {
const [linkPreview] = this.pyEnv["mail.link.preview"].searchRead([
["id", "=", args.link_preview_id],
]);
@@ -516,18 +516,18 @@ patch(MockServer.prototype, "mail/controllers/discuss", {
}
}
if (request_list.includes("followers")) {
const followers = this.pyEnv["mail.followers"].searchRead([
["id", "in", thread.message_follower_ids || []],
]);
// search read returns many2one relations as an array [id, display_name].
// But the original route does not. Thus, we need to change it now.
followers.forEach((follower) => {
follower.partner_id = follower.partner_id[0];
follower.partner = this._mockResPartnerMailPartnerFormat([follower.partner_id]).get(
follower.partner_id
);
});
res["followers"] = followers;
const domain = [
["res_id", "=", thread.id],
["res_model", "=", thread_model],
];
res["followersCount"] = (thread.message_follower_ids || []).length;
const selfFollower = this.pyEnv["mail.followers"].searchRead(
domain.concat([["partner_id", "=", this.pyEnv.currentPartnerId]])
)[0];
res["selfFollower"] = selfFollower
? this._mockMailFollowers_FormatForChatter(selfFollower.id)[0]
: false;
res["followers"] = this._mockMailThreadMessageGetFollowers(thread_model, [thread_id]);
}
if (request_list.includes("suggestedRecipients")) {
res["suggestedRecipients"] = this._mockMailThread_MessageGetSuggestedRecipients(
@@ -0,0 +1,32 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, "mail/models/mail_followers", {
/**
* Simulates `_format_for_chatter` on `mail.followers`.
*
* @private
* @returns {integer[]} ids
* @returns {Object[]}
*/
_mockMailFollowers_FormatForChatter(ids) {
const followers = this.getRecords("mail.followers", [["id", "in", ids]]);
// sorted from lowest ID to highest ID (i.e. from least to most recent)
followers.sort((f1, f2) => (f1.id < f2.id ? -1 : 1));
return followers.map((follower) => {
return {
id: follower.id,
partner_id: follower.partner_id,
name: follower.name,
display_name: follower.display_name,
email: follower.email,
is_active: follower.is_active,
partner: this._mockResPartnerMailPartnerFormat([follower.partner_id]).get(
follower.partner_id
),
};
});
},
});
@@ -18,6 +18,12 @@ patch(MockServer.prototype, "mail/models/mail_thread", {
const partner_ids = args.args[1] || args.kwargs.partner_ids;
return this._mockMailThreadMessageUnsubscribe(args.model, ids, partner_ids);
}
if (args.method === "message_get_followers") {
const ids = args.args[0];
const after = args.args[1] || args.kwargs.after;
const limit = args.args[2] || args.kwargs.limit;
return this._mockMailThreadMessageGetFollowers(args.model, ids, after, limit);
}
if (args.method === "message_post") {
const id = args.args[0];
const kwargs = args.kwargs;
@@ -113,6 +119,30 @@ patch(MockServer.prototype, "mail/models/mail_thread", {
result[record.id].push([partner, email, reason]);
return result;
},
/**
* Simulates `message_get_followers` on `mail.thread`.
*
* @private
* @param {string} model
* @param {integer[]} ids
* @param {integer} [after]
* @param {integer} [limit=100]
* @returns {Object[]}
*/
_mockMailThreadMessageGetFollowers(model, ids, after, limit = 100) {
const domain = [
["res_id", "=", ids[0]],
["res_model", "=", model],
];
if (after) {
domain.push(["id", ">", after]);
}
const followers = this.getRecords("mail.followers", domain).sort(
(f1, f2) => (f1.id < f2.id ? -1 : 1) // sorted from lowest ID to highest ID (i.e. from oldest to youngest)
);
followers.length = Math.min(followers.length, limit);
return this._mockMailFollowers_FormatForChatter(followers.map((follower) => follower.id));
},
/**
* Simulates `_message_get_suggested_recipients` on `mail.thread`.
*
@@ -17,15 +17,12 @@ QUnit.test("base rendering not editable", async (assert) => {
QUnit.test("hover following button", async (assert) => {
const pyEnv = await startServer();
const threadId = pyEnv["res.partner"].create({});
const followerId = pyEnv["mail.followers"].create({
pyEnv["mail.followers"].create({
is_active: true,
partner_id: pyEnv.currentPartnerId,
res_id: threadId,
res_model: "res.partner",
});
pyEnv["res.partner"].write([pyEnv.currentPartnerId], {
message_follower_ids: [followerId],
});
const { openView } = await start();
await openView({
res_id: threadId,
@@ -1,6 +1,6 @@
/* @odoo-module */
import { click, start, startServer } from "@mail/../tests/helpers/test_utils";
import { afterNextRender, click, start, startServer } from "@mail/../tests/helpers/test_utils";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { nextTick } from "web.test_utils";
@@ -211,6 +211,84 @@ QUnit.test(
}
);
QUnit.test("Load 100 followers at once", async (assert) => {
const pyEnv = await startServer();
const partnerIds = pyEnv["res.partner"].create(
[...Array(210).keys()].map((i) => ({ display_name: `Partner${i}`, name: `Partner${i}` }))
);
pyEnv["mail.followers"].create(
[...Array(210).keys()].map((i) => {
return {
is_active: true,
partner_id: i === 0 ? pyEnv.currentPartnerId : partnerIds[i],
res_id: partnerIds[0],
res_model: "res.partner",
};
})
);
const { openFormView } = await start();
await openFormView("res.partner", partnerIds[0]);
assert.containsOnce($, "button[title='Show Followers']:contains(210)");
await click("button[title='Show Followers']");
assert.containsOnce($, ".o-mail-Follower:contains(Mitchell Admin)");
assert.containsN($, ".o-mail-Follower", 100);
assert.containsOnce($, ".o-mail-Followers-dropdown span:contains(Load more)");
await afterNextRender(() =>
$(".o-mail-Followers-dropdown span:contains(Load more)")[0].scrollIntoView()
);
assert.containsN($, ".o-mail-Follower", 200);
assert.containsOnce($, ".o-mail-Followers-dropdown span:contains(Load more)");
await afterNextRender(() =>
$(".o-mail-Followers-dropdown span:contains(Load more)")[0].scrollIntoView()
);
assert.containsN($, ".o-mail-Follower", 210);
assert.containsNone($, ".o-mail-Followers-dropdown span:contains(Load more)");
});
QUnit.test("Load 100 recipients at once", async (assert) => {
const pyEnv = await startServer();
const partnerIds = pyEnv["res.partner"].create(
[...Array(210).keys()].map((i) => ({
display_name: `Partner${i}`,
name: `Partner${i}`,
email: `partner${i}@example.com`,
}))
);
pyEnv["mail.followers"].create(
[...Array(210).keys()].map((i) => {
return {
is_active: true,
partner_id: i === 0 ? pyEnv.currentPartnerId : partnerIds[i],
res_id: partnerIds[0],
res_model: "res.partner",
};
})
);
const { openFormView } = await start();
await openFormView("res.partner", partnerIds[0]);
assert.containsOnce($, "button[title='Show Followers']:contains(210)");
await click("button:contains(Send message)");
assert.containsOnce(
$,
".o-mail-Chatter:contains('me, partner1, partner2, partner3, partner4, …')"
);
assert.containsOnce($, "button[title='Show all recipients']");
await click("button[title='Show all recipients']");
assert.containsN($, ".o-mail-RecipientList li", 100);
assert.containsOnce($, ".o-mail-RecipientList span:contains(Load more)");
await afterNextRender(() =>
$(".o-mail-RecipientList span:contains(Load more)")[0].scrollIntoView()
);
assert.containsN($, ".o-mail-RecipientList li", 200);
assert.containsOnce($, ".o-mail-RecipientList span:contains(Load more)");
await afterNextRender(() =>
$(".o-mail-RecipientList span:contains(Load more)")[0].scrollIntoView()
);
assert.containsN($, ".o-mail-RecipientList li", 210);
assert.containsNone($, ".o-mail-RecipientList span:contains(Load more)");
});
QUnit.test(
'Show "Add follower" and subtypes edition/removal buttons on all followers if user has write access',
async (assert) => {