From 68ecfe1357437f1e9d6a82a2d3640c895afa3781 Mon Sep 17 00:00:00 2001 From: Thanh Dodeur Date: Thu, 10 Oct 2019 09:01:58 +0000 Subject: [PATCH] [ADD] web: adds file upload mixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- addons/web/__manifest__.py | 2 + .../static/src/js/views/file_upload_mixin.js | 234 ++++++++++++++++++ .../src/js/views/file_upload_progress_bar.js | 76 ++++++ .../src/js/views/file_upload_progress_card.js | 52 ++++ addons/web/static/src/scss/file_upload.scss | 58 +++++ .../src/xml/file_upload_progress_bar.xml | 9 + .../src/xml/file_upload_progress_card.xml | 39 +++ addons/web/views/webclient_templates.xml | 4 + 8 files changed, 474 insertions(+) create mode 100644 addons/web/static/src/js/views/file_upload_mixin.js create mode 100644 addons/web/static/src/js/views/file_upload_progress_bar.js create mode 100644 addons/web/static/src/js/views/file_upload_progress_card.js create mode 100644 addons/web/static/src/scss/file_upload.scss create mode 100644 addons/web/static/src/xml/file_upload_progress_bar.xml create mode 100644 addons/web/static/src/xml/file_upload_progress_card.xml diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 2321ff7389c..12bc57970f4 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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", diff --git a/addons/web/static/src/js/views/file_upload_mixin.js b/addons/web/static/src/js/views/file_upload_mixin.js new file mode 100644 index 00000000000..84dddcd904d --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_mixin.js @@ -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; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_bar.js b/addons/web/static/src/js/views/file_upload_progress_bar.js new file mode 100644 index 00000000000..95b99a831c0 --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_bar.js @@ -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; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_card.js b/addons/web/static/src/js/views/file_upload_progress_card.js new file mode 100644 index 00000000000..1f1db52e53f --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_card.js @@ -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; + +}); diff --git a/addons/web/static/src/scss/file_upload.scss b/addons/web/static/src/scss/file_upload.scss new file mode 100644 index 00000000000..155b39ad110 --- /dev/null +++ b/addons/web/static/src/scss/file_upload.scss @@ -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; + } + } +} diff --git a/addons/web/static/src/xml/file_upload_progress_bar.xml b/addons/web/static/src/xml/file_upload_progress_bar.xml new file mode 100644 index 00000000000..8d2c39e13bf --- /dev/null +++ b/addons/web/static/src/xml/file_upload_progress_bar.xml @@ -0,0 +1,9 @@ + + + +
+
+ +
+ + diff --git a/addons/web/static/src/xml/file_upload_progress_card.xml b/addons/web/static/src/xml/file_upload_progress_card.xml new file mode 100644 index 00000000000..d60b38cf2d7 --- /dev/null +++ b/addons/web/static/src/xml/file_upload_progress_card.xml @@ -0,0 +1,39 @@ + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
Processing...
+
+
+ +
+
+
+
+
+ + +
+ + Processing... + +
+
+ + + diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 7235103c11e..b0a4b956152 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -195,6 +195,7 @@ + @@ -322,6 +323,9 @@ + + +