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