From 409ccc833d7eb024e3516f6e330ef59cf8bc8507 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Fri, 11 Jun 2021 15:42:57 +0000 Subject: [PATCH] [IMP] base_import: add import progress bar for batch mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a progress bar that is displayed during the import (or import test). It is only displayed when importing in batch mode. The user can decide to stop the import. If it's the case, the current batch is finalized and the import is interrupted afterwards. The user can easily resume the import later starting from the line where the batch was interrupted e.g: interrupted after 300 / 500 lines, when you resume, it will start from line 301. (The above behavior is not applied in test mode, we never alter the starting line when testing). Side note: This commit also removes the alert-info box from batch import disclaimer, for design beauty purpose. Task-2567591 closes odoo/odoo#73243 Related: odoo/enterprise#19467 Signed-off-by: Thibault Delavallee (tde) Co-authored-by: Aurélien Warnon --- .../static/src/js/import_action.js | 136 +++++++++++++++--- .../static/src/scss/base_import.scss | 24 ++++ .../static/src/xml/base_import.xml | 50 +++++-- 3 files changed, 176 insertions(+), 34 deletions(-) diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/js/import_action.js index f5374be0571..c2fb9e1a362 100644 --- a/addons/base_import/static/src/js/import_action.js +++ b/addons/base_import/static/src/js/import_action.js @@ -4,8 +4,10 @@ odoo.define('base_import.import', function (require) { var AbstractAction = require('web.AbstractAction'); var config = require('web.config'); var core = require('web.core'); +var Dialog = require('web.Dialog'); var session = require('web.session'); var time = require('web.time'); +var fieldUtils = require('web.field_utils'); var QWeb = core.qweb; var _t = core._t; @@ -93,7 +95,7 @@ var DataImport = AbstractAction.extend({ ], events: { 'change .oe_import_file': 'loaded_file', - 'change input.oe_import_has_header, .js_import_options input': 'settings_changed', + 'change input.oe_import_has_header, .oe_import_sheet': 'settings_changed', 'change input.oe_import_advanced_mode': function (e) { this.do_not_change_match = true; this['settings_changed'](); @@ -360,7 +362,18 @@ var DataImport = AbstractAction.extend({ onfile_loaded: function (event, from, to, arg) { // arg is null if reload -> don't reset partial import if (arg != null ) { + var savedSkipLines = 0; + var isPartialEnabled = this.$('.oe_import').hasClass('o_import_partial_mode'); + if (isPartialEnabled) { + savedSkipLines = this.$('#oe_import_row_start').val(); + } this.toggle_partial(null); + if (isPartialEnabled && savedSkipLines) { + // If partial mode was already enabled, we want to keep the 'start at line' parameter + // This can help the end user when he has partially imported a file and wants to make + // some modifications into it before re-uploading and resuming the upload. + this.$('#oe_import_row_start').val(savedSkipLines); + } } this.$buttons.filter('.o_import_import, .o_import_validate').addClass('d-none'); @@ -434,7 +447,7 @@ var DataImport = AbstractAction.extend({ onpreview_success: function (event, from, to, result) { var self = this; this.$buttons.filter('.oe_import_file') - .text(_t('Load New File')) + .text(_t('Load File')) .removeClass('btn-primary').addClass('btn-secondary') .blur(); this.$buttons.filter('.o_import_import, .o_import_validate').removeClass('d-none'); @@ -445,7 +458,8 @@ var DataImport = AbstractAction.extend({ this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result)); // Activate the batch configuration panel only of the file length > 100. (In order to let the user choose // the batch size even for medium size file. Could be useful to reduce the batch size for complex models). - this.$('.o_import_batch').toggleClass('d-none', !(result.file_length > 100)); + this.fileLength = result.file_length; + this.$('.o_import_batch').toggleClass('d-none', !(this.fileLength > 100)); this.$('.o_import_batch_alert').toggleClass('d-none', !result.batch); var messages = []; @@ -642,6 +656,50 @@ var DataImport = AbstractAction.extend({ this.$form.removeClass('oe_import_error'); }, + /** + * Called at the start of every batch to update the progress bar display. + * It also computes an estimated time left based on: starting time of the whole process, + * percentage done so far and remaining records. + */ + _onBatchStart: function () { + var recordsDone = this.batchSize * (this.currentBatchNumber - 1); + var percentage = parseInt(recordsDone / this.totalToImport * 100); + $('.o_import_progress_dialog') + .find('.progress-bar') + .text(percentage + "%") + .attr('aria-valuenow', percentage) + .css('width', percentage + '%'); + $('.o_import_progress_dialog') + .find('.o_import_progress_dialog_batch_count') + .text(this.currentBatchNumber); + if (percentage !== 0) { + // e.g: it took 1 seconds (1000 millis) to import 33% + // -> there is (1000) * ((100 - 33) / 33) / 60000 minutes left + // -> 1000 * (66 / 33) / 60000 -> 2000 / 60000 -> 0.03 minute left (2 seconds) left + var estimatedTimeLeftMinutes = ((Date.now() - this.importStartTime) * ((100 - percentage) / percentage)) / 60000; + $('.o_import_progress_dialog_time_left').removeClass('d-none'); + $('.o_import_progress_dialog_time_left_text') + .text(fieldUtils.format.float_time(estimatedTimeLeftMinutes)); + } + }, + + /** + * Called when the user manually interrupts the import during a batched import. + * Sets 'stopImport' to true, which will stop the process when the current batch is done. + * + * @private + * @param {MouseEvent} event + */ + _onStopImport: function (event) { + var $currentTarget = $(event.currentTarget); + $currentTarget.enable(false); + $currentTarget + .closest('.o_import_progress_dialog') + .find('.o_import_progress_dialog_stop, .o_import_progress_dialog_batch') + .toggleClass('d-none'); + this.stopImport = true; + }, + /** * This method is called when selecting a new mapping field, or when changing/removing an already selected mapping * field. It will check if multiple field columns are/were mapped on the same field. @@ -797,21 +855,31 @@ var DataImport = AbstractAction.extend({ {}, this.parent_context, {tracking_disable: tracking_disable} ); - var self = this; - $.blockUI({message: QWeb.render('Throbber')}); - $(document.body).addClass('o_ui_blocked'); - var opts = this.import_options(); - var $el = $('.oe_throbber_message'); - var msg = kwargs.dryrun ? _t("%d records tested...") - : _t("%d records successfully imported..."); - opts.callback = function (count) { - if (count) { - $el.text(_.str.sprintf(msg, count)); - } else { - $el.text(kwargs.dryrun ? _t("Testing...") : _t("Importing...")); - } - }; + this.importStartTime = Date.now(); + this.stopImport = false; + this.totalToImport = this.fileLength - parseInt(this.$('#oe_import_row_start').val()); + this.batchSize = parseInt(this.$('#oe_import_batch_limit').val() || 0); + var isBatch = this.batchSize !== 0 && this.totalToImport > this.batchSize; + var totalSteps = isBatch ? Math.floor(this.totalToImport / this.batchSize) + 1 : 1; + this.currentBatchNumber = 1; + + $.blockUI({ + message: QWeb.render( + 'base_import.progressDialog', { + importMode: kwargs.dryrun ? _t('Testing') : _t('Importing'), + isBatch: isBatch, + totalSteps: totalSteps, + } + ) + }); + $(document.body).addClass('o_ui_blocked'); + + $('.o_import_progress_dialog') + .find('.o_progress_stop_import') + .on('click', this._onStopImport.bind(this)); + + var opts = this.import_options(); return this._batchedImport(opts, [this.id, fields, columns], kwargs, {done: 0, prev: 0}) .then(null, function (reason) { @@ -862,8 +930,17 @@ var DataImport = AbstractAction.extend({ * @private */ _batchedImport: function (opts, args, kwargs, rec) { - opts.callback && opts.callback(rec.done || 0); var self = this; + opts.callback && opts.callback(this); + this._onBatchStart(); + this.currentBatchNumber += 1; + + if (this.stopImport) { + $(document.body).removeClass('o_ui_blocked'); + $.unblockUI(); + return Promise.resolve({}); + } + return this._rpc({ model: 'base_import.import', method: 'execute_import', @@ -906,7 +983,7 @@ var DataImport = AbstractAction.extend({ return names[0] || names[1]; }), ids: (results.ids || []).concat(r2.ids || []), - messages: results.messages.concat(r2.messages), + messages: r2.messages ? results.messages.concat(r2.messages) : results.messages, skip: r2.skip || results.nextrow, nextrow: r2.nextrow } @@ -922,8 +999,17 @@ var DataImport = AbstractAction.extend({ var self = this; var prom = this.call_import({ dryrun: false }); prom.then(function (results) { - var message = results.messages; - if (!_.any(message, function (message) { + if (self.stopImport) { + var recordsImported = results.ids ? results.ids.length : 0; + if (recordsImported) { + self.$('#oe_import_row_start').val((results.skip || 0) + 1); + self.displayNotification({ message: _.str.sprintf( + _t("%d records successfully imported"), + recordsImported + )}); + } + self['import_interrupted'](results); + } else if (!_.any(results.messages, function (message) { return message.type === 'error'; })) { self['import_succeeded'](results); return; @@ -950,13 +1036,16 @@ var DataImport = AbstractAction.extend({ var error_type = "warning"; var errorMessages = results.messages; - var no_messages = _.isEmpty(errorMessages); - if (no_messages) { + + if (_.isEmpty(errorMessages) && event !== 'import_interrupted') { errorMessages.push({ type: 'info', message: _t("Everything seems valid.") }); error_type = false; + } else if (event === 'import_interrupted' && results.ids) { + this.toggle_partial(results); + error_type = false; } else if (event === 'import_failed' && results.ids) { // both ids in a failed import -> partial import this.toggle_partial(results); @@ -1162,6 +1251,7 @@ StateMachine.create({ { name: 'validated', from: 'validating', to: 'results' }, { name: 'import', from: ['preview_success', 'results'], to: 'importing' }, { name: 'import_succeeded', from: 'importing', to: 'imported'}, + { name: 'import_interrupted', from: 'importing', to: 'results' }, { name: 'import_failed', from: 'importing', to: 'results' } ], }); diff --git a/addons/base_import/static/src/scss/base_import.scss b/addons/base_import/static/src/scss/base_import.scss index c108fcabe77..d7de1ace8bc 100644 --- a/addons/base_import/static/src/scss/base_import.scss +++ b/addons/base_import/static/src/scss/base_import.scss @@ -68,6 +68,9 @@ overflow: hidden; text-overflow: ellipsis; } + .o_import_batch_alert { + color: #46646d; + } } &.oe_import_preview .oe_import_grid { @@ -234,3 +237,24 @@ } } } + +/* Import Progress Bar */ +.o_import_progress_dialog { + .progress { + height: 1.5rem; + border-radius: 4px; + font-size: 1rem; + + .progress-bar { + width: 0%; + min-width: 3%; + } + } + + .o_progress_stop_import { + cursor: pointer; + &:hover { + transform: scale(1.2); + } + } +} diff --git a/addons/base_import/static/src/xml/base_import.xml b/addons/base_import/static/src/xml/base_import.xml index 222ff129fcf..5198eac0d8f 100644 --- a/addons/base_import/static/src/xml/base_import.xml +++ b/addons/base_import/static/src/xml/base_import.xml @@ -18,12 +18,6 @@ -
- Click 'Resume' to proceed with the import, resuming at line - 0.
- You can test or reload your file before resuming the import. -
-

@@ -225,6 +219,11 @@ field corresponding to the column. This makes imports simpler especially when the file has many columns.

+
+ Click 'Resume' to proceed with the import, resuming at line + 0.
+ You can test or reload your file before resuming the import. +
@@ -244,7 +243,7 @@ -
+
@@ -270,15 +269,15 @@

Batch Import

-
- The file will be imported by batches. +
+ The file will be imported by batches
-
+
-
@@ -315,4 +314,33 @@ lines composed only of empty cells">
+ +
+ +
+
+ batch 1 out of ... +
+ Estimated time left: + + minutes +
+
+ + Finalizing current batch before interrupting... + +
+
+
+ 0% +
+
+ +
+
+
+ ... +
+