From cfe079221cd2176d6ece3f573ac832862eb42ce1 Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Tue, 20 Jul 2021 10:59:07 +0000 Subject: [PATCH] [FIX] web, web_editor: restore media upload progress toast In odoo/odoo#72675 the new services were made available in the frontend, and calls to the legacy notification services were redirected to the new notification service. However, some of the behaviour of the legacy notification service was not replicated in the new one, like the ability to pass an HTML element to use inside the notification. While html content can be passed, it is cloned, meaning that the DOM of the element cannot be manipulated by the caller, which is what was being done to show progress during media upload. Although the upload progress toast looks like a notification, it's hardly a standard notification and adds a lot of behaviour, because manipulating DOM directly when it is managed by owl cannot be done safely, it has been decided to simply make it its own widget separate from the notification service, which can manipulate its own DOM freely. task-2607393 closes odoo/odoo#74027 Signed-off-by: Romain Derie --- .../static/src/core/network/rpc_service.js | 2 +- addons/web/static/src/legacy/utils.js | 5 +- .../static/src/js/wysiwyg/widgets/media.js | 152 ++++------------- .../wysiwyg/widgets/upload_progress_toast.js | 153 ++++++++++++++++++ addons/web_editor/static/src/xml/wysiwyg.xml | 42 +++-- 5 files changed, 219 insertions(+), 135 deletions(-) create mode 100644 addons/web_editor/static/src/js/wysiwyg/widgets/upload_progress_toast.js diff --git a/addons/web/static/src/core/network/rpc_service.js b/addons/web/static/src/core/network/rpc_service.js index 4859c82b72d..9ab20fc4f72 100644 --- a/addons/web/static/src/core/network/rpc_service.js +++ b/addons/web/static/src/core/network/rpc_service.js @@ -50,7 +50,7 @@ function jsonrpc(env, rpcId, url, params, settings = {}) { method: "call", params: params, }; - const request = new XHR(); + const request = settings.xhr || new XHR(); let rejectFn; const promise = new Promise((resolve, reject) => { rejectFn = reject; diff --git a/addons/web/static/src/legacy/utils.js b/addons/web/static/src/legacy/utils.js index 76987f79352..76ebaa6b57d 100644 --- a/addons/web/static/src/legacy/utils.js +++ b/addons/web/static/src/legacy/utils.js @@ -143,7 +143,10 @@ export function mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv) { let rejection; const prom = new Promise((resolve, reject) => { const [route, params, settings = {}] = args; - const jsonrpc = wowlEnv.services.rpc(route, params, { silent: settings.shadow }); + const jsonrpc = wowlEnv.services.rpc(route, params, { + silent: settings.shadow, + xhr: settings.xhr, + }); rejection = () => { jsonrpc.abort(); }; diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index ab9622a73b1..008b913e479 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -11,6 +11,7 @@ var Widget = require('web.Widget'); var session = require('web.session'); const {removeOnImageChangeAttrs} = require('web_editor.image_processing'); const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils'); +const { UploadProgressToast } = require('@web_editor/js/wysiwyg/widgets/upload_progress_toast'); var QWeb = core.qweb; var _t = core._t; @@ -126,6 +127,10 @@ var FileWidget = SearchableMediaWidget.extend({ 'click .o_existing_attachment_remove': '_onRemoveClick', 'click .o_load_more': '_onLoadMoreClick', }), + custom_events: Object.assign({}, SearchableMediaWidget.prototype.events || {}, { + 'file_complete': '_onAttachmentUploaded', + 'upload_complete': '_onUploadCompleted', + }), existingAttachmentsTemplate: undefined, IMAGE_MIMETYPES: ['image/jpg', 'image/jpeg', 'image/jpe', 'image/png', 'image/svg+xml', 'image/gif'], @@ -193,6 +198,18 @@ var FileWidget = SearchableMediaWidget.extend({ return def; }); }, + /** + * @override + */ + destroy() { + if (this.uploader) { + // Prevent uploader from being destroyed with call to super so it can linger + this.uploader.setParent(null); + this.uploader.destroyOnClose = true; + this.uploader.close(2000); + } + this._super(...arguments); + }, //-------------------------------------------------------------------------- // Public @@ -563,52 +580,18 @@ var FileWidget = SearchableMediaWidget.extend({ * @returns {Promise} */ async _addData() { - let files = this.$fileInput[0].files; + const files = this.$fileInput[0].files; if (!files.length) { // Case if the input is emptied, return resolved promise return; } - - var uploadMutex = new concurrency.Mutex(); - - // Upload the smallest file first to block the user the least possible. - files = _.sortBy(files, 'size'); - await this._setUpProgressToast(files); - this.hasError = false; - _.each(files, (file, index) => { - // Upload one file at a time: no need to parallel as upload is - // limited by bandwidth. - uploadMutex.exec(() => { - return utils.getDataURLFromFile(file).then(result => { - return this._rpcShowProgress({ - route: '/web_editor/attachment/add_data', - params: { - 'name': file.name, - 'data': result.split(',')[1], - 'res_id': this.options.res_id, - 'res_model': this.options.res_model, - 'is_image': this.widgetType === 'image', - 'width': 0, - 'quality': 0, - } - }, index).then(attachment => { - if (!attachment.error) { - this._handleNewAttachment(attachment); - } - }); - }); - }); - }); - - return uploadMutex.getUnlockedDef().then(() => { - if (!this.hasError) { - this._closeProgressToast(); - } - if (!this.options.multiImages && !this.noSave) { - this.trigger_up('save_request'); - } - this.noSave = false; + this.uploader = new UploadProgressToast(this, { + files: files, + resModel: this.options.res_model, + resId: this.options.res_id, + isImage: this.widgetType === 'image', }); + this.uploader.appendTo(document.body); }, /** * @private @@ -712,91 +695,22 @@ var FileWidget = SearchableMediaWidget.extend({ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY; this._super.apply(this, arguments); }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** - * Sets up a progress bar for every file being uploaded in a toast. - * * @private - * @param {Object[]} files */ - _setUpProgressToast: async function (files) { - this.$progress = $('
'); - _.each(files, (file, index) => { - let fileSize = file.size; - if (!fileSize) { - fileSize = null; - } else if (fileSize < 1024) { - fileSize = fileSize.toFixed(2) + " bytes"; - } else if (fileSize < 1048576) { - fileSize = (fileSize / 1024).toFixed(2) + " KB"; - } else { - fileSize = (fileSize / 1048576).toFixed(2) + " MB"; - } - - this.$progress.append(QWeb.render('wysiwyg.widgets.upload.progressbar', { - fileId: index, - fileName: file.name, - fileSize: fileSize, - })); - }); - this.connectionNotificationID = this.displayNotification({ - type: 'info', - sticky: true, - contentEl: this.$progress, - }); + _onAttachmentUploaded({ data: attachment }) { + if (!attachment.error) { + this._handleNewAttachment(attachment); + } }, /** - * Closes the toast holding the file(s) progress bar(s). - * * @private */ - _closeProgressToast: function () { - this.call('notification', 'close', this.connectionNotificationID, false, 3000); - }, - /** - * Calls a RPC and shows its progress status. - * - * @private - * @param {Object} params regular `_rpc()` parameters - * @param {integer} index file index to retrieve its related progress bar - * @returns {Promise} - */ - _rpcShowProgress: function (params, index) { - let $progressBar = this.$progress.find(`.js_progressbar_${index}`); - return this._rpc(params, { - xhr: function () { - var xhr = $.ajaxSettings.xhr(); - xhr.upload.onprogress = function (ev) { - var prcComplete = ev.loaded / ev.total * 100; - $progressBar.find('.progress-bar').css({ - width: parseInt(prcComplete) + '%', - }).text(prcComplete.toFixed(2) + '%'); - }; - xhr.upload.onload = function () { - // Don't show yet success as backend code only starts now - $progressBar.find('.progress-bar').css({width: '100%'}).text('100%'); - }; - return xhr; - }, - }).then(attachment => { - $progressBar.find('.fa-spinner, .progress').addClass('d-none'); - if (attachment.error) { - this.hasError = true; - $progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none'); - $progressBar.find('.js_progressbar_txt .text-danger .o_we_error_text').text(attachment.error); - } else { - $progressBar.find('.js_progressbar_txt .text-success').removeClass('d-none'); - } - return attachment; - }).guardedCatch(() => { - this.hasError = true; - $progressBar.find('.fa-spinner, .progress').addClass('d-none'); - $progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none'); - }); + _onUploadCompleted() { + if (!this.options.multiImages && !this.noSave) { + this.trigger_up('save_request'); + } + this.noSave = false; }, }); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/upload_progress_toast.js b/addons/web_editor/static/src/js/wysiwyg/widgets/upload_progress_toast.js new file mode 100644 index 00000000000..069f8cdd94c --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/upload_progress_toast.js @@ -0,0 +1,153 @@ +/** @odoo-module */ + +import Widget from "web.Widget"; +import concurrency from "web.concurrency"; +import { getDataURLFromFile } from "web.utils"; + +export const UploadProgressToast = Widget.extend({ + xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'], + template: 'wysiwyg.widgets.upload.progress_toast', + events: { + 'click .o_notification_close': '_onCloseClick', + }, + + /** + * @override + */ + init(parent, { files, resModel, resId, isImage }) { + this._super(...arguments); + this.resId = resId; + this.resModel = resModel; + this.isImage = isImage; + + // Upload the smallest file first to block the user the least possible. + this.files = [...files].sort((a, b) => a.size - b.size); + this.files.forEach((file, index) => { + file.id = index; + if (!file.size) { + file.displaySize = null; + } else if (file.size < 1024) { + file.displaySize = file.size.toFixed(2) + " bytes"; + } else if (file.size < 1048576) { + file.displaySize = (file.size / 1024).toFixed(2) + " KB"; + } else { + file.displaySize = (file.size / 1048576).toFixed(2) + " MB"; + } + }); + }, + /** + * @override + */ + start() { + this.hasError = false; + const uploadMutex = new concurrency.Mutex(); + this.files.forEach((file, index) => { + // Upload one file at a time: no need to parallel as upload is + // limited by bandwidth. + uploadMutex.exec(async () => { + const dataURL = await getDataURLFromFile(file); + const attachment = await this._rpcShowProgress({ + route: '/web_editor/attachment/add_data', + params: { + name: file.name, + data: dataURL.split(',')[1], + res_id: this.resId, + res_model: this.resModel, + is_image: this.isImage, + width: 0, + quality: 0, + } + }, this.$el.find(`.js_progressbar_${index}`)); + if (!attachment.error) { + this.trigger_up('file_complete', attachment); + } + }); + }); + + uploadMutex.getUnlockedDef().then(() => { + this.trigger_up('upload_complete'); + this.destroyOnClose = true; + this.close(2000); + }); + this.uploadMutex = uploadMutex; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calls a RPC and shows its progress status. + * + * @public + * @param {Number} delay time to wait before closing the toast + */ + close(delay = 0) { + window.setTimeout(() => { + if (!this.hasError || delay === 0) { + this.el.querySelector('.fade').classList.remove('show'); + window.setTimeout(() => { + if (this.destroyOnClose) { + this.destroy(); + } else { + this.el.classList.add('d-none'); + } + }, 150); + } + }, delay); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Calls a RPC and shows its progress status. + * + * @private + * @param {Object} params regular `_rpc()` parameters + * @param {jQuery} $progressBar the element holding the progress bar + * @returns {Promise} resolved when the RPC is complete + */ + async _rpcShowProgress(params, $progressBar) { + try { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('progress', ev => { + const prcComplete = ev.loaded / ev.total * 100; + $progressBar.find('.progress-bar').css({ + width: Math.floor(prcComplete) + '%', + }).text(prcComplete.toFixed(2) + '%'); + }); + xhr.upload.addEventListener('load', function () { + // Don't show yet success as backend code only starts now + $progressBar.find('.progress-bar').css({width: '100%'}).text('100%'); + }); + const attachment = await this._rpc(params, { xhr }); + $progressBar.find('.fa-spinner, .progress').addClass('d-none'); + if (attachment.error) { + this.hasError = true; + $progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none'); + $progressBar.find('.js_progressbar_txt .text-danger .o_we_error_text').text(attachment.error); + } else { + $progressBar.find('.js_progressbar_txt .text-success').removeClass('d-none'); + } + return attachment; + } catch (error) { + this.hasError = true; + $progressBar.find('.fa-spinner, .progress').addClass('d-none'); + $progressBar.find('.js_progressbar_txt .text-danger').removeClass('d-none'); + throw error; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCloseClick() { + this.close(); + }, +}); diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 48e057a8245..e3694e11727 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -269,21 +269,35 @@
- -
-
- - - + +
+
+
+ +
+
+
+
+ + + +
+
+ File has been uploaded + File could not be saved +
+
+
0%
+
+
+
+
+
+
-
- File has been uploaded - File could not be saved -
-
-
0%
-
-