[IMP] mail: chatter file drag and drop

PURPOSE

The attachment icon in the chatter is too discrete, a lot of users easily miss
it.

SPECIFICATIONS

- Allow for file upload through drag and drop and the whole chatter.
  It doesn't matter whether the file tray is unfolded.
  Hence, removed DropZone on attachmentBox as now have DropZone on
  the whole chatter.
- On the file tray, rename "Attachments" to "Files" and
  "Add attachments" to "Attach files".
- When there are files attached to the record, display "X files".
- when there is no file yet, display 'Attach files'. Although clicking on it
  opens the file explorer, but doesn't unfold the tray yet. Once the file is
  submitted, the file tray unfolds to show confirmed upload to the user.

Task-2413814

closes odoo/odoo#91335

Related: odoo/enterprise#27763
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Hiral Bhavsar
2022-06-01 14:16:01 +02:00
parent 7144d2fe7c
commit a35c71690a
15 changed files with 190 additions and 145 deletions
@@ -7,17 +7,11 @@
<div class="o_AttachmentBox_title d-flex align-items-center">
<hr class="o_AttachmentBox_dashedLine flex-grow-1"/>
<span class="o_AttachmentBox_titleText p-3 font-weight-bold">
Attachments
Files
</span>
<hr class="o_AttachmentBox_dashedLine flex-grow-1"/>
</div>
<div class="o_AttachmentBox_content d-flex flex-column">
<t t-if="attachmentBoxView.dropZoneView">
<DropZone
className="'o_AttachmentBox_dropZone'"
record="attachmentBoxView.dropZoneView"
/>
</t>
<t t-if="attachmentBoxView.attachmentList">
<AttachmentList
className="'o_attachmentBox_attachmentList'"
@@ -26,7 +20,7 @@
</t>
<button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="attachmentBoxView.onClickAddAttachment">
<i class="fa fa-plus-square"/>
Add attachments
Attach files
</button>
</div>
</div>
@@ -4,6 +4,12 @@
<t t-name="mail.Chatter" owl="1">
<t t-if="chatter">
<div class="o_Chatter position-relative flex-grow-1 flex-column d-flex w-100 bg-white" t-attf-class="{{ className }}" t-ref="root">
<t t-if="chatter.dropZoneView">
<DropZone
className="'o_Chatter_dropZone'"
record="chatter.dropZoneView"
/>
</t>
<div class="o_Chatter_fixedPanel">
<ChatterTopbar
className="'o_Chatter_topbar'"
@@ -46,7 +46,7 @@
<button class="o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments btn btn-link" type="button" t-att-disabled="chatterTopbar.chatter.isDisabled" t-on-click="chatterTopbar.chatter.onClickButtonAttachments" title="Show Attachments">
<i class="fa fa-paperclip"/>
<t t-if="!chatterTopbar.chatter.isShowingAttachmentsLoading">
<span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount pl-1" t-esc="chatterTopbar.chatter.thread ? chatterTopbar.chatter.thread.allAttachments.length : 0"/>
<span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount pl-1" t-esc="chatterTopbar.attachmentButtonText"/>
</t>
<t t-if="chatterTopbar.chatter.isShowingAttachmentsLoading">
<i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-circle-o-notch fa-spin ms-1" aria-label="Attachment counter loading..."/>
@@ -23,16 +23,6 @@ registerModel({
? insertAndReplace()
: clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeDropZoneView() {
if (this.useDragVisibleDropZone.isVisible) {
return insertAndReplace();
}
return clear();
},
},
fields: {
/**
@@ -53,11 +43,6 @@ registerModel({
* States the OWL component displaying this attachment box.
*/
component: attr(),
dropZoneView: one('DropZoneView', {
compute: '_computeDropZoneView',
inverse: 'attachmentBoxViewOwner',
isCausal: true,
}),
fileUploader: one('FileUploader', {
default: insertAndReplace(),
inverse: 'attachmentBoxView',
@@ -65,12 +50,5 @@ registerModel({
readonly: true,
required: true,
}),
useDragVisibleDropZone: one('UseDragVisibleDropZone', {
default: insertAndReplace(),
inverse: 'attachmentBoxViewOwner',
isCausal: true,
readonly: true,
required: true,
}),
},
});
+42 -1
View File
@@ -41,7 +41,11 @@ registerModel({
* @param {MouseEvent} ev
*/
onClickButtonAttachments(ev) {
this.update({ attachmentBoxView: this.attachmentBoxView ? clear() : insertAndReplace() });
if (this.thread && this.thread.allAttachments.length === 0) {
this.fileUploader.openBrowserFileUploader();
} else {
this.update({ attachmentBoxView: this.attachmentBoxView ? clear() : insertAndReplace() });
}
},
/**
* Handles click on top bar close button.
@@ -112,6 +116,9 @@ registerModel({
}
this.threadView.messageListView.component.onScroll(ev);
},
openAttachmentBoxView() {
this.update({ attachmentBoxView: insertAndReplace() });
},
async refresh() {
const requestData = ['activities', 'followers', 'suggestedRecipients'];
if (this.hasMessageList) {
@@ -152,6 +159,23 @@ registerModel({
}
return clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeDropZoneView() {
if (this.useDragVisibleDropZone.isVisible) {
return insertAndReplace();
}
return clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeFileUploader() {
return this.thread ? insertAndReplace() : clear();
},
/**
* @private
* @returns {FieldCommand}
@@ -310,6 +334,16 @@ registerModel({
context: attr({
default: {},
}),
dropZoneView: one('DropZoneView', {
compute: '_computeDropZoneView',
inverse: 'chatterOwner',
isCausal: true,
}),
fileUploader: one('FileUploader', {
compute: '_computeFileUploader',
inverse: 'chatterOwner',
isCausal: true,
}),
followButtonView: one('FollowButtonView', {
compute: '_computeFollowButtonView',
inverse: 'chatterOwner',
@@ -423,6 +457,13 @@ registerModel({
inverse: 'chatter',
isCausal: true,
}),
useDragVisibleDropZone: one('UseDragVisibleDropZone', {
default: insertAndReplace(),
inverse: 'chatterOwner',
isCausal: true,
readonly: true,
required: true,
}),
},
onChanges: [
new OnChange({
@@ -1,12 +1,45 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { one } from '@mail/model/model_field';
import { attr, one } from '@mail/model/model_field';
import { sprintf } from '@web/core/utils/strings';
registerModel({
name: 'ChatterTopbar',
identifyingFields: ['chatter'],
recordMethods: {
/**
* @private
* @returns {string}
*/
_computeAttachmentButtonText() {
if (!this.chatter || !this.chatter.thread) {
return;
}
const attachments = this.chatter.thread.allAttachments;
switch (attachments.length) {
case 0:
return this.env._t("Attach files");
case 1:
return this.env._t("1 file");
case 2:
return this.env._t("2 files");
default:
return sprintf(
this.env._t("%(attachmentCount)s files"),
{ attachmentCount: attachments.length }
);
}
},
},
fields: {
/**
* Determines the label on the attachment button of the topbar.
*/
attachmentButtonText: attr({
compute: '_computeAttachmentButtonText',
}),
chatter: one('Chatter', {
inverse: 'topbar',
readonly: true,
@@ -6,7 +6,7 @@ import { decrement, increment } from '@mail/model/model_field_command';
registerModel({
name: 'DropZoneView',
identifyingFields: [['attachmentBoxViewOwner', 'composerViewOwner']],
identifyingFields: [['chatterOwner', 'composerViewOwner']],
recordMethods: {
/**
* Shows a visual drop effect when dragging inside the dropzone.
@@ -63,8 +63,8 @@ registerModel({
ev.preventDefault();
this.update({ isDraggingInside: false });
if (this._isDragSourceExternalFile(ev.dataTransfer)) {
if (this.attachmentBoxViewOwner) {
await this.attachmentBoxViewOwner.fileUploader.uploadFiles(ev.dataTransfer.files);
if (this.chatterOwner) {
await this.chatterOwner.fileUploader.uploadFiles(ev.dataTransfer.files);
}
if (this.composerViewOwner) {
await this.composerViewOwner.fileUploader.uploadFiles(ev.dataTransfer.files);
@@ -91,7 +91,7 @@ registerModel({
},
},
fields: {
attachmentBoxViewOwner: one('AttachmentBoxView', {
chatterOwner: one('Chatter', {
inverse: 'dropZoneView',
readonly: true,
}),
+12 -2
View File
@@ -16,7 +16,7 @@ const getAttachmentNextTemporaryId = (function () {
registerModel({
name: 'FileUploader',
identifyingFields: [['activityView', 'attachmentBoxView', 'composerView']],
identifyingFields: [['activityView', 'attachmentBoxView', 'chatterOwner', 'composerView']],
recordMethods: {
openBrowserFileUploader() {
this.fileInput.click();
@@ -40,6 +40,9 @@ registerModel({
if (this.fileInput && this.fileInput.el) {
this.fileInput.el.value = '';
}
if (this.chatterOwner && !this.chatterOwner.attachmentBoxView) {
this.chatterOwner.openAttachmentBoxView();
}
},
/**
* Create an HTML element that will serve as file input.
@@ -67,6 +70,9 @@ registerModel({
if (this.attachmentBoxView) {
return replace(this.attachmentBoxView.chatter.thread);
}
if (this.chatterOwner) {
return replace(this.chatterOwner.thread);
}
if (this.composerView) {
return replace(this.composerView.composer.activeThread);
}
@@ -120,7 +126,7 @@ registerModel({
async _performUpload({ files }) {
const composer = this.composerView && this.composerView.composer; // save before async
const thread = this.thread; // save before async
const chatter = this.attachmentBoxView && this.attachmentBoxView.chatter; // save before async
const chatter = this.chatterOwner; // save before async
const activity = this.activityView && this.activityView.activity; // save before async
const uploadingAttachments = new Map();
for (const file of files) {
@@ -182,6 +188,10 @@ registerModel({
inverse: 'fileUploader',
readonly: true,
}),
chatterOwner: one('Chatter', {
inverse: 'fileUploader',
readonly: true,
}),
composerView: one('ComposerView', {
inverse: 'fileUploader',
readonly: true,
@@ -6,7 +6,7 @@ import { decrement, increment } from '@mail/model/model_field_command';
registerModel({
name: 'UseDragVisibleDropZone',
identifyingFields: [['attachmentBoxViewOwner', 'composerViewOwner']],
identifyingFields: [['chatterOwner', 'composerViewOwner']],
lifecycleHooks: {
_created() {
document.addEventListener('dragenter', this._onDragenterListener, true);
@@ -68,7 +68,7 @@ registerModel({
},
},
fields: {
attachmentBoxViewOwner: one('AttachmentBoxView', {
chatterOwner: one('Chatter', {
inverse: 'useDragVisibleDropZone',
readonly: true,
}),
@@ -1,16 +1,6 @@
/** @odoo-module **/
import {
afterNextRender,
dragenterFiles,
dropFiles,
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
import { file } from 'web.test_utils';
const { createFile } = file;
import { start, startServer } from '@mail/../tests/helpers/test_utils';
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
@@ -94,82 +84,6 @@ QUnit.test('base non-empty rendering', async function (assert) {
);
});
QUnit.test('attachment box: drop attachments', async function (assert) {
assert.expect(5);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create();
const { createChatterContainerComponent } = await start();
await createChatterContainerComponent({
isAttachmentBoxVisibleInitially: true,
threadId: resPartnerId1,
threadModel: 'res.partner',
});
const files = [
await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text.txt',
}),
];
assert.strictEqual(
document.querySelectorAll('.o_AttachmentBox').length,
1,
"should have an attachment box"
);
await afterNextRender(() =>
dragenterFiles(document.querySelector('.o_AttachmentBox'))
);
assert.ok(
document.querySelector('.o_AttachmentBox_dropZone'),
"should have a drop zone"
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox .o_AttachmentCard`).length,
0,
"should have no attachment before files are dropped"
);
await afterNextRender(() =>
dropFiles(
document.querySelector('.o_AttachmentBox_dropZone'),
files
)
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox .o_AttachmentCard`).length,
1,
"should have 1 attachment in the box after files dropped"
);
await afterNextRender(() =>
dragenterFiles(document.querySelector('.o_AttachmentBox'))
);
const file1 = await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text2.txt',
});
const file2 = await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text3.txt',
});
await afterNextRender(() =>
dropFiles(
document.querySelector('.o_AttachmentBox_dropZone'),
[file1, file2]
)
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox .o_AttachmentCard`).length,
3,
"should have 3 attachments in the box after files dropped"
);
});
QUnit.test('view attachments', async function (assert) {
assert.expect(7);
@@ -1,6 +1,17 @@
/** @odoo-module **/
import { afterNextRender, nextAnimationFrame, start, startServer } from '@mail/../tests/helpers/test_utils';
import {
afterNextRender,
dragenterFiles,
dropFiles,
nextAnimationFrame,
start,
startServer
} from '@mail/../tests/helpers/test_utils';
import { file } from 'web.test_utils';
const { createFile } = file;
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
@@ -218,6 +229,68 @@ QUnit.test('show attachment box', async function (assert) {
);
});
QUnit.test('chatter: drop attachments', async function (assert) {
assert.expect(4);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create();
const { createChatterContainerComponent } = await start();
await createChatterContainerComponent({
threadId: resPartnerId1,
threadModel: 'res.partner',
});
const files = [
await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text.txt',
}),
await createFile({
content: 'hello, worlduh',
contentType: 'text/plain',
name: 'text2.txt',
}),
];
await afterNextRender(() => dragenterFiles(document.querySelector('.o_Chatter')));
assert.ok(
document.querySelector('.o_Chatter_dropZone'),
"should have a drop zone"
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox`).length,
0,
"should have no attachment before files are dropped"
);
await afterNextRender(() =>
dropFiles(document.querySelector('.o_Chatter_dropZone'), files)
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox .o_AttachmentCard`).length,
2,
"should have 2 attachments in the attachment box after files dropped"
);
await afterNextRender(() => dragenterFiles(document.querySelector('.o_Chatter')));
await afterNextRender(async () =>
dropFiles(
document.querySelector('.o_Chatter_dropZone'),
[
await createFile({
content: 'hello, world',
contentType: 'text/plain',
name: 'text3.txt',
})
]
)
);
assert.strictEqual(
document.querySelectorAll(`.o_AttachmentBox .o_AttachmentCard`).length,
3,
"should have 3 attachments in the attachment box after files dropped"
);
});
QUnit.test('composer show/hide on log note/send message [REQUIRE FOCUS]', async function (assert) {
assert.expect(10);
@@ -101,8 +101,8 @@ QUnit.test('base disabled rendering', async function (assert) {
);
assert.strictEqual(
document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
'0',
"attachments button counter should be 0"
'Attach files',
"attachments button counter content should be 'Attach files'"
);
});
@@ -273,8 +273,8 @@ QUnit.test('attachment counter without attachments', async function (assert) {
);
assert.strictEqual(
document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
'0',
'attachment counter should contain "0"'
'Attach files',
'attachment counter content should contain "Attach files"'
);
});
@@ -320,8 +320,8 @@ QUnit.test('attachment counter with attachments', async function (assert) {
);
assert.strictEqual(
document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
'2',
'attachment counter should contain "2"'
'2 files',
'attachment counter content should contain "2 files"'
);
});
@@ -24,10 +24,6 @@ QUnit.test('no conflicts between file uploaders', async function (assert) {
threadId: resPartnerId2,
threadModel: 'res.partner',
});
await afterNextRender(() => {
document.querySelectorAll('.o_ChatterTopbar_buttonAttachments')[0].click();
document.querySelectorAll('.o_ChatterTopbar_buttonAttachments')[1].click();
});
const file1 = await createFile({
name: 'text1.txt',
@@ -35,7 +31,7 @@ QUnit.test('no conflicts between file uploaders', async function (assert) {
contentType: 'text/plain',
});
await afterNextRender(() => inputFiles(
firstChatterContainerComponent.chatter.attachmentBoxView.fileUploader.fileInput,
firstChatterContainerComponent.chatter.fileUploader.fileInput,
[file1]
));
assert.strictEqual(
@@ -50,7 +46,7 @@ QUnit.test('no conflicts between file uploaders', async function (assert) {
contentType: 'text/plain',
});
await afterNextRender(() => inputFiles(
secondChatterContainerComponent.chatter.attachmentBoxView.fileUploader.fileInput,
secondChatterContainerComponent.chatter.fileUploader.fileInput,
[file2]
));
assert.strictEqual(
@@ -879,7 +879,7 @@ QUnit.test('Attachments that have been unlinked from server should be visually u
);
assert.strictEqual(
document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
'2',
'2 files',
"Partner1 should have 2 attachments initially"
);
@@ -891,7 +891,7 @@ QUnit.test('Attachments that have been unlinked from server should be visually u
await click('.o_pager_previous');
assert.strictEqual(
document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
'1',
'1 file',
"Partner1 should now have 1 attachment after it has been unlinked from server"
);
});
@@ -32,9 +32,9 @@ tour.register('mail/static/tests/tours/mail_full_composer_test_tour.js', {
name: 'text.txt',
});
const messaging = await odoo.__DEBUG__.messaging;
const uploaders = messaging.models['FileUploader'].all();
const uploader = messaging.models['ComposerView'].all()[0].fileUploader;
inputFiles(
uploaders[0].fileInput,
uploader.fileInput,
[file]
);
},