[IMP] mail: search a discuss thread
See https://github.com/odoo/enterprise/pull/43980 task-2359037 closes odoo/odoo#118794 Related: odoo/enterprise#43980 Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
@@ -52,17 +52,17 @@ class ChannelController(http.Controller):
|
||||
|
||||
@http.route("/discuss/channel/messages", methods=["POST"], type="json", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None):
|
||||
def discuss_channel_messages(self, channel_id, search_term=None, before=None, after=None, limit=30, around=None):
|
||||
channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=int(channel_id))
|
||||
domain = [
|
||||
("res_id", "=", channel_id),
|
||||
("model", "=", "discuss.channel"),
|
||||
("message_type", "!=", "user_notification"),
|
||||
]
|
||||
messages = channel_member_sudo.env["mail.message"]._message_fetch(domain, before, after, around, limit)
|
||||
res = channel_member_sudo.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
|
||||
if not request.env.user._is_public() and not around:
|
||||
messages.set_message_done()
|
||||
return messages.message_format()
|
||||
res["messages"].set_message_done()
|
||||
return {**res, "messages": res["messages"].message_format()}
|
||||
|
||||
@http.route("/discuss/channel/pinned_messages", methods=["POST"], type="json", auth="public")
|
||||
@add_guest_to_context
|
||||
|
||||
@@ -6,19 +6,20 @@ from odoo.http import request
|
||||
|
||||
class MailboxController(http.Controller):
|
||||
@http.route("/mail/inbox/messages", methods=["POST"], type="json", auth="user")
|
||||
def discuss_inbox_messages(self, before=None, after=None, limit=30, around=None):
|
||||
def discuss_inbox_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
|
||||
partner_id = request.env.user.partner_id.id
|
||||
domain = [("needaction", "=", True)]
|
||||
return (request.env["mail.message"]
|
||||
._message_fetch(domain, before, after, around, limit)
|
||||
._message_format_personalize(partner_id))
|
||||
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
|
||||
return {**res, "messages": res["messages"]._message_format_personalize(partner_id)}
|
||||
|
||||
@http.route("/mail/history/messages", methods=["POST"], type="json", auth="user")
|
||||
def discuss_history_messages(self, before=None, after=None, limit=30):
|
||||
def discuss_history_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
|
||||
domain = [("needaction", "=", False)]
|
||||
return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format()
|
||||
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
|
||||
return {**res, "messages": res["messages"].message_format()}
|
||||
|
||||
@http.route("/mail/starred/messages", methods=["POST"], type="json", auth="user")
|
||||
def discuss_starred_messages(self, before=None, after=None, limit=30):
|
||||
def discuss_starred_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
|
||||
domain = [("starred_partner_ids", "in", [request.env.user.partner_id.id])]
|
||||
return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format()
|
||||
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
|
||||
return {**res, "messages": res["messages"].message_format()}
|
||||
|
||||
@@ -16,16 +16,16 @@ class ThreadController(http.Controller):
|
||||
return thread._get_mail_thread_data(request_list)
|
||||
|
||||
@http.route("/mail/thread/messages", methods=["POST"], type="json", auth="user")
|
||||
def mail_thread_messages(self, thread_model, thread_id, before=None, after=None, around=None, limit=30):
|
||||
def mail_thread_messages(self, thread_model, thread_id, search_term=None, before=None, after=None, around=None, limit=30):
|
||||
domain = [
|
||||
("res_id", "=", int(thread_id)),
|
||||
("model", "=", thread_model),
|
||||
("message_type", "!=", "user_notification"),
|
||||
]
|
||||
messages = request.env["mail.message"]._message_fetch(domain, before, after, around, limit)
|
||||
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
|
||||
if not request.env.user._is_public():
|
||||
messages.set_message_done()
|
||||
return messages.message_format()
|
||||
res["messages"].set_message_done()
|
||||
return {**res, "messages": res["messages"].message_format()}
|
||||
|
||||
@http.route("/mail/partner/from_email", methods=["POST"], type="json", auth="user")
|
||||
def mail_thread_partner_from_email(self, emails):
|
||||
|
||||
@@ -898,7 +898,8 @@ class Channel(models.Model):
|
||||
domain = ["&", ("model", "=", "discuss.channel"), ("res_id", "in", self.ids)]
|
||||
if last_id:
|
||||
domain.append(("id", "<", last_id))
|
||||
return self.env['mail.message']._message_fetch(domain=domain, limit=limit).message_format()
|
||||
res = self.env['mail.message']._message_fetch(domain=domain, limit=limit)
|
||||
return res["messages"].message_format()
|
||||
|
||||
def _channel_format(self, fields=None):
|
||||
if not fields:
|
||||
|
||||
@@ -933,28 +933,31 @@ class Message(models.Model):
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def _message_fetch(self, domain, before=None, after=None, around=None, limit=30):
|
||||
""" Get a limited amount of formatted messages with provided domain.
|
||||
:param domain: the domain to filter messages;
|
||||
:param before: fetch messages before this message id.
|
||||
:param after: fetch messages after this message id.
|
||||
:param around: fetch messages around this message id
|
||||
i.e. limit//2 before and limit//2 after.
|
||||
:param limit: the maximum amount of messages to get;
|
||||
:returns: record set of mail.message sorted in descending order.
|
||||
"""
|
||||
def _message_fetch(self, domain, search_term=None, before=None, after=None, around=None, limit=30):
|
||||
res = {}
|
||||
if search_term:
|
||||
# we replace every space by a % to avoid hard spacing matching
|
||||
search_term = search_term.replace(" ", "%")
|
||||
domain = expression.AND([domain, expression.OR([
|
||||
[("attachment_ids.name", "ilike", search_term)],
|
||||
[("body", "ilike", search_term)],
|
||||
[("subject", "ilike", search_term)],
|
||||
[("subtype_id.description", "ilike", search_term)],
|
||||
])])
|
||||
domain = expression.AND([domain, [("message_type", "not in", ["user_notification", "notification"])]])
|
||||
res["count"] = self.search_count(domain)
|
||||
if around:
|
||||
messages_before = self.search(domain=[*domain, ('id', '<=', around)], limit=limit // 2, order="id DESC")
|
||||
messages_after = self.search(domain=[*domain, ('id', '>', around)], limit=limit // 2, order='id ASC')
|
||||
return (messages_after + messages_before).sorted('id', reverse=True)
|
||||
return {**res, "messages": (messages_after + messages_before).sorted('id', reverse=True)}
|
||||
if before:
|
||||
domain = expression.AND([domain, [('id', '<', before)]])
|
||||
if after:
|
||||
domain = expression.AND([domain, [('id', '>', after)]])
|
||||
message_ids = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
|
||||
res["messages"] = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
|
||||
if after:
|
||||
message_ids = message_ids.sorted('id', reverse=True)
|
||||
return message_ids
|
||||
res["messages"] = res["messages"].sorted('id', reverse=True)
|
||||
return res
|
||||
|
||||
def message_format(self, format_reply=True, msg_vals=None):
|
||||
""" Get the message values in the format for web client. Since message
|
||||
|
||||
@@ -13,10 +13,11 @@ import { url } from "@web/core/utils/urls";
|
||||
* @property {import("models").Attachment[]} attachments
|
||||
* @property {function} unlinkAttachment
|
||||
* @property {number} imagesHeight
|
||||
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class AttachmentList extends Component {
|
||||
static props = ["attachments", "unlinkAttachment", "imagesHeight"];
|
||||
static props = ["attachments", "unlinkAttachment", "imagesHeight", "messageSearch?"];
|
||||
static template = "mail.AttachmentList";
|
||||
|
||||
setup() {
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<t t-ref="nonImageMain">
|
||||
<div class="o-mail-AttachmentCard-image o_image flex-shrink-0 m-1" role="menuitem" aria-label="Preview" t-att-tabindex="-1" t-att-aria-disabled="false" t-att-data-mimetype="attachment.mimetype"/>
|
||||
<div class="overflow-auto d-flex justify-content-center flex-column px-1">
|
||||
<div t-if="attachment.name" class="text-truncate" t-esc="attachment.name"/>
|
||||
<div t-if="attachment.name" class="text-truncate" t-out="props.messageSearch?.highlight(attachment.name) ?? attachment.name"/>
|
||||
<small t-if="attachment.extension" class="text-uppercase" t-esc="attachment.extension"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -37,3 +37,7 @@
|
||||
.o-yellow {
|
||||
color: $yellow;
|
||||
}
|
||||
|
||||
.o-z-index-1 {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import { useMessageActions } from "./message_actions";
|
||||
* @property {import("@mail/utils/common/hooks").MessageToReplyTo} [messageToReplyTo]
|
||||
* @property {boolean} [squashed]
|
||||
* @property {import("models").Thread} [thread]
|
||||
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
|
||||
* @property {String} [className]
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class Message extends Component {
|
||||
@@ -78,6 +80,8 @@ export class Message extends Component {
|
||||
"messageToReplyTo?",
|
||||
"squashed?",
|
||||
"thread?",
|
||||
"messageSearch?",
|
||||
"className?",
|
||||
];
|
||||
static template = "mail.Message";
|
||||
|
||||
@@ -180,16 +184,17 @@ export class Message extends Component {
|
||||
|
||||
get attClass() {
|
||||
return {
|
||||
[this.props.className]: true,
|
||||
"o-highlighted bg-view shadow-lg": this.props.highlighted,
|
||||
"o-selfAuthored": this.message.isSelfAuthored,
|
||||
"o-selfAuthored": this.message.isSelfAuthored && !this.env.messageCard,
|
||||
"o-selected": this.props.messageToReplyTo?.isSelected(
|
||||
this.props.thread,
|
||||
this.props.message
|
||||
),
|
||||
"o-squashed pb-1": this.props.squashed,
|
||||
"py-1": !this.props.squashed,
|
||||
"mt-2": !this.props.squashed && this.props.thread,
|
||||
"px-3": !this.props.isInChatWindow,
|
||||
"mt-2": !this.props.squashed && this.props.thread && !this.env.messageCard,
|
||||
"px-3": !this.props.isInChatWindow && !this.env.messageCard,
|
||||
"px-2": this.props.isInChatWindow,
|
||||
"opacity-50": this.props.messageToReplyTo?.isNotSelected(
|
||||
this.props.thread,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o-mail-ChatWindow .o-mail-Message:not(.o-cancelSelfAuthored).o-selfAuthored {
|
||||
.o-mail-ChatWindow .o-mail-Message.o-selfAuthored {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.o-mail-Message-core, .o-mail-Message-textContent {
|
||||
@@ -74,6 +74,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.o-mail-Message-searchHighlight {
|
||||
background: rgba($warning, 0.75);
|
||||
}
|
||||
|
||||
.o-mail-Message-starred {
|
||||
color: $o-main-favorite-color;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
'justify-content-end': isAlignedRight,
|
||||
'ps-4': env.inChatWindow and isAlignedRight and !state.isEditing,
|
||||
'ps-2': env.inChatWindow and isAlignedRight and state.isEditing,
|
||||
'pe-4': env.inChatWindow and !isAlignedRight and !state.isEditing,
|
||||
'pe-4': env.inChatWindow and !isAlignedRight and !state.isEditing and !env.messageCard,
|
||||
'pe-2': env.inChatWindow and !isAlignedRight and composerViewInEditing,
|
||||
}"
|
||||
>
|
||||
@@ -94,11 +94,11 @@
|
||||
<Composer t-if="state.isEditing" autofocus="true" composer="message.composer" messageComponent="constructor" onDiscardCallback.bind="exitEditMode" onPostCallback.bind="exitEditMode" mode="env.inChatter ? 'extended' : 'compact'" sidebar="false"/>
|
||||
<t t-else="">
|
||||
<button t-if="message.type === 'email'" class="o-mail-Message-originalEmailText d-block float-end p-1 mb-1 btn btn-link opacity-75 fst-italic" t-att-class="env.inChatWindow and isAlignedRight ? 'me-3' : 'ms-3'" t-esc="originalEmailText" t-on-click="() => this.state.originalEmail = !this.state.originalEmail"/>
|
||||
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-esc="message.subject"/></em>
|
||||
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-out="props.messageSearch?.highlight(message.subject) ?? message.subject"/></em>
|
||||
<div t-if="message.type === 'email'" t-ref="shadowBody"/>
|
||||
<t t-elif="message.body" t-out="message.body"/>
|
||||
<t t-if="message.body" t-out="props.messageSearch?.highlight(message.body) ?? message.body"/>
|
||||
<!-- <small t-if="message.editDate" class="o-mail-Message-edited fst-italic text-muted" t-att-class="{ 'ms-2': !message.isBodyEmpty }" t-att-title="message.editDatetimeHuge">(edited)</small> --> <!-- DISABLED because new messages sent by email are wrongly flagged as (edited) -->
|
||||
<t t-if="showSubtypeDescription" t-out="message.subtypeDescription"/>
|
||||
<t t-if="showSubtypeDescription" t-out="props.messageSearch?.highlight(message.subtypeDescription) ?? message.subtypeDescription"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +110,8 @@
|
||||
t-if="message.attachments.length > 0"
|
||||
attachments="message.attachments.map((a) => a)"
|
||||
unlinkAttachment.bind="onClickAttachmentUnlink"
|
||||
imagesHeight="message.attachments.length === 1 ? 300 : 75"/>
|
||||
imagesHeight="message.attachments.length === 1 ? 300 : 75"
|
||||
messageSearch="props.messageSearch"/>
|
||||
<LinkPreviewList t-if="message.linkPreviews.length > 0 and store.hasLinkPreviewFeature and !message.linkPreviewSquash" linkPreviews="message.linkPreviews" deletable="deletable"/>
|
||||
</div>
|
||||
<t t-if="!message.hasTextContent or env.inChatWindow" t-call="mail.Message.actions"/>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Message } from "@mail/core/common/message";
|
||||
import { useVisible } from "@mail/utils/common/hooks";
|
||||
|
||||
import { Component, useState, useSubEnv } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {string} [emptyText]
|
||||
* @property {import("@mail/core/common/message_model").Message[]} messages
|
||||
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
|
||||
* @property {function} [loadMore]
|
||||
* @property {string} mode
|
||||
* @property {function} [onClickJump]
|
||||
* @property {function} [onClickUnpin]
|
||||
* @property {function} [onLoadMoreVisible]
|
||||
* @property {boolean} [showEmpty]
|
||||
* @property {import("@mail/core/common/thread_model").Thread} thread
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class MessageCardList extends Component {
|
||||
static components = { Message };
|
||||
static props = [
|
||||
"emptyText?",
|
||||
"messages",
|
||||
"messageSearch?",
|
||||
"loadMore?",
|
||||
"mode",
|
||||
"onClickJump?",
|
||||
"onClickUnpin?",
|
||||
"onLoadMoreVisible?",
|
||||
"showEmpty?",
|
||||
"thread",
|
||||
];
|
||||
static template = "mail.MessageCardList";
|
||||
|
||||
setup() {
|
||||
this.ui = useState(useService("ui"));
|
||||
useSubEnv({ messageCard: true });
|
||||
this.loadMore = useVisible("load-more", () => this.props.onLoadMoreVisible?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the given message and scrolls to it. In small mode, the
|
||||
* pin/search menus are closed beforewards
|
||||
*
|
||||
* @param {import('@mail/core/common/message_model').Message} message
|
||||
*/
|
||||
async onClickJump(message) {
|
||||
this.props.onClickJump?.();
|
||||
if (this.ui.isSmall || this.env.inChatWindow) {
|
||||
this.env.pinMenu?.close();
|
||||
this.env.searchMenu?.close();
|
||||
}
|
||||
// Give the time for menus to close before scrolling to the message.
|
||||
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
|
||||
await this.env.messageHighlight?.highlightMessage(message, this.props.thread);
|
||||
}
|
||||
|
||||
get emptyText() {
|
||||
return this.props.emptyText ?? _t("No messages found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="mail.MessageCardList">
|
||||
<div class="o-mail-MessageCardList d-flex flex-column" t-att-class="{ 'justify-content-center flex-grow-1': props.messages.length === 0 }" t-ref="message-list">
|
||||
<div class="card mb-2" t-foreach="props.messages" t-as="message" t-key="message.id">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-end position-absolute top-0 end-0 o-z-index-1 m-2">
|
||||
<button class="o-mail-MessageCard-jump btn rounded bg-400 badge opacity-0 flex-shrink-0" t-att-class="{ 'opacity-100 py-1 px-2': ui.isSmall }" t-on-click="() => this.onClickJump(message)">Jump</button>
|
||||
<button t-if="props.mode === 'pin'" class="btn ms-2 p-0 text-700 shadow-none" t-att-class="{ 'fs-5 align-self-end': ui.isSmall }" title="Unpin" t-on-click="() => this.props.onClickUnpin(message)">
|
||||
<i class="d-block fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<Message hasActions="false" message="message" thread="props.thread" messageSearch="props.messageSearch"/>
|
||||
</div>
|
||||
</div>
|
||||
<span t-if="props.loadMore" t-ref="load-more"/>
|
||||
<p t-if="props.showEmpty !== undefined ? props.showEmpty : props.messages.length === 0" t-esc="emptyText" class="text-center fst-italic text-500 fs-6"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,102 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { useSequential } from "@mail/utils/common/hooks";
|
||||
import { useState, onWillUnmount, markup } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export const HIGHLIGHT_CLASS = "o-mail-Message-searchHighlight";
|
||||
|
||||
/**
|
||||
* @param {string} searchTerm
|
||||
* @param {string} target
|
||||
*/
|
||||
export function searchHighlight(searchTerm, target) {
|
||||
if (!searchTerm) {
|
||||
return target;
|
||||
}
|
||||
const htmlDoc = new DOMParser().parseFromString(target, "text/html");
|
||||
for (const term of searchTerm.split(" ")) {
|
||||
const regexp = new RegExp(`(${term})`, "gi");
|
||||
const matchs = htmlDoc.evaluate(
|
||||
`//*[text()[contains(translate(., '${term.toUpperCase()}', '${term.toLowerCase()}'), '${term.toLowerCase()}')]]`,
|
||||
htmlDoc,
|
||||
null,
|
||||
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
|
||||
);
|
||||
for (let i = 0; i < matchs.snapshotLength; i++) {
|
||||
const element = matchs.snapshotItem(i);
|
||||
const newNode = [];
|
||||
for (const node of element.childNodes) {
|
||||
const match = node.textContent.match(regexp);
|
||||
if (node.nodeType === Node.TEXT_NODE && match?.length > 0) {
|
||||
let curIndex = 0;
|
||||
for (const match of node.textContent.matchAll(regexp)) {
|
||||
const start = htmlDoc.createTextNode(
|
||||
node.textContent.slice(curIndex, match.index)
|
||||
);
|
||||
newNode.push(start);
|
||||
const span = htmlDoc.createElement("span");
|
||||
span.setAttribute("class", HIGHLIGHT_CLASS);
|
||||
span.textContent = match[0];
|
||||
newNode.push(span);
|
||||
curIndex = match.index + match[0].length;
|
||||
}
|
||||
const end = htmlDoc.createTextNode(node.textContent.slice(curIndex));
|
||||
newNode.push(end);
|
||||
} else {
|
||||
newNode.push(node);
|
||||
}
|
||||
}
|
||||
element.replaceChildren(...newNode);
|
||||
}
|
||||
}
|
||||
return markup(htmlDoc.body.innerHTML);
|
||||
}
|
||||
|
||||
/** @param {import('@mail/core/common/thread_model').Thread} thread */
|
||||
export function useMessageSearch(thread) {
|
||||
const threadService = useService("mail.thread");
|
||||
const sequential = useSequential();
|
||||
const state = useState({
|
||||
thread,
|
||||
async search(before = false) {
|
||||
if (this.searchTerm) {
|
||||
this.searching = true;
|
||||
const { count, loadMore, messages } = await sequential(() =>
|
||||
threadService.search(this.searchTerm, this.thread, before)
|
||||
);
|
||||
this.searched = true;
|
||||
this.searching = false;
|
||||
this.count = count;
|
||||
this.loadMore = loadMore;
|
||||
if (before) {
|
||||
this.messages.push(...messages);
|
||||
} else {
|
||||
this.messages = messages;
|
||||
}
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
},
|
||||
count: 0,
|
||||
clear() {
|
||||
this.messages = [];
|
||||
this.searched = false;
|
||||
this.searching = false;
|
||||
this.searchTerm = undefined;
|
||||
},
|
||||
loadMore: false,
|
||||
/** @type {import('@mail/core/common/message_model').Message[]} */
|
||||
messages: [],
|
||||
/** @type {string|undefined} */
|
||||
searchTerm: undefined,
|
||||
searched: false,
|
||||
searching: false,
|
||||
/** @param {string} target */
|
||||
highlight: (target) => searchHighlight(state.searchTerm, target),
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
state.clear();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Component, onWillUpdateProps, useExternalListener, useState } from "@odoo/owl";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { useMessageSearch } from "@mail/core/common/message_search_hook";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
|
||||
import { MessageCardList } from "./message_card_list";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {import("@mail/core/common/thread_model").Thread} thread
|
||||
* @property {string} [className]
|
||||
* @property {funtion} [closeSearch]
|
||||
* @property {funtion} [onClickJump]
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class SearchMessagesPanel extends Component {
|
||||
static components = {
|
||||
MessageCardList,
|
||||
ActionPanel,
|
||||
};
|
||||
static props = ["thread", "className?", "closeSearch?", "onClickJump?"];
|
||||
static template = "mail.SearchMessagesPanel";
|
||||
|
||||
setup() {
|
||||
useAutofocus();
|
||||
this.state = useState({ searchTerm: "", searchedTerm: "" });
|
||||
this.messageSearch = useMessageSearch(this.props.thread);
|
||||
useAutofocus();
|
||||
useExternalListener(
|
||||
browser,
|
||||
"keydown",
|
||||
(ev) => {
|
||||
if (ev.key === "Escape") {
|
||||
this.props.closeSearch?.();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
onWillUpdateProps((nextProps) => {
|
||||
if (nextProps.thread) {
|
||||
this.messageSearch.thread = nextProps.thread;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get title() {
|
||||
return _t("Search messages");
|
||||
}
|
||||
|
||||
get MESSAGES_FOUND() {
|
||||
if (this.messageSearch.messages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return _t("%s messages found", this.messageSearch.count);
|
||||
}
|
||||
|
||||
search() {
|
||||
this.messageSearch.searchTerm = this.state.searchTerm;
|
||||
this.messageSearch.search();
|
||||
this.state.searchedTerm = this.state.searchTerm;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.state.searchTerm = "";
|
||||
this.state.searchedTerm = this.state.searchTerm;
|
||||
this.messageSearch.clear();
|
||||
this.props.closeSearch?.();
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} ev */
|
||||
onKeydownSearch(ev) {
|
||||
if (ev.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
if (!this.state.searchTerm) {
|
||||
this.clear();
|
||||
} else {
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
onLoadMoreVisible() {
|
||||
const before = this.messageSearch.messages
|
||||
? Math.min(...this.messageSearch.messages.map((message) => message.id))
|
||||
: false;
|
||||
this.messageSearch.search(before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.o-mail-SearchMessagesPanel {
|
||||
flex-basis: 425px;
|
||||
}
|
||||
|
||||
.o-mail-MessageCardList .card-body:hover .o-mail-MessageCard-jump {
|
||||
opacity: 100 !important;
|
||||
}
|
||||
|
||||
.o-mail-MessageCard-jump {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.o_touch_device .o-mail-MessageCard-jump.btn {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="mail.SearchMessagesPanel">
|
||||
<ActionPanel title="env.inChatter ? undefined : title">
|
||||
<div class="d-flex pb-2">
|
||||
<div class="input-group">
|
||||
<div class="o_searchview form-control d-flex align-items-center py-1" role="search" aria-autocomplete="list">
|
||||
<div class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1">
|
||||
<input type="text" class="o_searchview_input flex-grow-1 w-auto border-0" accesskey="Q" placeholder="Search" t-model="state.searchTerm" t-on-keydown="onKeydownSearch" t-ref="autofocus" role="searchbox"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" t-att-class="state.searchedTerm === state.searchTerm ? 'btn-outline-primary' : 'btn-primary'" t-on-click="() => this.search()" aria-label="Search button">
|
||||
<i t-if="!messageSearch.searching" class="o_searchview_icon oi oi-search" role="img" aria-label="Search Messages" title="Search Messages"/>
|
||||
<i t-else="" class="fa fa-spin fa-spinner" aria-label="Search in progress" title="Search in progress"/>
|
||||
</button>
|
||||
</div>
|
||||
<button t-if="env.inChatter" class="btn btn-outline-secondary ms-3" t-on-click="() => this.clear()" aria-label="Close button">
|
||||
<i class="o_searchview_icon oi oi-close cursor-pointer" role="img" aria-label="Close search" title="close"/>
|
||||
</button>
|
||||
</div>
|
||||
<p t-if="MESSAGES_FOUND" class="o-mail-SearchMessagesPanel-title py-1 mb-0 fw-bolder text-center text-uppercase text-700">
|
||||
<t t-out="MESSAGES_FOUND"/>
|
||||
</p>
|
||||
<MessageCardList messages="messageSearch.messages" thread="props.thread" mode="'search'" messageSearch="messageSearch" showEmpty="messageSearch.messages.length === 0 and messageSearch.searched" onClickJump="() => this.props.onClickJump?.()" loadMore="messageSearch.loadMore" onLoadMoreVisible="onLoadMoreVisible"/>
|
||||
</ActionPanel>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -25,24 +25,25 @@ export const PRESENT_THRESHOLD = 2500;
|
||||
* @property {import("@mail/utils/common/hooks").MessageToReplyTo} [messageToReplyTo]
|
||||
* @property {"asc"|"desc"} [order="asc"]
|
||||
* @property {import("models").Thread} thread
|
||||
* @property {string} [searchTerm]
|
||||
* @property {import("@web/core/utils/hooks").Ref} [scrollRef]
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class Thread extends Component {
|
||||
static components = { Message, Transition, DateSection };
|
||||
static props = [
|
||||
"isInChatWindow?",
|
||||
"hasScrollAdjust?",
|
||||
"jumpPresent?",
|
||||
"thread",
|
||||
"messageEdition?",
|
||||
"messageToReplyTo?",
|
||||
"order?",
|
||||
"scrollRef?",
|
||||
];
|
||||
static defaultProps = {
|
||||
isInChatWindow: false,
|
||||
hasScrollAdjust: true,
|
||||
jumpPresent: 0,
|
||||
order: "asc", // 'asc' or 'desc'
|
||||
order: "asc",
|
||||
};
|
||||
static template = "mail.Thread";
|
||||
|
||||
@@ -55,17 +56,16 @@ export class Thread extends Component {
|
||||
showJumpPresent: false,
|
||||
});
|
||||
this.threadService = useState(useService("mail.thread"));
|
||||
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
|
||||
useAutoScroll("messages", () => {
|
||||
if (this.env.messageHighlight?.highlightedMessageId) {
|
||||
return false;
|
||||
}
|
||||
if (this.props.thread.scrollPosition.isSaved) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
useAutoScroll("messages", () => {
|
||||
if (this.env.messageHighlight?.highlightedMessageId) {
|
||||
return false;
|
||||
}
|
||||
if (this.props.thread.scrollPosition.isSaved) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
/** @type {ReturnType<import('@mail/utils/common/hooks').useMessageHighlight>|null} */
|
||||
this.messageHighlight = this.env.messageHighlight
|
||||
? useState(this.env.messageHighlight)
|
||||
: null;
|
||||
@@ -92,34 +92,35 @@ export class Thread extends Component {
|
||||
this.props.thread.scrollPosition,
|
||||
"bottom"
|
||||
);
|
||||
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
|
||||
useScrollSnapshot("messages", {
|
||||
onWillPatch: () => {
|
||||
return {
|
||||
hasMoreMsgsAbove:
|
||||
this.props.thread.oldestPersistentMessage?.id <
|
||||
this.oldestPersistentMessage && this.props.order === "asc",
|
||||
hasMoreMsgBelow:
|
||||
this.props.thread.loadNewer &&
|
||||
this.props.thread.newestPersistentMessage?.id >
|
||||
this.newestPersistentMessage &&
|
||||
this.props.order === "asc",
|
||||
};
|
||||
},
|
||||
onPatched: ({ hasMoreMsgsAbove, hasMoreMsgBelow, scrollTop, scrollHeight }) => {
|
||||
const el = this.messagesRef.el;
|
||||
if (!this.isJumpingRecent) {
|
||||
if (hasMoreMsgsAbove) {
|
||||
el.scrollTop = scrollTop + el.scrollHeight - scrollHeight;
|
||||
} else if (hasMoreMsgBelow) {
|
||||
el.scrollTop = scrollTop;
|
||||
}
|
||||
useScrollSnapshot(this.env.inChatter ? this.props.scrollRef : this.messagesRef, {
|
||||
onWillPatch: () => {
|
||||
return {
|
||||
hasMoreMsgsAbove:
|
||||
this.props.thread.oldestPersistentMessage?.id <
|
||||
this.oldestPersistentMessage,
|
||||
hasMoreMsgBelow:
|
||||
this.props.thread.loadNewer &&
|
||||
this.props.thread.newestPersistentMessage?.id >
|
||||
this.newestPersistentMessage,
|
||||
};
|
||||
},
|
||||
onPatched: ({ hasMoreMsgsAbove, hasMoreMsgBelow, scrollTop, scrollHeight }) => {
|
||||
const el = this.messagesRef.el;
|
||||
if (!this.isJumpingRecent) {
|
||||
if (hasMoreMsgsAbove) {
|
||||
el.scrollTop = this.env.inChatter
|
||||
? scrollTop
|
||||
: scrollTop + el.scrollHeight - scrollHeight;
|
||||
} else if (hasMoreMsgBelow) {
|
||||
el.scrollTop = this.env.inChatter
|
||||
? scrollTop + el.scrollHeight - scrollHeight
|
||||
: scrollTop;
|
||||
}
|
||||
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
|
||||
this.newestPersistentMessage = this.props.thread.newestPersistentMessage?.id;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
|
||||
this.newestPersistentMessage = this.props.thread.newestPersistentMessage?.id;
|
||||
},
|
||||
});
|
||||
useEffect(
|
||||
() => this.updateShowJumpPresent(),
|
||||
() => [this.props.thread.loadNewer]
|
||||
@@ -142,7 +143,7 @@ export class Thread extends Component {
|
||||
return;
|
||||
}
|
||||
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
|
||||
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
|
||||
if (!this.env.inChatter) {
|
||||
this.scrollPosition.restore();
|
||||
this.updateShowJumpPresent();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<t t-name="mail.Thread">
|
||||
<t t-if="env.inChatter" t-call="mail.Thread.jumpPresent"/> <!-- chatter has its own scrollable, this ensures proper sticky showing -->
|
||||
<div class="o-mail-Thread position-relative flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasScrollAdjust, 'pb-4': !state.showJumpPresent }" t-ref="messages" tabindex="-1">
|
||||
<div class="o-mail-Thread position-relative flex-grow-1 d-flex flex-column overflow-auto" t-att-class="{ 'pb-4': !state.showJumpPresent }" t-ref="messages" tabindex="-1">
|
||||
<t t-if="!props.thread.isEmpty or props.thread.loadOlder or props.thread.hasLoadingFailed" name="content">
|
||||
<div class="d-flex flex-column position-relative flex-grow-1" t-att-class="{'justify-content-end': !env.inChatter and props.thread.type !== 'mailbox'}">
|
||||
<span class="position-absolute w-100 invisible" t-att-class="props.order === 'asc' ? 'bottom-0' : 'top-0'" t-ref="present-treshold" t-att-style="`height: Min(${PRESENT_THRESHOLD}px, 100%)`"/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { useComponent, useState } from "@odoo/owl";
|
||||
import { useSubEnv, useComponent, useState } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { SearchMessagesPanel } from "@mail/core/common/search_messages_panel";
|
||||
|
||||
export const threadActionsRegistry = registry.category("mail.thread/actions");
|
||||
|
||||
@@ -49,6 +50,37 @@ threadActionsRegistry
|
||||
component.close();
|
||||
},
|
||||
sequence: 100,
|
||||
})
|
||||
.add("search-messages", {
|
||||
component: SearchMessagesPanel,
|
||||
condition(component) {
|
||||
return (
|
||||
["discuss.channel", "mail.box"].includes(component.thread?.model) &&
|
||||
(!component.props.chatWindow || component.props.chatWindow.isOpen)
|
||||
);
|
||||
},
|
||||
panelOuterClass: "o-mail-SearchMessagesPanel",
|
||||
icon: "oi oi-fw oi-search",
|
||||
iconLarge: "oi oi-fw oi-search",
|
||||
name: _t("Search Messages"),
|
||||
nameActive: _t("Close Search"),
|
||||
sequence: (component) => {
|
||||
const res = component.env.inDiscussApp ? 8 : 16;
|
||||
return res;
|
||||
},
|
||||
setup(action) {
|
||||
useSubEnv({
|
||||
searchMenu: {
|
||||
open: () => action.open(),
|
||||
close: () => {
|
||||
if (action.isActive) {
|
||||
action.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
toggle: true,
|
||||
});
|
||||
|
||||
function transformAction(component, id, action) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import { escapeHTML } from "@web/core/utils/strings";
|
||||
|
||||
const FETCH_LIMIT = 30;
|
||||
|
||||
@@ -185,7 +186,7 @@ export class ThreadService {
|
||||
}
|
||||
try {
|
||||
// ordered messages received: newest to oldest
|
||||
const rawMessages = await this.rpc(this.getFetchRoute(thread), {
|
||||
const { messages: rawMessages } = await this.rpc(this.getFetchRoute(thread), {
|
||||
...this.getFetchParams(thread),
|
||||
limit: FETCH_LIMIT,
|
||||
after,
|
||||
@@ -295,7 +296,7 @@ export class ThreadService {
|
||||
*/
|
||||
async loadAround(thread, messageId) {
|
||||
if (!thread.messages.some(({ id }) => id === messageId)) {
|
||||
const messages = await this.rpc(this.getFetchRoute(thread), {
|
||||
const { messages } = await this.rpc(this.getFetchRoute(thread), {
|
||||
...this.getFetchParams(thread),
|
||||
around: messageId,
|
||||
});
|
||||
@@ -926,6 +927,32 @@ export class ThreadService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} searchTerm
|
||||
* @param {Thread} thread
|
||||
* @param {number|false} [before]
|
||||
*/
|
||||
async search(searchTerm, thread, before = false) {
|
||||
const { messages, count } = await this.rpc(this.getFetchRoute(thread), {
|
||||
...this.getFetchParams(thread),
|
||||
search_term: escapeHTML(searchTerm),
|
||||
before,
|
||||
});
|
||||
return {
|
||||
count,
|
||||
loadMore: messages.length === FETCH_LIMIT,
|
||||
messages: messages.map((message) => {
|
||||
message.body = markup(message.body);
|
||||
if (message.parentMessage) {
|
||||
message.parentMessage.body = message.parentMessage.body
|
||||
? markup(message.parentMessage.body)
|
||||
: message.parentMessage.body;
|
||||
}
|
||||
return this.store.Message.insert(message);
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const threadService = {
|
||||
|
||||
@@ -7,10 +7,11 @@ import { useDropzone } from "@mail/core/common/dropzone_hook";
|
||||
import { Thread } from "@mail/core/common/thread";
|
||||
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 { useHover, useMessageHighlight, useScrollPosition } from "@mail/utils/common/hooks";
|
||||
import { isDragSourceExternalFile } from "@mail/utils/common/misc";
|
||||
import { RecipientList } from "./recipient_list";
|
||||
import { FollowerList } from "./follower_list";
|
||||
import { SearchMessagesPanel } from "@mail/core/common/search_messages_panel";
|
||||
|
||||
import {
|
||||
Component,
|
||||
@@ -51,6 +52,7 @@ export class Chatter extends Component {
|
||||
FileUploader,
|
||||
FollowerList,
|
||||
SuggestedRecipientsList,
|
||||
SearchMessagesPanel,
|
||||
};
|
||||
static props = [
|
||||
"close?",
|
||||
@@ -59,7 +61,7 @@ export class Chatter extends Component {
|
||||
"hasActivities?",
|
||||
"hasFollowers?",
|
||||
"hasMessageList?",
|
||||
"hasMessageListScrollAdjust?",
|
||||
"isChatterAside?",
|
||||
"hasParentReloadOnAttachmentsChanged?",
|
||||
"hasParentReloadOnFollowersUpdate?",
|
||||
"hasParentReloadOnMessagePosted?",
|
||||
@@ -75,7 +77,7 @@ export class Chatter extends Component {
|
||||
hasActivities: true,
|
||||
hasFollowers: true,
|
||||
hasMessageList: true,
|
||||
hasMessageListScrollAdjust: false,
|
||||
isChatterAside: false,
|
||||
hasParentReloadOnAttachmentsChanged: false,
|
||||
hasParentReloadOnFollowersUpdate: false,
|
||||
hasParentReloadOnMessagePosted: false,
|
||||
@@ -103,6 +105,7 @@ export class Chatter extends Component {
|
||||
showAttachmentLoading: false,
|
||||
/** @type {import("models").Thread} */
|
||||
thread: undefined,
|
||||
isSearchOpen: false,
|
||||
});
|
||||
this.unfollowHover = useHover("unfollow");
|
||||
this.attachmentUploader = useAttachmentUploader(
|
||||
@@ -112,7 +115,11 @@ export class Chatter extends Component {
|
||||
this.rootRef = useRef("root");
|
||||
this.onScrollDebounced = useThrottleForAnimation(this.onScroll);
|
||||
this.recipientsPopover = usePopover(RecipientList);
|
||||
useChildSubEnv({ inChatter: true });
|
||||
this.messageHighlight = useMessageHighlight();
|
||||
useChildSubEnv({
|
||||
inChatter: true,
|
||||
messageHighlight: this.messageHighlight,
|
||||
});
|
||||
useDropzone(
|
||||
this.rootRef,
|
||||
async (ev) => {
|
||||
@@ -164,6 +171,9 @@ export class Chatter extends Component {
|
||||
this.onNextUpdate = null;
|
||||
}
|
||||
}
|
||||
if (nextProps.threadId) {
|
||||
this.closeSearch();
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
@@ -335,6 +345,7 @@ export class Chatter extends Component {
|
||||
}
|
||||
|
||||
toggleComposer(mode = false) {
|
||||
this.closeSearch();
|
||||
const toggle = () => {
|
||||
if (this.state.composerType === mode) {
|
||||
this.state.composerType = false;
|
||||
@@ -364,6 +375,7 @@ export class Chatter extends Component {
|
||||
}
|
||||
|
||||
async scheduleActivity() {
|
||||
this.closeSearch();
|
||||
const schedule = async (threadId) => {
|
||||
await this.activityService.schedule(this.props.threadModel, threadId);
|
||||
this.load(this.props.threadId, ["activities", "messages"]);
|
||||
@@ -412,6 +424,15 @@ export class Chatter extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
onClickSearch() {
|
||||
this.state.composerType = false;
|
||||
this.state.isSearchOpen = !this.state.isSearchOpen;
|
||||
}
|
||||
|
||||
closeSearch() {
|
||||
this.state.isSearchOpen = false;
|
||||
}
|
||||
|
||||
async onClickAttachFile(ev) {
|
||||
if (this.props.threadId) {
|
||||
return;
|
||||
|
||||
@@ -50,3 +50,7 @@
|
||||
.btn.o-mail-Chatter-follow:hover {
|
||||
color: $black !important;
|
||||
}
|
||||
|
||||
.o-mail-Chatter button.o-active {
|
||||
color: $o-action !important;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.Chatter">
|
||||
<div t-if="state.thread" class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
|
||||
<div t-if="state.thread" class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.isChatterAside, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
|
||||
<div class="o-mail-Chatter-top position-sticky top-0" t-att-class="{ 'shadow-sm': state.isTopStickyPinned }" t-ref="top">
|
||||
<div class="o-mail-Chatter-topbar d-flex flex-shrink-0 flex-grow-0 px-3 overflow-x-auto">
|
||||
<button t-if="props.hasMessageList" class="o-mail-Chatter-sendMessage btn text-nowrap me-1" t-att-class="{
|
||||
@@ -26,6 +26,9 @@
|
||||
<span>Activities</span>
|
||||
</button>
|
||||
<span class="o-mail-Chatter-topbarGrow flex-grow-1 pe-2"/>
|
||||
<button class="btn btn-link text-action" t-att-class="{ 'o-active': state.isSearchOpen }" aria-label="Search Messages" title="Search Messages" t-on-click="onClickSearch">
|
||||
<i class="oi oi-search" role="img"/>
|
||||
</button>
|
||||
<FileUploader t-if="attachments.length === 0" fileUploadClass="'o-mail-Chatter-fileUploader'" multiUpload="true" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
|
||||
<t t-set-slot="toggler">
|
||||
<t t-call="mail.Chatter.attachFiles"/>
|
||||
@@ -83,34 +86,46 @@
|
||||
</t>
|
||||
</div>
|
||||
<div class="o-mail-Chatter-content">
|
||||
<t t-if="props.hasActivities and activities.length">
|
||||
<t t-call="mail.ActivityList"/>
|
||||
</t>
|
||||
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative" t-ref="attachment-box">
|
||||
<div class="d-flex align-items-center">
|
||||
<hr class="flex-grow-1"/>
|
||||
<span class="p-3 fw-bold">
|
||||
Files
|
||||
</span>
|
||||
<hr class="flex-grow-1"/>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<AttachmentList
|
||||
attachments="attachments"
|
||||
unlinkAttachment.bind="unlinkAttachment"
|
||||
imagesHeight="100"
|
||||
/>
|
||||
<FileUploader multiUpload="true" fileUploadClass="'o-mail-Chatter-fileUploader'" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
|
||||
<t t-set-slot="toggler">
|
||||
<button class="btn btn-link" type="button" t-att-disabled="!state.thread.hasWriteAccess">
|
||||
<i class="fa fa-plus-square"/>
|
||||
Attach files
|
||||
</button>
|
||||
</t>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div t-if="state.isSearchOpen" class="o-mail-Chatter-search">
|
||||
<SearchMessagesPanel
|
||||
closeSearch.bind="closeSearch"
|
||||
thread="state.thread"
|
||||
onClickJump.bind="closeSearch"
|
||||
/>
|
||||
</div>
|
||||
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasScrollAdjust="!props.hasMessageListScrollAdjust" jumpPresent="state.jumpThreadPresent"/>
|
||||
<t t-else="">
|
||||
<t t-if="props.hasActivities and activities.length and !state.isSearchOpen">
|
||||
<t t-call="mail.ActivityList"/>
|
||||
</t>
|
||||
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative" t-ref="attachment-box">
|
||||
<div class="d-flex align-items-center">
|
||||
<hr class="flex-grow-1"/>
|
||||
<span class="p-3 fw-bold">
|
||||
Files
|
||||
</span>
|
||||
<hr class="flex-grow-1"/>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<AttachmentList
|
||||
attachments="attachments"
|
||||
unlinkAttachment.bind="unlinkAttachment"
|
||||
imagesHeight="100"
|
||||
/>
|
||||
<FileUploader multiUpload="true" fileUploadClass="'o-mail-Chatter-fileUploader'" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
|
||||
<t t-set-slot="toggler">
|
||||
<button class="btn btn-link" type="button" t-att-disabled="!state.thread.hasWriteAccess">
|
||||
<i class="fa fa-plus-square"/>
|
||||
Attach files
|
||||
</button>
|
||||
</t>
|
||||
</FileUploader>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="props.hasMessageList">
|
||||
<Thread t-if="!state.isSearchOpen" thread="state.thread" t-key="state.thread.localId" order="'desc'" scrollRef="rootRef" jumpPresent="state.jumpThreadPresent"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
@@ -135,7 +150,7 @@
|
||||
</t>
|
||||
|
||||
<t t-name="mail.Chatter.attachFiles">
|
||||
<button class="o-mail-Chatter-attachFiles btn btn-link text-action px-1 d-flex align-items-center" aria-label="Attach files" t-att-class="{ 'my-2': !props.compactHeight }" t-on-click="onClickAddAttachments">
|
||||
<button class="o-mail-Chatter-attachFiles btn btn-link text-action px-1 d-flex align-items-center" aria-label="Attach files" t-att-class="{ 'my-2': !props.compactHeight }" t-on-click="onClickAddAttachments" t-att-disabled="state.isSearchOpen">
|
||||
<i class="fa fa-paperclip fa-lg me-1"/>
|
||||
<span t-if="attachments.length > 0" t-esc="attachments.length"/>
|
||||
<i t-if="!state.thread.areAttachmentsLoaded and state.thread.isLoadingAttachments and state.showAttachmentLoading" class="fa fa-circle-o-notch fa-spin" aria-label="Attachment counter loading..."/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div>
|
||||
<t t-if="message.subtypeDescription">
|
||||
<p class="mb-0">
|
||||
<t t-esc="message.subtypeDescription"/>
|
||||
<t t-out="messageSearch?.highlight(message.subtypeDescription) ?? message.subtypeDescription"/>
|
||||
</p>
|
||||
</t>
|
||||
<t t-if="message.trackingValues.length">
|
||||
@@ -22,7 +22,7 @@
|
||||
</ul>
|
||||
</t>
|
||||
<div t-if="message.body" class="o-mail-Message-body text-break mb-0 w-100" t-ref="body">
|
||||
<t t-out="message.body"/>
|
||||
<t t-out="messageSearch?.highlight(message.body) ?? message.body"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Component } from "@odoo/owl";
|
||||
export class ActionPanel extends Component {
|
||||
static template = "mail.ActionPanel";
|
||||
static props = {
|
||||
title: String,
|
||||
title: { type: String, optional: true },
|
||||
slots: { type: Object, optional: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.o-mail-ActionPanel-header {
|
||||
z-index: $o-mail-NavigableList-zIndex - 1;
|
||||
}
|
||||
|
||||
.o-mail-ActionPanel-backButton {
|
||||
color: $o-brand-odoo;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ActionPanel">
|
||||
<div class="overflow-auto d-flex flex-column flex-shrink-0 bg-view position-relative p-3 pt-0 h-100">
|
||||
<div class="position-sticky top-0 py-3 d-flex align-items-baseline bg-view" style="z-index: 1;" >
|
||||
<div class="o-mail-ActionPanel overflow-auto d-flex flex-column flex-shrink-0 position-relative p-3 pt-0 h-100" t-att-class="{ 'bg-view': !env.inChatter }">
|
||||
<div class="o-mail-ActionPanel-header position-sticky top-0 py-3 d-flex align-items-baseline" t-att-class="{ 'bg-view': !env.inChatter }">
|
||||
<button t-if="env.closeActionPanel" class="o-mail-ActionPanel-backButton btn opacity-75 opacity-100-hover ps-0 py-0 fs-5" title="Close panel" t-on-click.stop="env.closeActionPanel">
|
||||
<i class="oi oi-arrow-left"/>
|
||||
</button>
|
||||
<p class="fs-6 fw-bold text-uppercase m-0 text-700 flex-grow-1" t-esc="props.title"/>
|
||||
<p t-if="props.title" class="fs-6 fw-bold text-uppercase m-0 text-700 flex-grow-1" t-esc="props.title"/>
|
||||
</div>
|
||||
<t t-slot="default"/>
|
||||
</div>
|
||||
|
||||
@@ -24,22 +24,14 @@ patch(Message.prototype, {
|
||||
this.messagePinService.pin(this.message);
|
||||
}
|
||||
},
|
||||
get attClass() {
|
||||
const res = super.attClass;
|
||||
return Object.assign(res, {
|
||||
"o-cancelSelfAuthored": this.env.pinnedPanel,
|
||||
"mt-1": res["mt-1"] && !this.env.pinnedPanel,
|
||||
"px-3": res["px-3"] && !this.env.pinnedPanel,
|
||||
});
|
||||
},
|
||||
get isAlignedRight() {
|
||||
return !this.env.pinnedPanel && super.isAlignedRight;
|
||||
return !this.env.messageCard && super.isAlignedRight;
|
||||
},
|
||||
getPinOptionText() {
|
||||
return this.messagePinService.getPinnedAt(this.message.id) ? _t("Unpin") : _t("Pin");
|
||||
},
|
||||
get shouldDisplayAuthorName() {
|
||||
if (this.env.pinnedPanel) {
|
||||
if (this.env.messageCard) {
|
||||
return true;
|
||||
}
|
||||
return super.shouldDisplayAuthorName;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { Message } from "@mail/core/common/message";
|
||||
import { MessageCardList } from "@mail/core/common/message_card_list";
|
||||
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
|
||||
|
||||
import { Component, onWillStart, onWillUpdateProps, useState, useSubEnv } from "@odoo/owl";
|
||||
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {import("@mail/core/common/thread_model").Thread} thread
|
||||
* @property {string} [className]
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class PinnedMessagesPanel extends Component {
|
||||
static components = {
|
||||
Message,
|
||||
MessageCardList,
|
||||
ActionPanel,
|
||||
};
|
||||
static props = ["thread", "className?"];
|
||||
@@ -19,7 +25,6 @@ export class PinnedMessagesPanel extends Component {
|
||||
setup() {
|
||||
this.store = useService("mail.store");
|
||||
this.rpc = useService("rpc");
|
||||
this.ui = useState(useService("ui"));
|
||||
this.messagePinService = useState(useService("discuss.message.pin"));
|
||||
onWillStart(() => {
|
||||
this.messagePinService.fetchPinnedMessages(this.props.thread);
|
||||
@@ -29,32 +34,13 @@ export class PinnedMessagesPanel extends Component {
|
||||
this.messagePinService.fetchPinnedMessages(nextProps.thread);
|
||||
}
|
||||
});
|
||||
useSubEnv({
|
||||
pinnedPanel: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the given message and scrolls to it. In small mode, the
|
||||
* pin menu is closed beforwards
|
||||
*
|
||||
* @param {Message} message
|
||||
*/
|
||||
async onClickJump(message) {
|
||||
if (this.ui.isSmall || this.env.inChatWindow) {
|
||||
this.env.pinMenu.close();
|
||||
// Give the time to the pin menu to close before scrolling
|
||||
// to the message.
|
||||
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
await this.env.messageHighlight?.highlightMessage(message, this.props.thread);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for confirmation and unpin the given message if
|
||||
* confirmed.
|
||||
*
|
||||
* @param {Message} message
|
||||
* @param {import('@mail/core/common/message_model').Message} message
|
||||
*/
|
||||
onClickUnpin(message) {
|
||||
this.messagePinService.unpin(message);
|
||||
@@ -63,7 +49,7 @@ export class PinnedMessagesPanel extends Component {
|
||||
/**
|
||||
* Get the message to display when nothing is pinned on this thread.
|
||||
*/
|
||||
get emptyMessage() {
|
||||
get emptyText() {
|
||||
if (this.props.thread.type === "channel") {
|
||||
return _t("This channel doesn't have any pinned messages.");
|
||||
} else {
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
.o-discuss-PinnedMessagesPanel {
|
||||
flex-basis: 425px;
|
||||
}
|
||||
|
||||
.o-discuss-PinnedMessagesPanel-jump {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-body:hover .o-discuss-PinnedMessagesPanel-jump {
|
||||
opacity: 100 !important;
|
||||
}
|
||||
|
||||
.o_touch_device .o-discuss-PinnedMessagesPanel-jump.btn {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="discuss.PinnedMessagesPanel">
|
||||
<ActionPanel title="title">
|
||||
<div class="d-flex flex-column" t-att-class="{ 'justify-content-center flex-grow-1': !messagePinService.hasPinnedMessages(props.thread) }" t-ref="message-list">
|
||||
<div class="card mb-3" t-foreach="messagePinService.getPinnedMessages(props.thread)" t-as="message" t-key="message.id">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="o-discuss-PinnedMessagesPanel-jump btn rounded bg-400 badge opacity-0 flex-shrink-0" t-att-class="{ 'opacity-100 py-1 px-2': ui.isSmall }" t-on-click="() => this.onClickJump(message)">Jump</button>
|
||||
<button class="btn ms-2 p-0 text-700 shadow-none" t-att-class="{ 'fs-5 align-self-end': ui.isSmall }" title="Unpin" t-on-click="() => this.onClickUnpin(message)">
|
||||
<i class="d-block fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<Message hasActions="false" message="message" thread="props.thread"/>
|
||||
</div>
|
||||
</div>
|
||||
<p t-if="!messagePinService.hasPinnedMessages(props.thread)" t-esc="emptyMessage" class="text-center fst-italic text-500 fs-6"/>
|
||||
</div>
|
||||
<MessageCardList emptyText="emptyText" messages="messagePinService.getPinnedMessages(props.thread)" thread="props.thread" mode="'pin'" onClickUnpin="(message) => this.onClickUnpin(message)"/>
|
||||
</ActionPanel>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -14,7 +14,7 @@ registry.category("web_tour.tours").add("mail.load_message_order", {
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o-discuss-PinnedMessagesPanel-jump",
|
||||
trigger: ".o-mail-MessageCard-jump",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -157,6 +157,10 @@ export function useAutoScroll(refName, shouldScrollPredicate = () => true) {
|
||||
onPatched(applyScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} refName
|
||||
* @param {function} cb
|
||||
*/
|
||||
export function useVisible(refName, cb, { init = false } = {}) {
|
||||
const ref = useRef(refName);
|
||||
const state = { isVisible: init };
|
||||
@@ -198,12 +202,11 @@ export function useVisible(refName, cb, { init = false } = {}) {
|
||||
* This hook eases adjusting scroll position by snapshotting scroll
|
||||
* properties of scrollable in onWillPatch / onPatched hooks.
|
||||
*
|
||||
* @param {string} refName
|
||||
* @param {import("@web/core/utils/hooks").Ref} ref
|
||||
* @param {function} param1.onWillPatch
|
||||
* @param {function} param1.onPatched
|
||||
*/
|
||||
export function useScrollSnapshot(refName, { onWillPatch: p_onWillPatch, onPatched: p_onPatched }) {
|
||||
const ref = useRef(refName);
|
||||
export function useScrollSnapshot(ref, { onWillPatch: p_onWillPatch, onPatched: p_onPatched }) {
|
||||
const snapshot = {
|
||||
scrollHeight: null,
|
||||
scrollTop: null,
|
||||
|
||||
@@ -92,7 +92,7 @@ patch(FormCompiler.prototype, {
|
||||
"t[t-component='__comp__.mailComponents.Chatter']"
|
||||
);
|
||||
setAttributes(chatterContainerXml, {
|
||||
hasMessageListScrollAdjust: "false",
|
||||
isChatterAside: "false",
|
||||
isInFormSheetBg: "false",
|
||||
saveRecord: "__comp__.props.saveButtonClicked",
|
||||
});
|
||||
@@ -122,7 +122,7 @@ patch(FormCompiler.prototype, {
|
||||
);
|
||||
setAttributes(sheetBgChatterContainerXml, {
|
||||
isInFormSheetBg: "true",
|
||||
hasMessageListScrollAdjust: "false",
|
||||
isChatterAside: "false",
|
||||
});
|
||||
}
|
||||
// after sheet bg (standard position, either aside or below)
|
||||
@@ -133,12 +133,12 @@ patch(FormCompiler.prototype, {
|
||||
});
|
||||
setAttributes(chatterContainerXml, {
|
||||
isInFormSheetBg: "__comp__.hasFileViewer()",
|
||||
hasMessageListScrollAdjust: `__comp__.uiService.size >= ${SIZES.XXL} and !(__comp__.hasFileViewer() and __comp__.uiService.size >= ${SIZES.XXL})`,
|
||||
isChatterAside: `__comp__.uiService.size >= ${SIZES.XXL} and !(__comp__.hasFileViewer() and __comp__.uiService.size >= ${SIZES.XXL})`,
|
||||
});
|
||||
} else {
|
||||
setAttributes(chatterContainerXml, {
|
||||
isInFormSheetBg: "false",
|
||||
hasMessageListScrollAdjust: `__comp__.uiService.size >= ${SIZES.XXL}`,
|
||||
isChatterAside: `__comp__.uiService.size >= ${SIZES.XXL}`,
|
||||
});
|
||||
setAttributes(chatterContainerHookXml, {
|
||||
"t-attf-class": `{{ __comp__.uiService.size >= ${SIZES.XXL} ? "o-aside" : "mt-4 mt-md-0" }}`,
|
||||
|
||||
@@ -145,7 +145,8 @@ QUnit.test("chat window: basic rendering", async () => {
|
||||
text: "There are no messages in this conversation.",
|
||||
});
|
||||
await click("[title='Open Actions Menu']");
|
||||
await contains(".o-mail-ChatWindow-command", { count: 10 });
|
||||
await contains(".o-mail-ChatWindow-command", { count: 11 });
|
||||
await contains("[title='Search Messages']");
|
||||
await contains("[title='Pinned Messages']");
|
||||
await contains("[title='Show Attachments']");
|
||||
await contains("[title='Add Users']");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { HIGHLIGHT_CLASS, searchHighlight } from "@mail/core/common/message_search_hook";
|
||||
import { triggerHotkey } from "@web/../tests/helpers/utils";
|
||||
import { click, contains, insertText } from "@web/../tests/utils";
|
||||
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
import { start } from "../helpers/test_utils";
|
||||
import { SIZES, patchUiSize } from "../helpers/patch_ui_size";
|
||||
|
||||
QUnit.module("Search highlight test", {});
|
||||
|
||||
QUnit.test("Search highlight", async (assert) => {
|
||||
const testCases = [
|
||||
{
|
||||
input: "test odoo",
|
||||
output: `test <span class="${HIGHLIGHT_CLASS}">odoo</span>`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: '<a href="https://www.odoo.com">https://www.odoo.com</a>',
|
||||
output: `<a href="https://www.odoo.com">https://www.<span class="${HIGHLIGHT_CLASS}">odoo</span>.com</a>`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: '<a href="https://www.odoo.com">Odoo</a>',
|
||||
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a>`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: '<a href="https://www.odoo.com">Odoo</a> Odoo is a free software',
|
||||
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a> <span class="${HIGHLIGHT_CLASS}">Odoo</span> is a free software`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: "odoo is a free software",
|
||||
output: `<span class="${HIGHLIGHT_CLASS}">odoo</span> is a free software`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: "software ODOO is a free",
|
||||
output: `software <span class="${HIGHLIGHT_CLASS}">ODOO</span> is a free`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: `<ul>
|
||||
<li>Odoo</li>
|
||||
<li><a href="https://odoo.com">Odoo ERP</a> Best ERP</li>
|
||||
</ul>`,
|
||||
output: `<ul>
|
||||
<li><span class="${HIGHLIGHT_CLASS}">Odoo</span></li>
|
||||
<li><a href="https://odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span> ERP</a> Best ERP</li>
|
||||
</ul>`,
|
||||
searchTerm: "odoo",
|
||||
},
|
||||
{
|
||||
input: "test <strong>Odoo</strong> test",
|
||||
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <strong><span class="${HIGHLIGHT_CLASS}">Odoo</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
|
||||
searchTerm: "odoo test",
|
||||
},
|
||||
{
|
||||
input: "test <br> test",
|
||||
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <br> <span class="${HIGHLIGHT_CLASS}">test</span>`,
|
||||
searchTerm: "odoo test",
|
||||
},
|
||||
{
|
||||
input: "<strong>test</strong> test",
|
||||
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
|
||||
searchTerm: "test",
|
||||
},
|
||||
{
|
||||
input: "<strong>a</strong> test",
|
||||
output: `<strong><span class="${HIGHLIGHT_CLASS}">a</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
|
||||
searchTerm: "a test",
|
||||
},
|
||||
{
|
||||
input: "&amp;",
|
||||
output: `<span class="${HIGHLIGHT_CLASS}">&amp;</span>`,
|
||||
searchTerm: "&",
|
||||
},
|
||||
{
|
||||
input: "&amp;",
|
||||
output: `<span class="${HIGHLIGHT_CLASS}">&</span>amp;`,
|
||||
searchTerm: "&",
|
||||
},
|
||||
{
|
||||
input: "<strong>test</strong> hello",
|
||||
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">hello</span>`,
|
||||
searchTerm: "test hello",
|
||||
},
|
||||
{
|
||||
input: "<p><strong>test</strong> hello</p>",
|
||||
output: `<p><strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">hello</span></p>`,
|
||||
searchTerm: "test hello",
|
||||
},
|
||||
];
|
||||
for (const { input, output, searchTerm } of testCases) {
|
||||
assert.equal(searchHighlight(searchTerm, input), output);
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test("Display highligthed search in chatter", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`);
|
||||
});
|
||||
|
||||
QUnit.test("Display multiple highligthed search in chatter", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not test empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "not empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`, { count: 2 });
|
||||
});
|
||||
|
||||
QUnit.test("Display highligthed search in Discuss", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "not empty",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("button[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`);
|
||||
});
|
||||
|
||||
QUnit.test("Display multiple highligthed search in Discuss", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "not prout empty",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("button[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "not empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`, {
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.test("Display highligthed with escaped character must ignore them", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "<p><strong>test</strong> hello</p>",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "test hello");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`, { count: 2 });
|
||||
await contains(`.o-mail-Message-body`, { text: "<strong>test</strong> hello" });
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
|
||||
import { triggerHotkey } from "@web/../tests/helpers/utils";
|
||||
import { click, contains, insertText, scroll } from "@web/../tests/utils";
|
||||
|
||||
QUnit.module("discuss search");
|
||||
|
||||
QUnit.test("Should have a search button", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await contains("[title='Search Messages']");
|
||||
});
|
||||
|
||||
QUnit.test("Should open the search panel when search button is clicked", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await contains(".o-mail-SearchMessagesPanel");
|
||||
await contains(".o_searchview");
|
||||
await contains(".o_searchview_input");
|
||||
});
|
||||
|
||||
QUnit.test("Search a message", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("button[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
|
||||
});
|
||||
|
||||
QUnit.test("Search should be hightlighted", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message .${HIGHLIGHT_CLASS}`);
|
||||
});
|
||||
|
||||
QUnit.test("Search a starred message", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
starred_partner_ids: [pyEnv.currentPartnerId],
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss("mail.box_starred");
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
|
||||
});
|
||||
|
||||
QUnit.test("Search a message in inbox", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
needaction: true,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss("mail.box_inbox");
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
|
||||
});
|
||||
|
||||
QUnit.test("Search a message in history", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
const messageId = pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
needaction: false,
|
||||
});
|
||||
pyEnv["mail.notification"].create({
|
||||
is_read: true,
|
||||
mail_message_id: messageId,
|
||||
notification_status: "sent",
|
||||
notification_type: "inbox",
|
||||
res_partner_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss("mail.box_history");
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
|
||||
});
|
||||
|
||||
QUnit.test("Should close the search panel when search button is clicked again", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await click("[title='Close Search']");
|
||||
await contains(".o-mail-SearchMessagesPanel");
|
||||
});
|
||||
|
||||
QUnit.test("Search a message in 60 messages should return 30 message first", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
}
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
|
||||
});
|
||||
|
||||
QUnit.test("Scrolling to the bottom should load more searched message", async () => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
}
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
|
||||
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
|
||||
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 60 });
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"Editing the searched term should not edit the current searched term",
|
||||
async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "discuss.channel",
|
||||
res_id: channelId,
|
||||
});
|
||||
}
|
||||
const { openDiscuss } = await start({
|
||||
async mockRPC(route, args) {
|
||||
if (route === "/discuss/channel/messages" && args.search_term) {
|
||||
const { search_term } = args;
|
||||
assert.strictEqual(search_term, "message");
|
||||
}
|
||||
},
|
||||
});
|
||||
await openDiscuss(channelId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await insertText(".o_searchview_input", "test");
|
||||
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
|
||||
}
|
||||
);
|
||||
@@ -36,8 +36,15 @@ patch(MockServer.prototype, {
|
||||
return this._mockRouteMailAttachmentRemove(attachment_id);
|
||||
}
|
||||
if (route === "/discuss/channel/messages") {
|
||||
const { channel_id, after, around, before, limit } = args;
|
||||
return this._mockRouteDiscussChannelMessages(channel_id, before, after, around, limit);
|
||||
const { search_term, channel_id, after, around, before, limit } = args;
|
||||
return this._mockRouteDiscussChannelMessages(
|
||||
channel_id,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
limit
|
||||
);
|
||||
}
|
||||
if (route === "/discuss/channel/pinned_messages") {
|
||||
const { channel_id } = args;
|
||||
@@ -59,15 +66,15 @@ patch(MockServer.prototype, {
|
||||
return this._mockDiscussChannelloadOlderMembers([channel_id], known_member_ids);
|
||||
}
|
||||
if (route === "/mail/history/messages") {
|
||||
const { after, before, limit } = args;
|
||||
return this._mockRouteMailMessageHistory(after, before, limit);
|
||||
const { search_term, after, before, limit } = args;
|
||||
return this._mockRouteMailMessageHistory(search_term, after, before, limit);
|
||||
}
|
||||
if (route === "/mail/init_messaging") {
|
||||
return this._mockRouteMailInitMessaging();
|
||||
}
|
||||
if (route === "/mail/inbox/messages") {
|
||||
const { after, around, before, limit } = args;
|
||||
return this._mockRouteMailMessageInbox(after, before, around, limit);
|
||||
const { search_term, after, around, before, limit } = args;
|
||||
return this._mockRouteMailMessageInbox(search_term, after, before, around, limit);
|
||||
}
|
||||
if (route === "/mail/link_preview") {
|
||||
return this._mockRouteMailLinkPreview(args.message_id, args.clear);
|
||||
@@ -158,8 +165,8 @@ patch(MockServer.prototype, {
|
||||
return;
|
||||
}
|
||||
if (route === "/mail/starred/messages") {
|
||||
const { after, before, limit } = args;
|
||||
return this._mockRouteMailMessageStarredMessages(after, before, limit);
|
||||
const { search_term, after, before, limit } = args;
|
||||
return this._mockRouteMailMessageStarredMessages(search_term, after, before, limit);
|
||||
}
|
||||
if (route === "/mail/thread/data") {
|
||||
return this._mockRouteMailThreadData(
|
||||
@@ -169,10 +176,11 @@ patch(MockServer.prototype, {
|
||||
);
|
||||
}
|
||||
if (route === "/mail/thread/messages") {
|
||||
const { after, around, before, limit, thread_model, thread_id } = args;
|
||||
const { search_term, after, around, before, limit, thread_model, thread_id } = args;
|
||||
return this._mockRouteMailThreadFetchMessages(
|
||||
thread_model,
|
||||
thread_id,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
@@ -232,6 +240,7 @@ patch(MockServer.prototype, {
|
||||
*/
|
||||
async _mockRouteDiscussChannelMessages(
|
||||
channel_id,
|
||||
search_term = false,
|
||||
before = false,
|
||||
after = false,
|
||||
around = false,
|
||||
@@ -242,11 +251,21 @@ patch(MockServer.prototype, {
|
||||
["model", "=", "discuss.channel"],
|
||||
["message_type", "!=", "user_notification"],
|
||||
];
|
||||
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
|
||||
const res = this._mockMailMessage_MessageFetch(
|
||||
domain,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
limit
|
||||
);
|
||||
if (!around) {
|
||||
this._mockMailMessageSetMessageDone(messages.map((message) => message.id));
|
||||
this._mockMailMessageSetMessageDone(res.messages.map((message) => message.id));
|
||||
}
|
||||
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
|
||||
return {
|
||||
...res,
|
||||
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates the `/discuss/channel/notify_typing` route.
|
||||
@@ -328,10 +347,17 @@ patch(MockServer.prototype, {
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockRouteMailMessageHistory(after = false, before = false, limit = 30) {
|
||||
_mockRouteMailMessageHistory(search_term = false, after = false, before = false, limit = 30) {
|
||||
const domain = [["needaction", "=", false]];
|
||||
const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit);
|
||||
const messagesWithNotification = messages.filter((message) => {
|
||||
const res = this._mockMailMessage_MessageFetch(
|
||||
domain,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
false,
|
||||
limit
|
||||
);
|
||||
const messagesWithNotification = res.messages.filter((message) => {
|
||||
const notifs = this.pyEnv["mail.notification"].searchRead([
|
||||
["mail_message_id", "=", message.id],
|
||||
["is_read", "=", true],
|
||||
@@ -340,9 +366,12 @@ patch(MockServer.prototype, {
|
||||
return notifs.length > 0;
|
||||
});
|
||||
|
||||
return this._mockMailMessageMessageFormat(
|
||||
messagesWithNotification.map((message) => message.id)
|
||||
);
|
||||
return {
|
||||
...res,
|
||||
messages: this._mockMailMessageMessageFormat(
|
||||
messagesWithNotification.map((message) => message.id)
|
||||
),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates the `/mail/inbox/messages` route.
|
||||
@@ -350,10 +379,28 @@ patch(MockServer.prototype, {
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockRouteMailMessageInbox(after = false, before = false, around = false, limit = 30) {
|
||||
_mockRouteMailMessageInbox(
|
||||
search_term = false,
|
||||
after = false,
|
||||
before = false,
|
||||
around = false,
|
||||
limit = 30
|
||||
) {
|
||||
const domain = [["needaction", "=", true]];
|
||||
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
|
||||
return this._mockMailMessageFormatPersonalize(messages.map((message) => message.id));
|
||||
const res = this._mockMailMessage_MessageFetch(
|
||||
domain,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
limit
|
||||
);
|
||||
return {
|
||||
...res,
|
||||
messages: this._mockMailMessageFormatPersonalize(
|
||||
res.messages.map((message) => message.id)
|
||||
),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates the `/mail/starred/messages` route.
|
||||
@@ -361,10 +408,25 @@ patch(MockServer.prototype, {
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockRouteMailMessageStarredMessages(after = false, before = false, limit = 30) {
|
||||
_mockRouteMailMessageStarredMessages(
|
||||
search_term = false,
|
||||
after = false,
|
||||
before = false,
|
||||
limit = 30
|
||||
) {
|
||||
const domain = [["starred_partner_ids", "in", [this.pyEnv.currentPartnerId]]];
|
||||
const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit);
|
||||
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
|
||||
const res = this._mockMailMessage_MessageFetch(
|
||||
domain,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
false,
|
||||
limit
|
||||
);
|
||||
return {
|
||||
...res,
|
||||
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates the `/mail/partner/from_email` route.
|
||||
@@ -593,6 +655,7 @@ patch(MockServer.prototype, {
|
||||
async _mockRouteMailThreadFetchMessages(
|
||||
res_model,
|
||||
res_id,
|
||||
search_term = false,
|
||||
before = false,
|
||||
after = false,
|
||||
around = false,
|
||||
@@ -603,8 +666,18 @@ patch(MockServer.prototype, {
|
||||
["model", "=", res_model],
|
||||
["message_type", "!=", "user_notification"],
|
||||
];
|
||||
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
|
||||
this._mockMailMessageSetMessageDone(messages.map((message) => message.id));
|
||||
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
|
||||
const res = this._mockMailMessage_MessageFetch(
|
||||
domain,
|
||||
search_term,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
limit
|
||||
);
|
||||
this._mockMailMessageSetMessageDone(res.messages.map((message) => message.id));
|
||||
return {
|
||||
...res,
|
||||
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,7 +147,13 @@ patch(MockServer.prototype, {
|
||||
* @param {integer} [limit=30]
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_mockMailMessage_MessageFetch(domain, before, after, around, limit = 30) {
|
||||
_mockMailMessage_MessageFetch(domain, search_term, before, after, around, limit = 30) {
|
||||
const res = {};
|
||||
if (search_term) {
|
||||
search_term = search_term.replace(" ", "%");
|
||||
domain.push(["body", "ilike", search_term]);
|
||||
res.count = this.pyEnv["mail.message"].searchCount(domain);
|
||||
}
|
||||
if (around) {
|
||||
const messagesBefore = this.getRecords(
|
||||
"mail.message",
|
||||
@@ -159,7 +165,7 @@ patch(MockServer.prototype, {
|
||||
domain.concat([["id", ">", around]])
|
||||
).sort((m1, m2) => m1.id - m2.id);
|
||||
messagesAfter.length = Math.min(messagesAfter.length, limit / 2);
|
||||
return messagesAfter.concat(messagesBefore.reverse());
|
||||
return { ...res, messages: messagesAfter.concat(messagesBefore.reverse()) };
|
||||
}
|
||||
if (before) {
|
||||
domain.push(["id", "<", before]);
|
||||
@@ -174,7 +180,8 @@ patch(MockServer.prototype, {
|
||||
});
|
||||
// pick at most 'limit' messages
|
||||
messages.length = Math.min(messages.length, limit);
|
||||
return messages;
|
||||
res.messages = messages;
|
||||
return res;
|
||||
},
|
||||
/**
|
||||
* Simulates `message_format` on `mail.message`.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
import { SIZES, patchUiSize } from "../helpers/patch_ui_size";
|
||||
import { triggerHotkey } from "@web/../tests/helpers/utils";
|
||||
import { click, contains, insertText, scroll } from "@web/../tests/utils";
|
||||
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
|
||||
|
||||
QUnit.module("chatter search");
|
||||
|
||||
QUnit.test("Chatter should display search icon", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const { openFormView, pyEnv } = await start();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
await openFormView("res.partner", partnerId);
|
||||
await contains("[title='Search Messages']");
|
||||
});
|
||||
|
||||
QUnit.test("Click on the search icon should open the search form", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const { openFormView, pyEnv } = await start();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await contains(".o_searchview");
|
||||
await contains(".o_searchview_input");
|
||||
});
|
||||
|
||||
QUnit.test("Click again on the search icon should close the search form", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click(".o-mail-Chatter-topbar [title='Search Messages']");
|
||||
await contains(".o_searchview");
|
||||
await click(".o-mail-Chatter-topbar [title='Search Messages']");
|
||||
await contains(".o_searchview", { count: 0 });
|
||||
await contains(".o_searchview_input", { count: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Search in chatter", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-Chatter-search .o-mail-Message");
|
||||
});
|
||||
|
||||
QUnit.test("Close button should close the search panel", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click(".o-mail-Chatter-topbar [title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-Chatter-search .o-mail-Message");
|
||||
await click(".o-mail-Chatter-topbar [title='Search Messages']");
|
||||
await contains(".o-mail-Chatter-search", { count: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Search in chatter should be hightligted", async () => {
|
||||
patchUiSize({ size: SIZES.XXL });
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
pyEnv["mail.message"].create({
|
||||
body: "not empty",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "empty");
|
||||
triggerHotkey("Enter");
|
||||
await contains(`.o-mail-Chatter-search .o-mail-Message .${HIGHLIGHT_CLASS}`);
|
||||
});
|
||||
|
||||
QUnit.test("Scrolling bottom in non-aside chatter should load more searched message", async () => {
|
||||
patchUiSize({ size: SIZES.LG }); // non-aside
|
||||
const pyEnv = await startServer();
|
||||
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
pyEnv["mail.message"].create({
|
||||
author_id: pyEnv.currentPartnerId,
|
||||
body: "This is a message",
|
||||
attachment_ids: [],
|
||||
message_type: "comment",
|
||||
model: "res.partner",
|
||||
res_id: partnerId,
|
||||
});
|
||||
}
|
||||
const { openFormView } = await start();
|
||||
await openFormView("res.partner", partnerId);
|
||||
await click("[title='Search Messages']");
|
||||
await insertText(".o_searchview_input", "message");
|
||||
triggerHotkey("Enter");
|
||||
await contains(".o-mail-Chatter-search .o-mail-Message", { count: 30 });
|
||||
await scroll(".o_content", "bottom");
|
||||
await contains(".o-mail-Chatter-search .o-mail-Message", { count: 60 });
|
||||
});
|
||||
@@ -849,7 +849,7 @@ class UnfollowFromInboxTest(MailCommon):
|
||||
partner_id = self.env.user.partner_id.id
|
||||
return list(filter(
|
||||
lambda m: m['id'] == message_id,
|
||||
MailMessage._message_fetch(domain=[('needaction', '=', True)])._message_format_personalize(partner_id)))
|
||||
MailMessage._message_fetch(domain=[('needaction', '=', True)])["messages"]._message_format_personalize(partner_id)))
|
||||
|
||||
@users('employee')
|
||||
@mute_logger('odoo.models')
|
||||
|
||||
@@ -304,18 +304,18 @@ class TestDiscuss(MailCommon, TestRecipients):
|
||||
message2 = self.test_record.with_user(self.user_admin).message_post(body='Message 2', partner_ids=[user1.partner_id.id, user2.partner_id.id])
|
||||
|
||||
# both notified users should have the 2 messages in Inbox initially
|
||||
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(messages), 2)
|
||||
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(messages), 2)
|
||||
res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(res["messages"]), 2)
|
||||
res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(res["messages"]), 2)
|
||||
|
||||
# first user is marking one message as done: the other message is still Inbox, while the other user still has the 2 messages in Inbox
|
||||
message1.with_user(user1).set_message_done()
|
||||
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0].id, message2.id)
|
||||
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(messages), 2)
|
||||
res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(res["messages"]), 1)
|
||||
self.assertEqual(res["messages"][0].id, message2.id)
|
||||
res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
|
||||
self.assertEqual(len(res["messages"]), 2)
|
||||
|
||||
def test_notification_has_error_filter(self):
|
||||
"""Ensure message_has_error filter is only returning threads for which
|
||||
|
||||
Reference in New Issue
Block a user