[MERGE][IMP] mail, web: drag/drop files in conversations

This merge adds the support of drag/drop files in a conversation
(discuss, document chatter and chat windows).

It also adds helper functions to simulate drag/drop with files in test
environments.

Task-ID 32469

closes odoo/odoo#18914
This commit is contained in:
Robot Odoo
2018-11-26 13:50:33 +01:00
committed by GitHub
9 changed files with 379 additions and 29 deletions
@@ -28,6 +28,8 @@ var BasicComposer = Widget.extend({
'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
'dragover .o_file_drop_zone_container': '_onFileDragover',
'drop .o_file_drop_zone_container': '_onFileDrop',
'input .o_input': '_onInput',
'keydown .o_composer_input textarea': '_onKeydown',
'keyup .o_composer_input': '_onKeyup',
@@ -124,9 +126,19 @@ var BasicComposer = Widget.extend({
// Mention
this._mentionManager.prependTo(this.$('.o_composer'));
// Drag-Drop files
// Allowing body to detect dragenter and dragleave for display
var $body = $('body');
this._dropZoneNS = _.uniqueId('o_dz_'); // For event namespace used when multiple chat window is open
$body.on('dragleave.' + this._dropZoneNS, this._onBodyFileDragLeave.bind(this));
$body.on("dragover." + this._dropZoneNS, this._onBodyFileDragover.bind(this));
$body.on("drop." + this._dropZoneNS, this._onBodyFileDrop.bind(this));
return this._super();
},
destroy: function () {
$("body").off('dragleave.' + this._dropZoneNS);
$("body").off('dragover.' + this._dropZoneNS);
$("body").off('drop.' + this._dropZoneNS);
$(window).off(this.fileuploadID);
return this._super.apply(this, arguments);
},
@@ -253,6 +265,24 @@ var BasicComposer = Widget.extend({
_hideEmojis: function () {
this._$emojisContainer.remove();
},
/**
* Making sure that dragging content is external files.
* Ignoring other content draging like text.
*
* @private
* @param {DataTransfer} dataTransfer
* @returns {boolean}
*/
_isDragSourceExternalFile: function (dataTransfer) {
var DragDataType = dataTransfer.types;
if (DragDataType.constructor === DOMStringList) {
return DragDataType.contains('Files');
}
if (DragDataType.constructor === Array) {
return DragDataType.indexOf('Files') !== -1;
}
return false;
},
/**
* @private
* @param {string} search
@@ -377,6 +407,67 @@ var BasicComposer = Widget.extend({
command: commands.length > 0 ? commands[0].name : undefined,
});
},
/**
* Allowing to upload attachment with file selector as well drag drop feature.
*
* @private
* @param {Array<File>} params.files
* @param {boolean} params.submitForm [optional]
*/
_processAttachmentChange: function (params) {
var self = this,
attachments = this.get('attachment_ids'),
files = params.files,
submitForm = params.submitForm;
_.each(files, function (file) {
var attachment = _.findWhere(attachments, {
name: file.name,
size: file.size
});
// if the files already exits, delete the file before upload
if (attachment) {
self._attachmentDataSet.unlink([attachment.id]);
attachments = _.without(attachments, attachment);
}
});
var $form = this.$('form.o_form_binary_form');
if (submitForm) {
$form.submit();
this._$attachmentButton.prop('disabled', true);
} else {
var data = new FormData($form[0]);
_.each(files, function (file) {
// removing existing key with blank data and appending again with file info
// In safari, existing key will not be updated when append with new file.
data.delete("ufile");
data.append("ufile", file, file.name);
$.ajax({
url: $form.attr("action"),
type: "POST",
enctype: 'multipart/form-data',
processData: false,
contentType: false,
data: data,
success: function (result) {
var $el = $(result);
$.globalEval($el.contents().text());
}
});
});
}
var uploadAttachments = _.map(files, function (file){
return {
id: 0,
name: file.name,
filename: file.name,
url: '',
upload: true,
mimetype: '',
};
});
attachments = attachments.concat(uploadAttachments);
this.set('attachment_ids', attachments);
},
/**
* @private
*/
@@ -419,33 +510,10 @@ var BasicComposer = Widget.extend({
* @param {jQuery.Event} ev
*/
_onAttachmentChange: function (ev) {
var self = this;
var files = ev.target.files;
var attachments = this.get('attachment_ids');
_.each(files, function (file){
var attachment = _.findWhere(attachments, {name: file.name});
// if the files already exits, delete the file before upload
if (attachment){
self._attachmentDataSet.unlink([attachment.id]);
attachments = _.without(attachments, attachment);
}
this._processAttachmentChange({
files: ev.currentTarget.files,
submitForm: true
});
this.$('form.o_form_binary_form').submit();
this._$attachmentButton.prop('disabled', true);
var uploadAttachments = _.map(files, function (file){
return {
id: 0,
name: file.name,
filename: file.name,
url: '',
upload: true,
mimetype: '',
};
});
attachments = attachments.concat(uploadAttachments);
this.set('attachment_ids', attachments);
},
/**
* @private
@@ -497,6 +565,7 @@ var BasicComposer = Widget.extend({
name: file.name || file.filename,
filename: file.filename,
mimetype: file.mimetype,
size: file.size,
url: session.url('/web/content', { id: file.id, download: true }),
});
}
@@ -517,6 +586,46 @@ var BasicComposer = Widget.extend({
attachmentViewer.appendTo($('body'));
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onBodyFileDragLeave: function (ev) {
// On every dragenter chain created with parent child element
// That's why dragleave is fired every time when a child elemnt is hovered
// so here we hide dropzone based on mouse position
if (ev.originalEvent.clientX <= 0
|| ev.originalEvent.clientY <= 0
|| ev.originalEvent.clientX >= window.innerWidth
|| ev.originalEvent.clientY >= window.innerHeight
) {
this.$(".o_file_drop_zone_container").addClass("d-none");
}
},
/**
* When user start dragging on element drop area will be visible to drop selected files.
*
* @private
* @param {MouseEvent} ev
*/
_onBodyFileDragover: function (ev) {
ev.preventDefault();
if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) {
this.$(".o_file_drop_zone_container").removeClass("d-none");
}
},
/**
* @private
* @param {MouseEvent} ev
*/
_onBodyFileDrop: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this.$(".o_file_drop_zone_container").addClass("d-none");
},
/**
* @private
*/
_onClickAddAttachment: function () {
this.$('input.o_input_file').click();
this.$input.focus();
@@ -573,6 +682,32 @@ var BasicComposer = Widget.extend({
_onEmojiImageFocus: function () {
clearTimeout(this._hideEmojisTimeout);
},
/**
* Setting drop Effect to copy so when mouse pointer on dropzone
* cursor icon changed to copy ('+')
*
* @private
* @param {MouseEvent} ev
*/
_onFileDragover: function (ev) {
ev.originalEvent.dataTransfer.dropEffect = "copy";
},
/**
* Called when user drop selected files on drop area
*
* @private
* @param {MouseEvent} ev
*/
_onFileDrop: function (ev) {
ev.preventDefault();
// FIX: In case multiple chat windows are opened, and file droped in one of them
// at that time, other chat windows are still displaing drop areas so here hide them all with $ selector
$(".o_file_drop_zone_container").addClass("d-none");
if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) {
var files = ev.originalEvent.dataTransfer.files;
this._processAttachmentChange({ files: files });
}
},
/**
* Called when the input in the composer changes
*
+21 -2
View File
@@ -13,7 +13,6 @@
}
.o_thread_composer {
position: relative;
flex: 0 0 auto;
display: flex;
margin: 1px 0;
@@ -111,6 +110,7 @@
.o_composer {
border-radius: 2px;
textarea {
padding-top: $o-mail-chatter-gap !important;
padding-right: $grid-gutter-width; // avoid overlaps betwenn textarea and o_composer_button_full_composer
}
@@ -143,7 +143,7 @@
flex: 1 0 100%;
}
.o_composer, .o_composer_subject {
padding: $o-mail-chatter-gap $o-mail-chatter-gap 0;
padding: 0 $o-mail-chatter-gap 0;
}
.o_composer_send {
margin-top: $o-mail-chatter-gap;
@@ -167,6 +167,10 @@
.o_composer_input {
display: flex;
flex-flow: row wrap;
textarea {
padding-top: 0px !important;
}
}
.o_composer_text_field {
flex: 1 1 50%;
@@ -230,6 +234,21 @@
display: none !important;
}
}
.o_file_drop_zone_container {
display: flex;
position: absolute;
left: 0px;
bottom: 0px;
height: 100%;
width: 100%;
color: $o-brand-primary;
background: rgba(255, 255, 255, 0.9);
border: 2px dashed $o-brand-primary;
z-index: 1;
align-items: center;
justify-content: center;
}
}
.o_composer_attachments_list:not(:empty) {
@@ -26,6 +26,9 @@
}
.o_thread_composer {
.o_file_drop_zone_container {
top: 35px;
}
.o_composer {
.o_composer_input {
width: 80%;
+6
View File
@@ -2,12 +2,18 @@
<templates xml:space="preserve">
<div t-name="mail.Composer" t-attf-class="o_thread_composer #{widget.extended ? 'o_thread_composer_extended' : (widget.notInline ? '' : 'o_chat_inline_composer')} #{widget.isMini ? 'o_chat_mini_composer' : ''}">
<div t-if="widget.isMini || !widget.notInline" class="o_file_drop_zone_container d-none">
<h4>Drag Files Here <span class="fa fa-download"/></h4>
</div>
<div class="o_composer_container">
<img t-if="widget.avatarURL" height="36" width="36" class="o_thread_message_sidebar o_chatter_avatar rounded-circle" t-att-src="widget.avatarURL" alt="Avatar"/>
<div t-if="widget.extended" class="o_composer_subject">
<input tabindex="1" type="text" placeholder="Subject" class="o_input"/>
</div>
<div t-attf-class="o_composer #{widget.extended ? 'o_extended_composer' : ''}">
<div t-if="widget.notInline" class="o_file_drop_zone_container d-none">
<h4>Drag Files Here <span class="fa fa-download"/></h4>
</div>
<div class="o_composer_input">
<textarea class="o_input o_composer_text_field" tabindex="2" placeholder="Write something..."/>
<div class="o_chatter_composer_tools">
+66
View File
@@ -989,5 +989,71 @@ QUnit.test('mark all messages as read from Inbox', function (assert) {
});
});
QUnit.test('drag and drop file in composer', function (assert) {
assert.expect(8);
var done = assert.async();
this.data.initMessaging = {
channel_slots: {
channel_channel: [{
id: 1,
channel_type: "channel",
name: "general",
}],
},
};
createDiscuss({
id: 1,
context: {},
params: {},
data: this.data,
services: this.services,
})
.then(function (discuss) {
var $general = discuss.$('.o_mail_discuss_sidebar')
.find('.o_mail_discuss_item[data-thread-id=1]');
// click on general
testUtils.dom.click($general);
// first composer is active (basic), 2nd is hidden (extended)
var $composer = discuss.$('.o_thread_composer').first();
assert.containsNone($composer, '.o_attachments_list',
"should not display any attachment on the composer initially");
assert.containsOnce($composer, '.o_file_drop_zone_container',
"should have a dropzone to drag-and-drop files");
var $dropZoneContainer = $composer.find('.o_file_drop_zone_container');
assert.isNotVisible($dropZoneContainer,
"dropzone should not be visible");
testUtils.file.createFile({
name: 'text.txt',
content: 'hello, world',
contentType: 'text/plain',
}).then(function (file) {
testUtils.file.dragoverFile($dropZoneContainer, file);
assert.isVisible($dropZoneContainer,
"dropzone should be visible");
testUtils.file.dropFile($dropZoneContainer, file);
assert.containsOnce($composer, '.o_attachments_list',
"should display some attachments on the composer");
assert.containsOnce($composer, '.o_attachment',
"should display one attachment on the composer");
var filename = $('.o_attachment').find('.caption').first().text().trim();
assert.strictEqual(filename, 'text.txt',
"should display the correct filename");
assert.hasClass($('.o_attachment_uploaded i'), 'fa-check',
"text file should have been uploaded");
discuss.destroy();
done();
});
});
});
});
});
+2 -1
View File
@@ -1177,7 +1177,8 @@ class Binary(http.Controller):
args.append({
'filename': filename,
'mimetype': ufile.content_type,
'id': attachment.id
'id': attachment.id,
'size': attachment.file_size
})
return out % (json.dumps(callback), json.dumps(args))
@@ -16,6 +16,7 @@ var session = require('web.session');
var testUtilsCreate = require('web.test_utils_create');
var testUtilsDom = require('web.test_utils_dom');
var testUtilsFields = require('web.test_utils_fields');
var testUtilsFile = require('web.test_utils_file');
var testUtilsForm = require('web.test_utils_form');
var testUtilsGraph = require('web.test_utils_graph');
var testUtilsKanban = require('web.test_utils_kanban');
@@ -98,6 +99,11 @@ return $.when(
editSelect: testUtilsFields.editSelect,
editAndTrigger: testUtilsFields.editAndTrigger,
},
file: {
createFile: testUtilsFile.createFile,
dragoverFile: testUtilsFile.dragoverFile,
dropFile: testUtilsFile.dropFile,
},
createActionManager: testUtilsCreate.createActionManager,
createDebugManager: testUtilsCreate.createDebugManager,
@@ -0,0 +1,113 @@
odoo.define('web.test_utils_file', function () {
"use strict";
/**
* FILE Test Utils
*
* This module defines various utility functions to help simulate events with
* files, such as drag-and-drop.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
//------------------------------------------------------------------------------
// Private functions
//------------------------------------------------------------------------------
/**
* Create a fake object 'dataTransfer', linked to a file, which is passed to
* drag and drop events.
*
* @param {Object} file
* @returns {Object}
*/
function _createFakeDataTransfer(file) {
return {
dropEffect: 'all',
effectAllowed: 'all',
files: [file],
getData: function () {
return file;
},
items: [],
types: ['Files'],
};
}
//------------------------------------------------------------------------------
// Public functions
//------------------------------------------------------------------------------
/**
* Create a file object, which can be used for drag-and-drop.
*
* @param {Object} data
* @param {string} data.name
* @param {string} data.content
* @param {string} data.contentType
* @returns {$.Promise<Object>} resolved with file created
*/
function createFile(data) {
var def = $.Deferred();
var requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
if (!requestFileSystem) {
throw new Error('FileSystem API is not supported');
}
requestFileSystem(window.TEMPORARY, 1024 * 1024, function (fileSystem) {
fileSystem.root.getFile(data.name, { create: true }, function (fileEntry) {
fileEntry.createWriter(function (fileWriter) {
fileWriter.onwriteend = function (e) {
fileSystem.root.getFile(data.name, {}, function (fileEntry) {
fileEntry.file(function (file) {
def.resolve(file);
});
});
};
fileWriter.write(new Blob([ data.content ], { type: data.contentType }));
});
});
});
return def;
}
/**
* Drag a file over a DOM element
*
* @param {$.Element} $el
* @param {Object} file must have been created beforehand (@see createFile)
*/
function dragoverFile($el, file) {
var ev = new Event('dragover', { bubbles: true });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(file),
});
$el[0].dispatchEvent(ev);
}
/**
* Drop a file on a DOM element.
*
* @param {$.Element} $el
* @param {Object} file must have been created beforehand (@see createFile)
*/
function dropFile($el, file) {
var ev = new Event('drop', { bubbles: true, });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(file),
});
$el[0].dispatchEvent(ev);
}
//------------------------------------------------------------------------------
// Exposed API
//------------------------------------------------------------------------------
return {
createFile: createFile,
dragoverFile: dragoverFile,
dropFile: dropFile,
};
});
+1
View File
@@ -495,6 +495,7 @@
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_create.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_dom.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_fields.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_file.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_form.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_graph.js"></script>
<script type="text/javascript" src="/web/static/tests/helpers/test_utils_kanban.js"></script>