[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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user