[ADD] web: adds file upload mixin
This commit adds mixin to manage file uploads in view controllers and to display progress bars. The first motivation and use case is to allow MRP to manage and display uploads like in Documents. closes odoo/odoo#38397 Task: #2074822 Related: odoo/enterprise#5887 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
@@ -23,6 +23,8 @@ This module provides the core of the Odoo Web Client.
|
||||
'qweb': [
|
||||
"static/src/xml/base.xml",
|
||||
"static/src/xml/chart.xml",
|
||||
"static/src/xml/file_upload_progress_bar.xml",
|
||||
"static/src/xml/file_upload_progress_card.xml",
|
||||
"static/src/xml/kanban.xml",
|
||||
"static/src/xml/menu.xml",
|
||||
"static/src/xml/notification.xml",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
odoo.define('web.fileUploadMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Mixin to be used in view Controllers to manage uploads and generate progress bars.
|
||||
* supported views: kanban, list
|
||||
*/
|
||||
|
||||
const { csrf_token, _t } = require('web.core');
|
||||
const ProgressBar = require('web.ProgressBar');
|
||||
const ProgressCard = require('web.ProgressCard');
|
||||
|
||||
const ProgressBarMixin = {
|
||||
|
||||
custom_events: {
|
||||
progress_bar_abort: '_onProgressBarAbort',
|
||||
},
|
||||
|
||||
init() {
|
||||
/**
|
||||
* Contains the uploads currently happening, used to attach progress bars.
|
||||
* e.g: {'fileUploadId45': {progressBar, progressCard, ...params}}
|
||||
*/
|
||||
this._fileUploads = {};
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* used to use a mocked version of Xhr in the tests.
|
||||
*
|
||||
* @private
|
||||
* @returns {XMLHttpRequest}
|
||||
*/
|
||||
_createXhr() {
|
||||
return new window.XMLHttpRequest();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_getFileUploadRenderOptions() {
|
||||
return {
|
||||
predicate: () => true,
|
||||
targetCallback: undefined,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {string} upload route
|
||||
*/
|
||||
_getFileUploadRoute() {
|
||||
return '/web/binary/upload_attachment';
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object[]} params.files
|
||||
* @param {XMLHttpRequest} params.xhr
|
||||
*/
|
||||
_makeFileUpload(params) {
|
||||
const { files, xhr } = params;
|
||||
const fileUploadId = _.uniqueId('fileUploadId');
|
||||
const formData = new FormData();
|
||||
const formDataKeys = this._makeFileUploadFormDataKeys(Object.assign({ fileUploadId }, params));
|
||||
|
||||
formData.append('csrf_token', csrf_token);
|
||||
for (const key in formDataKeys) {
|
||||
if (formDataKeys[key] !== undefined) {
|
||||
formData.append(key, formDataKeys[key]);
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
formData.append('ufile', file);
|
||||
}
|
||||
|
||||
return {
|
||||
fileUploadId,
|
||||
xhr,
|
||||
title: files.length === 1
|
||||
? files[0].name
|
||||
: _.str.sprintf(_t("%s Files"), files.length),
|
||||
type: files.length === 1 ? files[0].type : undefined,
|
||||
formData,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.fileUploadId
|
||||
* @returns {Object} the list of the form entries of a file upload.
|
||||
*/
|
||||
_makeFileUploadFormDataKeys({ fileUploadId }) {
|
||||
return {
|
||||
callback: fileUploadId,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} fileUploadId
|
||||
*/
|
||||
async _removeFileUpload(fileUploadId) {
|
||||
const upload = this._fileUploads[fileUploadId];
|
||||
upload.progressCard && upload.progressCard.destroy();
|
||||
upload.progressBar && upload.progressBar.destroy();
|
||||
delete this._fileUploads[fileUploadId];
|
||||
await this.reload();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _renderFileUploads() {
|
||||
const { predicate, targetCallback } = this._getFileUploadRenderOptions();
|
||||
|
||||
for (const fileUploadId in this._fileUploads) {
|
||||
const upload = this._fileUploads[fileUploadId];
|
||||
if (!predicate(upload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!upload.progressBar) {
|
||||
if (!upload.recordId || this.viewType !== 'kanban') {
|
||||
upload.progressCard = new ProgressCard(this, {
|
||||
title: upload.title,
|
||||
type: upload.type,
|
||||
viewType: this.viewType,
|
||||
});
|
||||
}
|
||||
upload.progressBar = new ProgressBar(this, {
|
||||
xhr: upload.xhr,
|
||||
title: upload.title,
|
||||
fileUploadId,
|
||||
});
|
||||
}
|
||||
|
||||
let $targetCard;
|
||||
if (upload.progressCard) {
|
||||
await upload.progressCard.prependTo(this.renderer.$el);
|
||||
$targetCard = upload.progressCard.$el;
|
||||
} else if (targetCallback) {
|
||||
$targetCard = targetCallback(upload);
|
||||
}
|
||||
await upload.progressBar.appendTo($targetCard);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object[]} files
|
||||
* @param {Object} [params] optional additional data
|
||||
*/
|
||||
async _uploadFiles(files, params={}) {
|
||||
if (!files || !files.length) { return; }
|
||||
|
||||
await new Promise(resolve => {
|
||||
const xhr = this._createXhr();
|
||||
xhr.open('POST', this._getFileUploadRoute());
|
||||
const fileUploadData = this._makeFileUpload(Object.assign({ files, xhr }, params));
|
||||
const { fileUploadId, formData } = fileUploadData;
|
||||
this._fileUploads[fileUploadId] = fileUploadData;
|
||||
xhr.upload.addEventListener("progress", ev => {
|
||||
this._updateFileUploadProgress(fileUploadId, ev);
|
||||
});
|
||||
const progressPromise = this._onBeforeUpload();
|
||||
xhr.onload = async () => {
|
||||
await progressPromise;
|
||||
resolve();
|
||||
this._onUploadLoad({ fileUploadId, xhr });
|
||||
};
|
||||
xhr.onerror = async () => {
|
||||
await progressPromise;
|
||||
resolve();
|
||||
this._onUploadError({ fileUploadId, xhr });
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} fileUploadId
|
||||
* @param {ProgressEvent} ev
|
||||
*/
|
||||
_updateFileUploadProgress(fileUploadId, ev) {
|
||||
const { progressCard, progressBar } = this._fileUploads[fileUploadId];
|
||||
progressCard && progressCard.update(ev.loaded, ev.total);
|
||||
progressBar && progressBar.update(ev.loaded, ev.total);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hook to customize the behaviour of _uploadFiles() before an upload is made.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _onBeforeUpload() {
|
||||
await this._renderFileUploads();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {integer} ev.data.fileUploadId
|
||||
*/
|
||||
_onProgressBarAbort(ev) {
|
||||
this._removeFileUpload(ev.data.fileUploadId);
|
||||
},
|
||||
/**
|
||||
* Hook to customize the behaviour of the xhr.onload of an upload.
|
||||
*
|
||||
* @private
|
||||
* @param {string} param0.fileUploadId
|
||||
*/
|
||||
_onUploadLoad({ fileUploadId }) {
|
||||
this._removeFileUpload(fileUploadId);
|
||||
},
|
||||
/**
|
||||
* Hook to customize the behaviour of the xhr.onerror of an upload.
|
||||
*
|
||||
* @private
|
||||
* @param {string} param1.fileUploadId
|
||||
* @param {XMLHttpRequest} param0.xhr
|
||||
*/
|
||||
_onUploadError({ fileUploadId, xhr }) {
|
||||
this.do_notify(xhr.status, _.str.sprintf(_t('message: %s'), xhr.reponseText), true);
|
||||
this._removeFileUpload(fileUploadId);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
return ProgressBarMixin;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
odoo.define('web.ProgressBar', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { _t } = require('web.core');
|
||||
const Dialog = require('web.Dialog');
|
||||
const Widget = require('web.Widget');
|
||||
|
||||
const ProgressBar = Widget.extend({
|
||||
template: 'web.ProgressBar',
|
||||
|
||||
events: {
|
||||
'click .o_upload_cross': '_onClickCross',
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} param1
|
||||
* @param {String} param1.title
|
||||
* @param {String} param1.fileUploadId
|
||||
* @param {XMLHttpRequest} param2.xhr
|
||||
*/
|
||||
init(parent, { title, fileUploadId, xhr }) {
|
||||
this._super(...arguments);
|
||||
this.title = title;
|
||||
this.fileUploadId = fileUploadId;
|
||||
this.xhr = xhr;
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {Promise}
|
||||
*/
|
||||
start() {
|
||||
this.xhr.onabort = () => this.do_notify(_t("Upload canceled"));
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {integer} loaded
|
||||
* @param {integer} total
|
||||
*/
|
||||
update(loaded, total) {
|
||||
if (!this.$el) {
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((loaded / total) * 100);
|
||||
this.$('.o_file_upload_progress_bar_value').css("width", percent + "%");
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickCross(ev) {
|
||||
ev.stopPropagation();
|
||||
const promptText = _.str.sprintf(_t("Do you really want to cancel the upload of %s?"), _.escape(this.title));
|
||||
Dialog.confirm(this, promptText, {
|
||||
confirm_callback: () => {
|
||||
this.xhr.abort();
|
||||
this.trigger_up('progress_bar_abort', { fileUploadId: this.fileUploadId });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ProgressBar;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
odoo.define('web.ProgressCard', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { _t } = require('web.core');
|
||||
const Widget = require('web.Widget');
|
||||
|
||||
const ProgressCard = Widget.extend({
|
||||
template: 'web.ProgressCard',
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} param1
|
||||
* @param {String} param1.title
|
||||
* @param {String} param1.type file mimetype
|
||||
* @param {String} param1.viewType
|
||||
*/
|
||||
init(parent, { title, type, viewType }) {
|
||||
this._super(...arguments);
|
||||
this.title = title;
|
||||
this.type = type;
|
||||
this.viewType = viewType;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {integer} loaded
|
||||
* @param {integer} total
|
||||
*/
|
||||
update(loaded, total) {
|
||||
if (!this.$el) {
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((loaded / total) * 100);
|
||||
const $textDivLeft = this.$('.o_file_upload_progress_text_left');
|
||||
const $textDivRight = this.$('.o_file_upload_progress_text_right');
|
||||
if (percent === 100) {
|
||||
$textDivLeft.text(_t('Processing...'));
|
||||
} else {
|
||||
const mbLoaded = Math.round(loaded/1000000);
|
||||
const mbTotal = Math.round(total/1000000);
|
||||
$textDivLeft.text(_.str.sprintf(_t("Uploading... (%s%%)"), percent));
|
||||
$textDivRight.text(_.str.sprintf(_t("(%s/%sMb)"), mbLoaded, mbTotal));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return ProgressCard;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
.o_content {
|
||||
|
||||
.o_kanban_progress_card {
|
||||
|
||||
position: relative;
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
cursor: pointer;
|
||||
|
||||
.o_kanban_record_bottom {
|
||||
color: $gray-900;
|
||||
}
|
||||
.o_kanban_image_wrapper {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.o_list_progress_card {
|
||||
height: 25px;
|
||||
border: 1px solid #dfdfdf;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
padding: 0;
|
||||
|
||||
.o_file_upload_upload_title {
|
||||
color: $headings-color;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.o_file_upload_progress_bar {
|
||||
@include o-position-absolute($top: 0px, $left: 0px);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
.o_file_upload_progress_bar_value {
|
||||
transition: width 0.1s;
|
||||
border-right: 1px solid darken($o-brand-primary, 7.5%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: $o-brand-primary;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.o_upload_cross {
|
||||
@include o-position-absolute(0, 0);
|
||||
padding: 4px;
|
||||
color: #963535;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
&:not(:hover) .o_upload_cross {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<div t-name="web.ProgressBar" class="o_file_upload_progress_bar">
|
||||
<div class="o_file_upload_progress_bar_value"/>
|
||||
<span class="o_upload_cross fa fa-times-circle" title="Cancel Upload" aria-label="Cancel Upload"/>
|
||||
</div>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="web.ProgressCard">
|
||||
<t t-if="widget.viewType === 'kanban'">
|
||||
<div class="o_kanban_progress_card o_kanban_record o_kanban_attachment">
|
||||
<div class="o_kanban_image">
|
||||
<div class="o_kanban_image_wrapper">
|
||||
<div class="o_image o_image_thumbnail" t-att-data-mimetype="widget.type"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_details">
|
||||
<div class="o_kanban_details_wrapper">
|
||||
<div t-att-title="widget.title" t-att-aria-label="widget.title" class="o_kanban_record_title">
|
||||
<span t-esc="widget.title"/>
|
||||
</div>
|
||||
<div class="o_kanban_record_body"/>
|
||||
<div class="o_kanban_record_bottom">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<div class="o_file_upload_progress_text_left">Processing...</div>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<span class="o_file_upload_progress_text_right"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="widget.viewType === 'list'">
|
||||
<div class="o_data_row o_list_progress_card">
|
||||
<span class="o_file_upload_upload_title" t-esc="widget.title"/>
|
||||
<span class="o_file_upload_progress_text_left">Processing...</span>
|
||||
<span class="o_file_upload_progress_text_right"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -195,6 +195,7 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/control_panel.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/fields.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/fields_extra.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/file_upload.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/views.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/pivot_view.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/graph_view.scss"/>
|
||||
@@ -322,6 +323,9 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_bar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_card.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/select_create_controllers_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/signature_dialog.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user