[IMP] mail: add attachment panel to discuss

This PR adds a panel to consult all attachments of a discuss channel
easily.

task-3476444

closes odoo/odoo#132784

Signed-off-by: Matthieu Stockbauer (tsm) <tsm@odoo.com>
This commit is contained in:
tsm-odoo
2023-08-30 13:10:20 +00:00
parent 55e08f2269
commit 814b26261e
23 changed files with 268 additions and 18 deletions
@@ -25,6 +25,9 @@ patch(Thread.prototype, {
get hasMemberList() {
return false;
},
get hasAttachmentPanel() {
return this.type !== "livechat" && super.hasAttachmentPanel;
},
});
patch(ThreadService.prototype, {
@@ -81,3 +81,21 @@ class ChannelController(http.Controller):
def discuss_channel_notify_typing(self, channel_id, is_typing):
channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=int(channel_id))
channel_member_sudo._notify_typing(is_typing)
@http.route("/discuss/channel/attachments", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def load_attachments(self, channel_id, limit=30, before=None):
"""Load attachments of a channel. If before is set, load attachments
older than the given id.
:param channel_id: id of the channel
:param limit: maximum number of attachments to return
:param before: id of the attachment from which to load older attachments
"""
channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=channel_id)
domain = [
["res_id", "=", channel_id],
["res_model", "=", "discuss.channel"],
]
if before:
domain.append(["id", "<", before])
return channel_member_sudo.env["ir.attachment"].search(domain, limit=limit, order="id DESC")._attachment_format()
+1
View File
@@ -76,6 +76,7 @@ class IrAttachment(models.Model):
safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari'
return [{
'checksum': attachment.checksum,
'create_date': attachment.create_date,
'id': attachment.id,
'filename': attachment.name,
'name': attachment.name,
@@ -3,6 +3,7 @@
import { Record } from "@mail/core/common/record";
import { assignDefined } from "@mail/utils/common/misc";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { url } from "@web/core/utils/urls";
export class Attachment extends Record {
@@ -25,6 +26,8 @@ export class Attachment extends Record {
uploading;
/** @type {import("@mail/core/common/message_model").Message} */
message;
/** @type {string} */
create_date;
/** @type {import("@mail/core/common/thread_model").Thread} */
get originThread() {
@@ -54,6 +57,14 @@ export class Attachment extends Record {
return this.mimetype && this.mimetype.startsWith("application/pdf");
}
get monthYear() {
if (!this.create_date) {
return undefined;
}
const datetime = deserializeDateTime(this.create_date);
return `${datetime.monthLong}, ${datetime.year}`;
}
get isImage() {
const imageMimetypes = [
"image/bmp",
@@ -38,6 +38,7 @@ export class AttachmentService {
update(attachment, data) {
assignDefined(attachment, data, [
"checksum",
"create_date",
"filename",
"mimetype",
"name",
@@ -0,0 +1,13 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
/**
* @typedef {Object} Props
* @property {string} date
* @property {string} [className]
*/
export class DateSection extends Component {
static template = "mail.DateSection";
static props = ["date", "className?"];
}
@@ -0,0 +1,3 @@
.o-mail-DateSection hr {
opacity: $hr-opacity / 2;
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.DateSection">
<div class="o-mail-DateSection d-flex align-items-center fw-bolder w-100" t-attf-class="{{ props.className }}">
<hr class="ms-3 flex-grow-1"/>
<span class="px-3 text-muted"><t t-esc="props.date"/></span>
<hr class="me-3 flex-grow-1"/>
</div>
</t>
</templates>
+2 -1
View File
@@ -1,5 +1,6 @@
/* @odoo-module */
import { DateSection } from "@mail/core/common/date_section";
import { Message } from "@mail/core/common/message";
import {
useAutoScroll,
@@ -35,7 +36,7 @@ export const PRESENT_THRESHOLD = 2500;
* @extends {Component<Props, Env>}
*/
export class Thread extends Component {
static components = { Message, Transition };
static components = { Message, Transition, DateSection };
static props = [
"isInChatWindow?",
"hasScrollAdjust?",
@@ -1,7 +1,3 @@
.o-mail-Thread-date hr {
opacity: $hr-opacity / 2;
}
.o-mail-Thread-newMessage {
transition: opacity 0.5s;
@@ -19,11 +19,7 @@
</t>
<t t-foreach="props.order === 'asc' ? props.thread.nonEmptyMessages : [...props.thread.nonEmptyMessages].reverse()" t-as="msg" t-key="msg.id">
<t t-if="msg.dateDay !== currentDay">
<div class="o-mail-Thread-date d-flex align-items-center fw-bolder pt-4">
<hr class="ms-3 flex-grow-1"/>
<span class="px-3 text-muted"><t t-esc="msg.dateDay"/></span>
<hr class="me-3 flex-grow-1"/>
</div>
<DateSection date="msg.dateDay" className="'pt-4'"/>
<t t-set="currentDay" t-value="msg.dateDay"/>
</t>
<Transition t-if="props.thread.isChannel" visible="props.thread.seen_message_id === (prevMsg?.id ?? false)" name="'o-fade'" t-slot-scope="transition">
@@ -204,6 +204,10 @@ export class Thread extends Record {
return ["channel", "group"].includes(this.type);
}
get hasAttachmentPanel() {
return this.model === "discuss.channel";
}
get isChatChannel() {
return ["chat", "group"].includes(this.type);
}
@@ -0,0 +1,50 @@
/* @odoo-module */
import { DateSection } from "@mail/core/common/date_section";
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
import { AttachmentList } from "@mail/core/common/attachment_list";
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { useVisible } from "@mail/utils/common/hooks";
/**
* @typedef {Object} Props
* @property {import("@mail/core/common/thread_model").Thread} thread
*/
export class AttachmentPanel extends Component {
static components = { ActionPanel, AttachmentList, DateSection };
static props = ["thread"];
static template = "mail.AttachmentPanel";
setup() {
this.threadService = useService("mail.thread");
this.attachmentUploadService = useService("mail.attachment_upload");
onWillStart(() => {
this.threadService.fetchMoreAttachments(this.props.thread);
});
onWillUpdateProps((nextProps) => {
if (nextProps.thread.notEq(this.props.thread)) {
this.threadService.fetchMoreAttachments(nextProps.thread);
}
});
const loadOlderState = useVisible("load-older", () => {
if (loadOlderState.isVisible) {
this.threadService.fetchMoreAttachments(this.props.thread);
}
});
}
/**
* @return {Object<string, import("@mail/core/common/attachment_model").Attachment[]>}
*/
get attachmentsByDate() {
const attachmentsByDate = {};
for (const attachment of this.props.thread.attachments) {
const attachments = attachmentsByDate[attachment.monthYear] ?? [];
attachments.push(attachment);
attachmentsByDate[attachment.monthYear] = attachments;
}
return attachmentsByDate;
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.AttachmentPanel">
<t t-set="title">Attachments</t>
<ActionPanel title="title">
<div class="flex-grow-1" t-att-class="{
'd-flex justify-content-center align-items-center': props.thread.attachments.length === 0,
}">
<p t-if="props.thread.attachments.length === 0" class="text-center fst-italic text-500">
<t t-if="props.thread.type === 'channel'">This channel doesn't have any attachments.</t>
<t t-else="">This conversation doesn't have any attachments.</t>
</p>
<div t-else="" t-foreach="attachmentsByDate" t-as="dateDay" t-key="dateDay" class="d-flex flex-column">
<DateSection date="dateDay" className="'my-1'"/>
<AttachmentList imagesHeight="100" attachments="dateDay_value" unlinkAttachment="(attachment) => this.attachmentUploadService.unlink(attachment)"/>
</div>
</div>
<span t-ref="load-older"/>
</ActionPanel>
</t>
</templates>
@@ -1,6 +1,7 @@
/* @odoo-module */
import { threadActionsRegistry } from "@mail/core/common/thread_actions";
import { AttachmentPanel } from "@mail/discuss/core/common/attachment_panel";
import { ChannelInvitation } from "@mail/discuss/core/common/channel_invitation";
import { ChannelMemberList } from "@mail/discuss/core/common/channel_member_list";
@@ -10,6 +11,18 @@ import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
threadActionsRegistry
.add("attachments", {
condition: (component) =>
component.thread?.hasAttachmentPanel &&
(!component.props.chatWindow || component.props.chatWindow.isOpen),
component: AttachmentPanel,
icon: "fa fa-fw fa-paperclip",
iconLarge: "fa fa-fw fa-lg fa-paperclip",
name: _t("Show Attachments"),
nameActive: _t("Hide Attachments"),
sequence: 25,
toggle: true,
})
.add("add-users", {
close(component, action) {
action.popover?.close();
@@ -27,4 +27,24 @@ patch(ThreadService.prototype, {
}
return super.post(...arguments);
},
async fetchMoreAttachments(thread, limit = 30) {
if (thread.isLoadingAttachments || thread.areAttachmentsLoaded) {
return;
}
thread.isLoadingAttachments = true;
try {
const rawAttachments = await this.rpc("/discuss/channel/attachments", {
before: Math.min(...thread.attachments.map(({ id }) => id)),
channel_id: thread.id,
limit,
});
const attachments = rawAttachments.map((a) => this.store.Attachment.insert(a));
if (attachments.length < limit) {
thread.areAttachmentsLoaded = true;
}
} finally {
thread.isLoadingAttachments = false;
}
},
});
@@ -20,7 +20,7 @@ threadActionsRegistry.add("pinned-messages", {
iconLarge: "fa fa-fw fa-lg fa-thumb-tack",
name: _t("Pinned Messages"),
nameActive: _t("Hide Pinned Messages"),
sequence: 29,
sequence: 20,
setup(action) {
useChildSubEnv({
pinMenu: {
@@ -146,8 +146,9 @@ QUnit.test("chat window: basic rendering", async (assert) => {
"There are no messages in this conversation."
);
await click("[title='Open Actions Menu']");
await contains(".o-mail-ChatWindow-command", 9);
await contains(".o-mail-ChatWindow-command", 10);
await contains("[title='Pinned Messages']");
await contains("[title='Show Attachments']");
await contains("[title='Add Users']");
await contains("[title='Show Member List']");
await contains("[title='Show Call Settings']");
@@ -0,0 +1,44 @@
/* @odoo-module */
import { click, contains, start, startServer } from "@mail/../tests/helpers/test_utils";
QUnit.module("attachment panel");
QUnit.test("Empty attachment panel", async () => {
const pyEnv = await startServer();
const channelId = await pyEnv["discuss.channel"].create({ name: "General" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click(".o-mail-Discuss-header button[title='Show Attachments']");
await contains(
".o-mail-Discuss-inspector:contains(This channel doesn't have any attachments.)"
);
});
QUnit.test("Attachment panel sort by date", async () => {
const pyEnv = await startServer();
const channelId = await pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["ir.attachment"].create([
{
res_id: channelId,
res_model: "discuss.channel",
name: "file1.pdf",
create_date: "2023-08-20",
},
{
res_id: channelId,
res_model: "discuss.channel",
name: "file2.pdf",
create_date: "2023-09-21",
},
]);
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click(".o-mail-Discuss-header button[title='Show Attachments']");
await contains(
".o-mail-DateSection:contains(August, 2023) + .o-mail-AttachmentList:contains(file1.pdf)"
);
await contains(
".o-mail-DateSection:contains(September, 2023) + .o-mail-AttachmentList:contains(file2.pdf)"
);
});
@@ -0,0 +1,41 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, {
/**
* @override
*/
async _performRPC(route, args) {
if (route === "/discuss/channel/attachments") {
return this._mockRouteDiscussChannelAttachments(
args.channel_id,
args.limit,
args.older_attachment_id
);
}
return super._performRPC(route, args);
},
/**
* Simulates the `/discuss/channel/attachments` route.
*
* @param {number} channelId
* @param {number} [limit=30]
* @param {number} [olderAttachmentId]
*/
_mockRouteDiscussChannelAttachments(channelId, limit = 30, olderAttachmentId = null) {
const attachmentIds = this.models["ir.attachment"].records
.filter(
({ id, res_id, res_model }) =>
res_id === channelId &&
res_model === "discuss.channel" &&
(!olderAttachmentId || id < olderAttachmentId)
)
.sort()
.slice(0, limit)
.map(({ id }) => id);
return this._mockIrAttachment_attachmentFormat(attachmentIds);
},
});
@@ -22,6 +22,7 @@ patch(MockServer.prototype, {
const attachments = this.mockRead("ir.attachment", [ids]);
return attachments.map((attachment) => {
const res = {
create_date: attachment.create_date,
checksum: attachment.checksum,
filename: attachment.name,
id: attachment.id,
@@ -128,7 +128,7 @@ QUnit.test("display day separator before first message of the day", async () =>
]);
const { openDiscuss } = await start();
openDiscuss(channelId);
await contains(".o-mail-Thread-date");
await contains(".o-mail-DateSection");
});
QUnit.test("do not display day separator if all messages of the day are empty", async () => {
@@ -142,7 +142,7 @@ QUnit.test("do not display day separator if all messages of the day are empty",
const { openDiscuss } = await start();
openDiscuss(channelId);
await contains(".o-mail-Thread-empty");
await contains(".o-mail-Thread-date", 0);
await contains(".o-mail-DateSection", 0);
});
QUnit.test("scroll position is kept when navigating from one channel to another", async () => {
@@ -3,7 +3,7 @@
import json
import odoo
from odoo.tools import mute_logger
from odoo.tools import mute_logger, date_utils
from odoo.tests import HttpCase
@@ -96,7 +96,7 @@ class TestMessageController(HttpCase):
message_format1 = res2.json()["result"]
self.assertEqual(
message_format1["attachment_ids"],
json.loads(json.dumps(self.attachments[0]._attachment_format())),
json.loads(json.dumps(self.attachments[0]._attachment_format(), default=date_utils.json_default)),
"guest should be allowed to add attachment with token when posting message",
)
# test message update: token error
@@ -139,7 +139,7 @@ class TestMessageController(HttpCase):
message_format2 = res4.json()["result"]
self.assertEqual(
message_format2["attachment_ids"],
json.loads(json.dumps(self.attachments.sorted("id")._attachment_format())),
json.loads(json.dumps(self.attachments.sorted("id")._attachment_format(), default=date_utils.json_default)),
"guest should be allowed to add attachment with token when updating message",
)
# test message update: own attachment ok
@@ -160,7 +160,7 @@ class TestMessageController(HttpCase):
message_format3 = res5.json()["result"]
self.assertEqual(
message_format3["attachment_ids"],
json.loads(json.dumps(self.attachments.sorted("id")._attachment_format())),
json.loads(json.dumps(self.attachments.sorted("id")._attachment_format(), default=date_utils.json_default)),
"guest should be allowed to add own attachment without token when updating message",
)