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...
+
+
+
+
+
+
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 @@
+
+
+