[FIX] mail: ask for confirmation before attachment deletion

also hide all messages with empty body and no attachments

task-2282322

closes odoo/odoo#59675

X-original-commit: 60ae42dcda777cd66e4e421dbc02f9ef12a430ef
Related: odoo/enterprise#13974
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Xavier Dubuc
2020-10-09 17:28:17 +00:00
committed by Sébastien Theys
parent 30036759e8
commit ec3f5cdaf8
22 changed files with 545 additions and 61 deletions
@@ -3,7 +3,11 @@ odoo.define('mail/static/src/components/attachment/attachment.js', function (req
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
const { Component } = owl;
const components = {
AttachmentDeleteConfirmDialog: require('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js'),
};
const { Component, useState } = owl;
class Attachment extends Component {
@@ -18,6 +22,9 @@ class Attachment extends Component {
attachment: attachment ? attachment.__state : undefined,
};
});
this.state = useState({
hasDeleteConfirmDialog: false,
});
}
//--------------------------------------------------------------------------
@@ -125,13 +132,24 @@ class Attachment extends Component {
*/
_onClickUnlink(ev) {
ev.stopPropagation();
this.attachment.remove();
this.trigger('o-attachment-removed');
if (this.attachment.isLinkedToComposer) {
this.attachment.remove();
this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId });
} else {
this.state.hasDeleteConfirmDialog = true;
}
}
/**
* @private
*/
_onDeleteConfirmDialogClosed() {
this.state.hasDeleteConfirmDialog = false;
}
}
Object.assign(Attachment, {
components,
defaultProps: {
attachmentLocalIds: [],
detailsMode: 'auto',
@@ -102,6 +102,12 @@
</t>
</div>
</t>
<t t-if="state.hasDeleteConfirmDialog">
<AttachmentDeleteConfirmDialog
attachmentLocalId="props.attachmentLocalId"
t-on-dialog-closed="_onDeleteConfirmDialogClosed"
/>
</t>
</t>
</div>
</t>
@@ -284,6 +284,54 @@ QUnit.test('view attachments', async function (assert) {
);
});
QUnit.test('remove attachment should ask for confirmation', async function (assert) {
assert.expect(5);
await this.start();
const thread = this.env.models['mail.thread'].create({
attachments: [
['insert', {
id: 143,
mimetype: 'text/plain',
name: 'Blah.txt'
}],
],
id: 100,
model: 'res.partner',
});
await this.createAttachmentBoxComponent(thread);
assert.containsOnce(
document.body,
'.o_Attachment',
"should have an attachment",
);
assert.containsOnce(
document.body,
'.o_Attachment_actionUnlink',
"attachment should have a delete button"
);
await afterNextRender(() => document.querySelector('.o_Attachment_actionUnlink').click());
assert.containsOnce(
document.body,
'.o_AttachmentDeleteConfirmDialog',
"A confirmation dialog should have been opened"
);
assert.strictEqual(
document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent,
`Do you really want to delete "Blah.txt"?`,
"Confirmation dialog should contain the attachment delete confirmation text"
);
// Confirm the deletion
await afterNextRender(() => document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click());
assert.containsNone(
document.body,
'.o_Attachment',
"should no longer have an attachment",
);
});
});
});
});
@@ -1,6 +1,90 @@
odoo.define('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js', function (require) {
'use strict';
// TODO FIXME file to be completed in task-2282322
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
const components = {
Dialog: require('web.OwlDialog'),
};
const { Component } = owl;
const { useRef } = owl.hooks;
class AttachmentDeleteConfirmDialog extends Component {
/**
* @override
*/
constructor(...args) {
super(...args);
useStore(props => {
const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId);
return {
attachment: attachment ? attachment.__state : undefined,
};
});
// to manually trigger the dialog close event
this._dialogRef = useRef('dialog');
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {mail.attachment}
*/
get attachment() {
return this.env.models['mail.attachment'].get(this.props.attachmentLocalId);
}
/**
* @returns {string}
*/
getBody() {
return _.str.sprintf(
this.env._t(`Do you really want to delete "%s"?`),
owl.utils.escape(this.attachment.displayName)
);
}
/**
* @returns {string}
*/
getTitle() {
return this.env._t("Confirmation");
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClickCancel() {
this._dialogRef.comp._close();
}
/**
* @private
*/
_onClickOk() {
this._dialogRef.comp._close();
this.attachment.remove();
this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId });
}
}
Object.assign(AttachmentDeleteConfirmDialog, {
components,
props: {
attachmentLocalId: String,
},
template: 'mail.AttachmentDeleteConfirmDialog',
});
return AttachmentDeleteConfirmDialog;
});
@@ -1,4 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- TODO FIXME file to be completed in task-2282322 -->
<t t-name="mail.AttachmentDeleteConfirmDialog" owl="1">
<Dialog contentClass="'o_AttachmentDeleteConfirmDialog'" title="getTitle()" size="'medium'" t-ref="dialog">
<p class="o_AttachmentDeleteConfirmDialog_mainText" t-esc="getBody()"/>
<t t-set-slot="buttons">
<button class="o_AttachmentDeleteConfirmDialog_confirmButton btn btn-primary" t-on-click="_onClickOk">Ok</button>
<button class="o_AttachmentDeleteConfirmDialog_cancelButton btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
</t>
</Dialog>
</t>
</templates>
@@ -802,9 +802,8 @@ QUnit.test('[technical] chat window: scroll conservation on toggle home menu', a
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i < 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
});
}
await this.start();
@@ -1543,9 +1542,8 @@ QUnit.test('chat window with a thread: keep scroll position in message list on f
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i < 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
});
}
await this.start();
@@ -1588,9 +1586,8 @@ QUnit.test('chat window should scroll to the newly posted message just after pos
});
for (let i = 0; i < 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
});
}
await this.start();
@@ -1710,9 +1707,8 @@ QUnit.test('[technical] chat window with a thread: keep scroll position in messa
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i < 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
});
}
await this.start();
@@ -47,6 +47,7 @@ QUnit.test('base rendering when chatter has no attachment', async function (asse
this.data['res.partner'].records.push({ id: 100 });
for (let i = 0; i < 60; i++) {
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner',
res_id: 100,
});
@@ -1746,6 +1746,44 @@ QUnit.test('warning on send with shortcut when attempting to post message with s
);
});
QUnit.test('remove an attachment from composer does not need any confirmation', async function (assert) {
assert.expect(3);
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
const file = await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text.txt',
});
await afterNextRender(() =>
inputFiles(
document.querySelector('.o_FileUploader_input'),
[file]
)
);
assert.containsOnce(
document.body,
'.o_Composer_attachmentList',
"should have an attachment list"
);
assert.containsOnce(
document.body,
'.o_Composer .o_Attachment',
"should have only one attachment"
);
await afterNextRender(() =>
document.querySelector('.o_Attachment_asideItemUnlink').click()
);
assert.containsNone(
document.body,
'.o_Composer .o_Attachment',
"should not have any attachment left after unlinking the only one"
);
});
QUnit.test('remove an uploading attachment', async function (assert) {
assert.expect(4);
@@ -44,6 +44,7 @@ QUnit.test('reply: discard on pressing escape', async function (assert) {
});
// message expected to be found in inbox
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner',
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -143,6 +144,7 @@ QUnit.test('reply: discard on discard button click', async function (assert) {
assert.expect(4);
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner',
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -183,6 +185,7 @@ QUnit.test('reply: discard on reply button toggle', async function (assert) {
assert.expect(3);
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner',
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -218,6 +221,7 @@ QUnit.test('reply: discard on click away', async function (assert) {
assert.expect(7);
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner',
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -284,6 +288,7 @@ QUnit.test('"reply to" composer should log note if message replied to is a note'
assert.expect(6);
this.data['mail.message'].records.push({
body: "not empty",
is_discussion: false,
model: 'res.partner',
needaction: true,
@@ -336,6 +341,7 @@ QUnit.test('"reply to" composer should send message if message replied to is not
assert.expect(6);
this.data['mail.message'].records.push({
body: "not empty",
is_discussion: true,
model: 'res.partner',
needaction: true,
@@ -388,6 +394,7 @@ QUnit.test('error notifications should not be shown in Inbox', async function (a
assert.expect(3);
this.data['mail.message'].records.push({
body: "not empty",
id: 100,
model: 'mail.channel',
needaction: true,
@@ -422,6 +429,7 @@ QUnit.test('show subject of message in Inbox', async function (assert) {
assert.expect(3);
this.data['mail.message'].records.push({
body: "not empty",
model: 'mail.channel', // random existing model
needaction: true, // message_fetch domain
needaction_partner_ids: [this.data.currentPartnerId], // not needed, for consistency
@@ -449,6 +457,7 @@ QUnit.test('show subject of message in history', async function (assert) {
assert.expect(3);
this.data['mail.message'].records.push({
body: "not empty",
history_partner_ids: [3], // not needed, for consistency
model: 'mail.channel', // random existing model
subject: "Salutations, voyageur", // will be asserted in the test
@@ -509,6 +518,7 @@ QUnit.test('click on (non-channel/non-partner) origin thread link should redirec
});
this.data['some.model'] = { fields: {}, records: [{ id: 10 }] };
this.data['mail.message'].records.push({
body: "not empty",
model: 'some.model',
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -747,6 +747,7 @@ QUnit.test('as author, sent message accepted in moderated channel', async functi
name: "general", // random name, will be asserted in the test
});
this.data['mail.message'].records.push({
body: "not empty",
id: 100, // random unique id, will be referenced in the test
model: 'mail.channel', // expected value to link message to channel
moderation_status: 'pending_moderation', // message is expected to be pending
@@ -820,6 +821,7 @@ QUnit.test('as author, sent message rejected in moderated channel', async functi
name: "general", // random name, will be asserted in the test
});
this.data['mail.message'].records.push({
body: "not empty",
id: 100, // random unique id, will be referenced in the test
model: 'mail.channel', // expected value to link message to channel
moderation_status: 'pending_moderation', // message is expected to be pending
@@ -884,6 +886,7 @@ QUnit.test('as moderator, pending moderation message accessibility', async funct
moderation: true, // channel must be moderated to test the feature
});
this.data['mail.message'].records.push({
body: "not empty",
id: 100, // random unique id, will be referenced in the test
model: 'mail.channel', // expected value to link message to channel
moderation_status: 'pending_moderation', // message is expected to be pending
@@ -936,6 +939,7 @@ QUnit.test('as author, pending moderation message should appear in origin thread
});
this.data['mail.message'].records.push({
author_id: this.data.currentPartnerId, // test as author of message
body: "not empty",
id: 100, // random unique id, will be referenced in the test
model: 'mail.channel', // expected value to link message to channel
moderation_status: 'pending_moderation', // message is expected to be pending
@@ -983,6 +987,7 @@ QUnit.test('as moderator, new pending moderation message posted by someone else'
// simulate receiving the message
const messageData = {
author_id: 10, // random id, different than current partner
body: "not empty",
channel_ids: [], // server do NOT return channel_id of the message if pending moderation
id: 1, // random unique id
model: 'mail.channel', // expected value to link message to channel
@@ -1027,16 +1032,19 @@ QUnit.test('accept multiple moderation messages', async function (assert) {
});
this.data['mail.message'].records.push(
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
},
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
},
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
@@ -1100,16 +1108,19 @@ QUnit.test('accept multiple moderation messages after having accepted other mess
});
this.data['mail.message'].records.push(
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
},
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
},
{
body: "not empty",
model: 'mail.channel',
moderation_status: 'pending_moderation',
res_id: 20,
@@ -513,6 +513,7 @@ QUnit.test('sidebar: channel rendering with needaction counter', async function
this.data['mail.channel'].records.push({ id: 20 });
// expected needaction message
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20], // link message to channel
id: 100, // random unique id, useful to link notification
});
@@ -1186,6 +1187,7 @@ QUnit.test('load single message from channel initially', async function (assert)
// channel expected to be rendered, with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20 });
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
date: "2019-04-20 10:00:00",
id: 100,
@@ -1554,6 +1556,7 @@ QUnit.test('load all messages from channel initially, less than fetch limit (29
this.data['res.partner'].records.push({ id: 11 });
for (let i = 28; i >= 0; i--) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
date: "2019-04-20 10:00:00",
model: 'mail.channel',
@@ -1613,6 +1616,7 @@ QUnit.test('load more messages from channel', async function (assert) {
this.data['res.partner'].records.push({ id: 11 });
for (let i = 0; i < 40; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
date: "2019-04-20 10:00:00",
model: 'mail.channel',
@@ -1684,6 +1688,7 @@ QUnit.test('auto-scroll to bottom of thread', async function (assert) {
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 1; i <= 25; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
@@ -1719,6 +1724,7 @@ QUnit.test('load more messages from channel (auto-load on scroll)', async functi
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i < 40; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
model: 'mail.channel',
res_id: 20,
@@ -1784,6 +1790,7 @@ QUnit.test('new messages separator [REQUIRE FOCUS]', async function (assert) {
});
for (let i = 1; i <= 25; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
id: 100 + i, // for setting proper value for seen_message_id
model: 'mail.channel',
@@ -1886,6 +1893,7 @@ QUnit.test('restore thread scroll position', async function (assert) {
this.data['mail.channel'].records.push({ id: 11 }, { id: 12 });
for (let i = 1; i <= 25; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [11],
model: 'mail.channel',
res_id: 11,
@@ -1893,6 +1901,7 @@ QUnit.test('restore thread scroll position', async function (assert) {
}
for (let i = 1; i <= 24; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [12],
model: 'mail.channel',
res_id: 12,
@@ -2036,6 +2045,7 @@ QUnit.test('message origin redirect to channel', async function (assert) {
this.data['mail.channel'].records.push({ id: 11 }, { id: 12 });
this.data['mail.message'].records.push(
{
body: "not empty",
channel_ids: [11, 12],
id: 100,
model: 'mail.channel',
@@ -2043,6 +2053,7 @@ QUnit.test('message origin redirect to channel', async function (assert) {
res_id: 11,
},
{
body: "not empty",
channel_ids: [11, 12],
id: 101,
model: 'mail.channel',
@@ -2245,6 +2256,7 @@ QUnit.test('redirect to author (open chat)', async function (assert) {
this.data['mail.message'].records.push(
{
author_id: 7,
body: "not empty",
channel_ids: [1],
id: 100,
model: 'mail.channel',
@@ -2483,6 +2495,7 @@ QUnit.test('inbox: mark all messages as read', async function (assert) {
this.data['mail.message'].records.push(
// first expected message
{
body: "not empty",
channel_ids: [20], // link message to channel
id: 100, // random unique id, useful to link notification
// needaction needs to be set here for message_fetch domain, because
@@ -2491,6 +2504,7 @@ QUnit.test('inbox: mark all messages as read', async function (assert) {
},
// second expected message
{
body: "not empty",
channel_ids: [20], // link message to channel
id: 101, // random unique id, useful to link notification
// needaction needs to be set here for message_fetch domain, because
@@ -2586,8 +2600,8 @@ QUnit.test('starred: unstar all', async function (assert) {
// messages expected to be starred
this.data['mail.message'].records.push(
{ starred_partner_ids: [this.data.currentPartnerId] },
{ starred_partner_ids: [this.data.currentPartnerId] }
{ body: "not empty", starred_partner_ids: [this.data.currentPartnerId] },
{ body: "not empty", starred_partner_ids: [this.data.currentPartnerId] }
);
await this.start({
discuss: {
@@ -2647,6 +2661,7 @@ QUnit.test('toggle_star message', async function (assert) {
// with a random unique id, will be referenced in the test
this.data['mail.channel'].records.push({ id: 20 });
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
id: 100,
model: 'mail.channel',
@@ -3176,6 +3191,7 @@ QUnit.test('rendering of inbox message', async function (assert) {
assert.expect(7);
this.data['mail.message'].records.push({
body: "not empty",
model: 'res.partner', // random existing model
needaction: true, // for message_fetch domain
needaction_partner_ids: [this.data.currentPartnerId], // for consistency
@@ -3228,6 +3244,7 @@ QUnit.test('mark channel as seen on last message visible [REQUIRE FOCUS]', async
// and a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 10, message_unread_counter: 1 });
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [10],
model: 'mail.channel',
res_id: 10,
@@ -3318,6 +3335,7 @@ QUnit.test('receive new needaction messages', async function (assert) {
// simulate receiving a new needaction message
await afterNextRender(() => {
const data = {
body: "not empty",
id: 100,
needaction_partner_ids: [3],
model: 'res.partner',
@@ -3359,6 +3377,7 @@ QUnit.test('receive new needaction messages', async function (assert) {
// simulate receiving another new needaction message
await afterNextRender(() => {
const data2 = {
body: "not empty",
id: 101,
needaction_partner_ids: [3],
model: 'res.partner',
@@ -3533,6 +3552,7 @@ QUnit.test('load recent messages from thread (already loaded some old messages)'
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i < 50; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20], // id of related channel
id: 100 + i, // random unique id, will be referenced in the test
model: 'mail.channel', // expected value to link message to channel
@@ -3610,6 +3630,7 @@ QUnit.test('messages marked as read move to "History" mailbox', async function (
// expected messages
this.data['mail.message'].records.push(
{
body: "not empty",
id: 100, // random unique id, useful to link notification
model: 'mail.channel', // value to link message to channel
// needaction needs to be set here for message_fetch domain, because
@@ -3618,6 +3639,7 @@ QUnit.test('messages marked as read move to "History" mailbox', async function (
res_id: 20, // id of related channel
},
{
body: "not empty",
id: 101, // random unique id, useful to link notification
model: 'mail.channel', // value to link message to channel
// needaction needs to be set here for message_fetch domain, because
@@ -3734,11 +3756,13 @@ QUnit.test('mark a single message as read should only move this message to "Hist
this.data['mail.message'].records.push(
{
body: "not empty",
id: 1,
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
},
{
body: "not empty",
id: 2,
needaction: true,
needaction_partner_ids: [this.data.currentPartnerId],
@@ -3856,6 +3880,7 @@ QUnit.test('all messages in "Inbox" in "History" after marked all as read', asyn
for (let id = messageOffset; id < messageOffset + 40; id++) {
// message expected to be found in Inbox
this.data['mail.message'].records.push({
body: "not empty",
id, // will be used to link notification to message
// needaction needs to be set here for message_fetch domain, because
// mocked models don't have computed fields
@@ -708,7 +708,7 @@ QUnit.test('only show messaging seen indicator if authored by me, after last see
});
QUnit.test('allow attachment delete on authored message', async function (assert) {
assert.expect(3);
assert.expect(5);
await this.start();
const message = this.env.models['mail.message'].create({
@@ -735,6 +735,20 @@ QUnit.test('allow attachment delete on authored message', async function (assert
);
await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click());
assert.containsOnce(
document.body,
'.o_AttachmentDeleteConfirmDialog',
"An attachment delete confirmation dialog should have been opened"
);
assert.strictEqual(
document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent,
`Do you really want to delete "BLAH"?`,
"Confirmation dialog should contain the attachment delete confirmation text"
);
await afterNextRender(() =>
document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
);
assert.containsNone(
document.body,
'.o_Attachment',
@@ -107,7 +107,7 @@ class MessageList extends Component {
* Because of this, the scroll position must be changed when whole UI
* is rendered. To make this simpler, this is done when <ThreadView/>
* component is patched. This is acceptable when <ThreadView/> has a
* fixed height, which is the case for the moment.
* fixed height, which is the case for the moment. task-2358066
*/
async adjustFromComponentHints() {
if (!this.threadView) {
@@ -418,9 +418,6 @@ class MessageList extends Component {
if (this.props.order === 'asc' && this.props.hasScrollAdjust) {
this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop;
}
this.env.messagingBus.trigger('o-component-message-list-more-messages-loaded', {
threadViewer: this.threadView.threadViewer,
});
this.threadView.markComponentHintProcessed(hint);
}
@@ -49,36 +49,38 @@
</div>
</t>
<t t-foreach="orderedMessages" t-as="message" t-key="message.localId">
<t t-set="message_day" t-value="getDateDay(message)"/>
<t t-if="current_day !== message_day">
<div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item">
<hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/>
<t t-set="current_day" t-value="message_day"/>
<t t-set="isMessageSquashed" t-value="false"/>
</div>
</t>
<t t-else="">
<t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/>
</t>
<Message
class="o_MessageList_item o_MessageList_message"
t-att-class="{
'o-has-message-selection': props.selectedMessageLocalId,
}"
hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
hasCheckbox="props.hasMessageCheckbox"
hasReplyIcon="props.haveMessagesReplyIcon"
isSelected="props.selectedMessageLocalId === message.localId"
isSquashed="isMessageSquashed"
messageLocalId="message.localId"
threadViewLocalId="threadView.localId"
t-ref="{{ message.localId }}"
/>
<t t-set="prev_message" t-value="message"/>
<t t-if="threadView.thread.model === 'mail.channel' and threadView.thread.localMessageUnreadCounter > 0 and threadView.thread.lastSeenByCurrentPartnerMessageId === message.id">
<div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
<hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
</div>
<t t-if="!message.isEmpty">
<t t-set="message_day" t-value="getDateDay(message)"/>
<t t-if="current_day !== message_day">
<div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item">
<hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/>
<t t-set="current_day" t-value="message_day"/>
<t t-set="isMessageSquashed" t-value="false"/>
</div>
</t>
<t t-else="">
<t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/>
</t>
<Message
class="o_MessageList_item o_MessageList_message"
t-att-class="{
'o-has-message-selection': props.selectedMessageLocalId,
}"
hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
hasCheckbox="props.hasMessageCheckbox"
hasReplyIcon="props.haveMessagesReplyIcon"
isSelected="props.selectedMessageLocalId === message.localId"
isSquashed="isMessageSquashed"
messageLocalId="message.localId"
threadViewLocalId="threadView.localId"
t-ref="{{ message.localId }}"
/>
<t t-set="prev_message" t-value="message"/>
<t t-if="threadView.thread.model === 'mail.channel' and threadView.thread.localMessageUnreadCounter > 0 and threadView.thread.lastSeenByCurrentPartnerMessageId === message.id">
<div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
<hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
</div>
</t>
</t>
</t>
<!-- LOADING (if order desc)-->
@@ -3,7 +3,9 @@ odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dia
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
const Dialog = require('web.OwlDialog');
const components = {
Dialog: require('web.OwlDialog'),
};
const { Component } = owl;
const { useRef } = owl.hooks;
@@ -71,7 +73,7 @@ class ModerationBanDialog extends Component {
}
Object.assign(ModerationBanDialog, {
components: { Dialog },
components,
props: {
messageLocalIds: {
type: Array,
@@ -3,7 +3,9 @@ odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_dis
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
const Dialog = require('web.OwlDialog');
const components = {
Dialog: require('web.OwlDialog'),
};
const { Component } = owl;
const { useRef } = owl.hooks;
@@ -38,7 +40,7 @@ class ModerationDiscardDialog extends Component {
/**
* @returns {string}
*/
getText() {
getBody() {
if (this.messages.length === 1) {
return this.env._t("You are going to discard 1 message.");
}
@@ -60,7 +62,7 @@ class ModerationDiscardDialog extends Component {
/**
* @returns {string}
*/
get CONFIRMATION() {
getTitle() {
return this.env._t("Confirmation");
}
@@ -86,7 +88,7 @@ class ModerationDiscardDialog extends Component {
}
Object.assign(ModerationDiscardDialog, {
components: { Dialog },
components,
props: {
messageLocalIds: {
type: Array,
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ModerationDiscardDialog" owl="1">
<Dialog contentClass="'o_ModerationDiscardDialog'" title="CONFIRMATION" size="'medium'" t-ref="dialog">
<p t-esc="getText()"/>
<Dialog contentClass="'o_ModerationDiscardDialog'" title="getTitle()" size="'medium'" t-ref="dialog">
<p t-esc="getBody()"/>
<p>Do you confirm the action?</p>
<t t-set-slot="buttons">
<button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button>
@@ -3,7 +3,9 @@ odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reje
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
const Dialog = require('web.OwlDialog');
const components = {
Dialog: require('web.OwlDialog'),
};
const { Component, useState } = owl;
const { useRef } = owl.hooks;
@@ -81,7 +83,7 @@ class ModerationRejectDialog extends Component {
}
Object.assign(ModerationRejectDialog, {
components: { Dialog },
components,
props: {
messageLocalIds: {
type: Array,
@@ -101,6 +101,7 @@ QUnit.test('message list desc order', async function (assert) {
for (let i = 0; i <= 60; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [100],
model: 'mail.channel',
res_id: 100,
@@ -172,6 +173,7 @@ QUnit.test('message list asc order', async function (assert) {
for (let i = 0; i <= 60; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [100],
model: 'mail.channel',
res_id: 100,
@@ -389,6 +391,7 @@ QUnit.test('show message subject if thread is mailing channel', async function (
assert.expect(3);
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [100],
model: 'mail.channel',
res_id: 100,
@@ -717,6 +720,7 @@ QUnit.test('basic rendering of canceled notification', async function (assert) {
this.data['mail.channel'].records.push({ id: 11 });
this.data['res.partner'].records.push({ id: 12, name: "Someone" });
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [11],
id: 10,
message_type: 'email',
@@ -817,6 +821,7 @@ QUnit.test('should scroll to bottom on receiving new message if the list is init
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i <= 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
});
}
@@ -887,6 +892,7 @@ QUnit.test('should not scroll on receiving new message if the list is initially
this.data['mail.channel'].records.push({ id: 20 });
for (let i = 0; i <= 10; i++) {
this.data['mail.message'].records.push({
body: "not empty",
channel_ids: [20],
});
}
@@ -952,6 +958,186 @@ QUnit.test('should not scroll on receiving new message if the list is initially
);
});
QUnit.test("delete all attachments of message without content should no longer display the message", async function (assert) {
assert.expect(2);
this.data['ir.attachment'].records.push({
id: 143,
mimetype: 'text/plain',
name: "Blah.txt",
});
this.data['mail.channel'].records.push({ id: 11 });
this.data['mail.message'].records.push(
{
attachment_ids: [143],
channel_ids: [11],
id: 101,
}
);
await this.start();
const threadViewer = this.env.models['mail.thread_viewer'].create({
hasThreadView: true,
thread: [['insert', { id: 11, model: 'mail.channel' }]],
});
// wait for messages of the thread to be loaded
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
this.createThreadViewComponent(threadViewer.threadView);
},
message: "thread become loaded with messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 11
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"there should be 1 message displayed initially"
);
await afterNextRender(() => {
document.querySelector(`.o_Attachment[data-attachment-local-id="${
this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
}"] .o_Attachment_asideItemUnlink`).click();
});
await afterNextRender(() =>
document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
);
assert.containsNone(
document.body,
'.o_Message',
"message should no longer be displayed after removing all its attachments (empty content)"
);
});
QUnit.test('delete all attachments of a message with some text content should still keep it displayed', async function (assert) {
assert.expect(2);
this.data['ir.attachment'].records.push({
id: 143,
mimetype: 'text/plain',
name: "Blah.txt",
});
this.data['mail.channel'].records.push({ id: 11 });
this.data['mail.message'].records.push(
{
attachment_ids: [143],
body: "Some content",
channel_ids: [11],
id: 101,
},
);
await this.start();
const threadViewer = this.env.models['mail.thread_viewer'].create({
hasThreadView: true,
thread: [['insert', { id: 11, model: 'mail.channel' }]],
});
// wait for messages of the thread to be loaded
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
this.createThreadViewComponent(threadViewer.threadView);
},
message: "thread become loaded with messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 11
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"there should be 1 message displayed initially"
);
await afterNextRender(() => {
document.querySelector(`.o_Attachment[data-attachment-local-id="${
this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
}"] .o_Attachment_asideItemUnlink`).click();
});
await afterNextRender(() =>
document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
);
assert.containsOnce(
document.body,
'.o_Message',
"message should still be displayed after removing its attachments (non-empty content)"
);
});
QUnit.test('delete all attachments of a message with tracking fields should still keep it displayed', async function (assert) {
assert.expect(2);
this.data['ir.attachment'].records.push({
id: 143,
mimetype: 'text/plain',
name: "Blah.txt",
});
this.data['mail.channel'].records.push({ id: 11 });
this.data['mail.message'].records.push(
{
attachment_ids: [143],
channel_ids: [11],
id: 101,
tracking_value_ids: [6]
},
);
this.data['mail.tracking.value'].records.push({
changed_field: "Name",
field_type: "char",
id: 6,
new_value: "New name",
old_value: "Old name",
});
await this.start();
const threadViewer = this.env.models['mail.thread_viewer'].create({
hasThreadView: true,
thread: [['insert', { id: 11, model: 'mail.channel' }]],
});
// wait for messages of the thread to be loaded
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
this.createThreadViewComponent(threadViewer.threadView);
},
message: "thread become loaded with messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 11
);
},
});
assert.containsOnce(
document.body,
'.o_Message',
"there should be 1 message displayed initially"
);
await afterNextRender(() => {
document.querySelector(`.o_Attachment[data-attachment-local-id="${
this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
}"] .o_Attachment_asideItemUnlink`).click();
});
await afterNextRender(() =>
document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
);
assert.containsOnce(
document.body,
'.o_Message',
"message should still be displayed after removing its attachments (non-empty content)"
);
});
});
});
});
@@ -401,6 +401,17 @@ function factory(dependencies) {
return inlineBody.toLowerCase() === this.subtype_description.toLowerCase();
}
/**
* @private
*/
_computeIsEmpty() {
return (
(!this.body || htmlToTextContentInline(this.body) === '') &&
this.attachments.length === 0 &&
this.tracking_value_ids.length === 0
);
}
/**
* @private
* @returns {boolean}
@@ -551,6 +562,19 @@ function factory(dependencies) {
'subtype_description',
],
}),
/**
* Determine whether the message has to be considered empty or not.
*
* An empty message has no text, no attachment and no tracking value.
*/
isEmpty: attr({
compute: '_computeIsEmpty',
dependencies: [
'attachments',
'body',
'tracking_value_ids',
],
}),
isModeratedByCurrentPartner: attr({
compute: '_computeIsModeratedByCurrentPartner',
default: false,
@@ -463,7 +463,7 @@ QUnit.test('basic chatter rendering without messages', async function (assert) {
QUnit.test('chatter updating', async function (assert) {
assert.expect(3);
this.data['mail.message'].records.push({ model: 'res.partner', res_id: 12 });
this.data['mail.message'].records.push({ body: "not empty", model: 'res.partner', res_id: 12 });
this.data['res.partner'].records.push(
{ display_name: "first partner", id: 11 },
{ display_name: "second partner", id: 12 }
@@ -136,6 +136,7 @@ class MockModels {
res_model_name: { string: "Res Model Name", type: 'char' },
starred_partner_ids: { string: "Favorited By", type: 'many2many', relation: 'res.partner' },
subject: { string: "Subject", type: 'char' },
tracking_value_ids: { relation: 'mail.tracking.value', string: "Tracking values", type: 'one2many' },
},
records: [],
},
@@ -177,6 +178,15 @@ class MockModels {
},
records: [],
},
'mail.tracking.value': {
fields: {
changed_field: { string: 'Changed field', type: 'char' },
field_type: { string: 'Field type', type: 'char' },
new_value: { string: 'New value', type: 'char' },
old_value: { string: 'Old value', type: 'char' },
},
records: [],
},
'res.country': {
fields: {
code: { string: "Code", type: 'char' },