diff --git a/addons/base_import/controllers/main.py b/addons/base_import/controllers/main.py index 7eff0aa420d..5ea80284d11 100644 --- a/addons/base_import/controllers/main.py +++ b/addons/base_import/controllers/main.py @@ -11,13 +11,14 @@ from odoo.tools import misc class ImportController(http.Controller): @http.route('/base_import/set_file', methods=['POST']) - def set_file(self, file, import_id, jsonp='callback'): - import_id = int(import_id) + # pylint: disable=redefined-builtin + def set_file(self, id): + file = request.httprequest.files.getlist('ufile')[0] - written = request.env['base_import.import'].browse(import_id).write({ + written = request.env['base_import.import'].browse(int(id)).write({ 'file': file.read(), 'file_name': file.filename, 'file_type': file.content_type, }) - return 'window.top.%s(%s)' % (misc.html_escape(jsonp), json.dumps({'result': written})) + return json.dumps({'result': written}) diff --git a/addons/base_import/static/src/import_action/import_action.js b/addons/base_import/static/src/import_action/import_action.js new file mode 100644 index 00000000000..a71e6a3cd7a --- /dev/null +++ b/addons/base_import/static/src/import_action/import_action.js @@ -0,0 +1,203 @@ +/** @odoo-module **/ + +import { Component, onWillStart, onMounted, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { sprintf } from "@web/core/utils/strings"; +import { FileInput } from "@web/core/file_input/file_input"; +import { useImportModel } from "../import_model"; +import { ImportDataContent } from "../import_data_content/import_data_content"; +import { ImportDataProgress } from "../import_data_progress/import_data_progress"; +import { ImportDataSidepanel } from "../import_data_sidepanel/import_data_sidepanel"; +import { Layout } from "@web/search/layout"; + +export class ImportAction extends Component { + static template = "ImportAction"; + static nextId = 1; + static components = { + FileInput, + ImportDataContent, + ImportDataSidepanel, + Layout, + }; + + setup() { + this.notification = useService("notification"); + this.orm = useService("orm"); + this.router = useService("router"); + this.user = useService("user"); + + this.env.config.setDisplayName(this.props.action.name || this.env._t("Import a File")); + this.resModel = this.props.action.params.model; + this.model = useImportModel({ + env: this.env, + resModel: this.resModel, + context: this.props.action.params.context || {}, + orm: this.orm, + }); + + this.state = useState({ + filename: undefined, + fileLength: 0, + importMessages: [], + importProgress: { + value: 0, + step: 1, + }, + isPaused: false, + previewError: "", + }); + + onWillStart(() => this.model.init()); + onMounted(() => this.enter()); + } + + enter() { + const newState = { action: "import", model: this.resModel }; + this.router.pushState(newState, { replace: true }); + } + + exit() { + this.env.config.historyBack(); + } + + get display() { + return { + controlPanel: { + "top-right": false, + "bottom-left": true, + "bottom-right": false, + }, + }; + } + + get importTemplates() { + return this.model.importTemplates; + } + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + get formattingOptions() { + return this.model.formattingOptions; + } + + get totalToImport() { + return this.state.fileLength - parseInt(this.importOptions.skip); + } + + get totalSteps() { + return this.isBatched ? Math.ceil(this.totalToImport / this.importOptions.limit) : 1; + } + + get importOptions() { + return this.model.importOptions; + } + + get isPreviewing() { + return this.state.filename !== undefined; + } + + // Activate the batch configuration panel only if 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). + get isBatched() { + return this.state.fileLength > 100; + } + + async onOptionChanged(name, value, fieldName = null) { + this.model.block(); + await this.model.setOption(name, value, fieldName); + this.model.unblock(); + } + + async reload() { + this.model.block(); + await this.model.updateData(); + this.model.unblock(); + } + + //-------------------------------------------------------------------------- + // File + //-------------------------------------------------------------------------- + + async handleFilesUpload(files) { + if (!files || files.length <= 0) { + return; + } + + this.state.filename = files[0].name; + this.state.importMessages = []; + + this.model.block(this.env._t("Loading file...")); + const { res, error } = await this.model.updateData(true); + + if (error) { + this.state.previewError = error; + } else { + this.state.fileLength = res.file_length; + this.state.previewError = undefined; + } + this.model.unblock(); + } + + async handleImport(isTest = true) { + const message = isTest ? this.env._t("Testing") : this.env._t("Importing"); + + let blockComponent; + if (this.isBatched) { + blockComponent = { + class: ImportDataProgress, + props: { + stopImport: () => this.stopImport(), + totalSteps: this.totalSteps, + importProgress: this.state.importProgress, + }, + }; + } + + this.model.block(message, blockComponent); + + let res = { ids: [] }; + try { + const data = await this.model.executeImport( + isTest, + this.totalSteps, + this.state.importProgress + ); + res = data.res; + } finally { + this.model.unblock(); + } + + if (!isTest && res.nextrow) { + this.state.isPaused = true; + } + + if (!isTest && res.ids.length) { + this.notification.add( + sprintf(this.env._t("%s records successfully imported"), res.ids.length), + { type: "success" } + ); + this.exit(); + } + } + + stopImport() { + this.model.stopImport(); + } + + //-------------------------------------------------------------------------- + // Fields + //-------------------------------------------------------------------------- + + onFieldChanged(column, value) { + this.model.setColumnField(column, value); + } + + isFieldSet(column) { + return column.fieldInfo != null; + } +} + +registry.category("actions").add("import", ImportAction); diff --git a/addons/base_import/static/src/import_action/import_action.scss b/addons/base_import/static/src/import_action/import_action.scss new file mode 100644 index 00000000000..7545063d08e --- /dev/null +++ b/addons/base_import/static/src/import_action/import_action.scss @@ -0,0 +1,3 @@ +.o_import_action { + grid-template-rows: auto 1fr; +} diff --git a/addons/base_import/static/src/import_action/import_action.xml b/addons/base_import/static/src/import_action/import_action.xml new file mode 100644 index 00000000000..e5e4420a5a9 --- /dev/null +++ b/addons/base_import/static/src/import_action/import_action.xml @@ -0,0 +1,64 @@ + + + +
+ + + + + + + + + + + + + + + + + +
+
+

+ Upload an Excel or CSV file to import +

+

+ Excel files are recommended as formatting is automatic. +

+
Need Help?
+
+ + + +
+ Import FAQ +
+
+
+
+
+
diff --git a/addons/base_import/static/src/import_block_ui.js b/addons/base_import/static/src/import_block_ui.js new file mode 100644 index 00000000000..ae11ae32f08 --- /dev/null +++ b/addons/base_import/static/src/import_block_ui.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class ImportBlockUI extends Component { + static props = { + message: { type: String, optional: true }, + blockComponent: { type: Object, optional: true }, + }; + static template = "base_import.BlockUI"; +} diff --git a/addons/base_import/static/src/import_block_ui.xml b/addons/base_import/static/src/import_block_ui.xml new file mode 100644 index 00000000000..8c4db802815 --- /dev/null +++ b/addons/base_import/static/src/import_block_ui.xml @@ -0,0 +1,16 @@ + + + + +
+
+ Loading... +
+
+
+ +
+
+ + + diff --git a/addons/base_import/static/src/import_data_column_error/import_data_column_error.js b/addons/base_import/static/src/import_data_column_error/import_data_column_error.js new file mode 100644 index 00000000000..b756c5a1ebb --- /dev/null +++ b/addons/base_import/static/src/import_data_column_error/import_data_column_error.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export class ImportDataColumnError extends Component { + static template = "ImportDataColumnError"; + static props = { + errors: { type: Array }, + fieldInfo: { type: Object }, + resultNames: { type: Array }, + }; + + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + this.state = useState({ + isExpanded: false, + moreInfoContent: undefined, + }); + } + get moreInfo() { + const moreInfoObjects = this.props.errors.map((error) => error.moreinfo); + return moreInfoObjects.length && moreInfoObjects[0]; + } + isErrorVisible(index) { + return this.state.isExpanded || index < 3; + } + onMoreInfoClicked() { + const moreInfo = this.moreInfo; + if (this.state.moreInfoContent) { + this.state.moreInfoContent = undefined; + } else if (moreInfo instanceof Array) { + this.state.moreInfoContent = moreInfo; + } else { + this.action.doAction(moreInfo); + } + } +} diff --git a/addons/base_import/static/src/import_data_column_error/import_data_column_error.xml b/addons/base_import/static/src/import_data_column_error/import_data_column_error.xml new file mode 100644 index 00000000000..0df40591d9e --- /dev/null +++ b/addons/base_import/static/src/import_data_column_error/import_data_column_error.xml @@ -0,0 +1,42 @@ + + + +
+ +

+ No matching records found for the following name + Multiple errors occurred + in field : +

+
    + + + more + +
  • + + at row + + () + + + at multiple rows +
  • +
    +
+
+

+ + +

+
    +
  • +
+ +
+
+
diff --git a/addons/base_import/static/src/legacy/img/ui/studio_icons.png b/addons/base_import/static/src/import_data_content/img/studio_icons.png similarity index 100% rename from addons/base_import/static/src/legacy/img/ui/studio_icons.png rename to addons/base_import/static/src/import_data_content/img/studio_icons.png diff --git a/addons/base_import/static/src/import_data_content/import_data_content.js b/addons/base_import/static/src/import_data_content/import_data_content.js new file mode 100644 index 00000000000..00d891776f9 --- /dev/null +++ b/addons/base_import/static/src/import_data_content/import_data_content.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { SelectMenu } from "@web/core/select_menu/select_menu"; +import { ImportDataColumnError } from "../import_data_column_error/import_data_column_error"; +import { ImportDataOptions } from "../import_data_options/import_data_options"; +import { _t } from "@web/core/l10n/translation"; + +export class ImportDataContent extends Component { + static template = "ImportDataContent"; + static components = { + ImportDataColumnError, + ImportDataOptions, + SelectMenu, + }; + static props = { + columns: { type: Array }, + isFieldSet: { type: Function }, + onOptionChanged: { type: Function }, + onFieldChanged: { type: Function }, + options: { type: Object }, + importMessages: { type: Object }, + previewError: { type: String, optional: true }, + }; + + setup() { + this.searchPlaceholder = _t("Search a field..."); + } + + getGroups(column) { + const groups = [ + { choices: this.makeChoices(column.fields.basic) }, + { choices: this.makeChoices(column.fields.suggested), label: _t("Suggested Fields") }, + { choices: this.makeChoices(column.fields.additional), label: _t("Additional Fields") }, + { choices: this.makeChoices(column.fields.relational), label: _t("Relation Fields") }, + ]; + return groups; + } + + makeChoices(fields) { + return fields.map((field) => ({ + label: field.string, + value: field, + })); + } + + getTooltipDetails(field) { + return JSON.stringify({ + resModel: field.model_name, + debug: true, + field: { + name: field.name, + label: field.string, + type: field.type, + }, + }); + } + + getTooltip(column) { + const displayCount = 5; + if (column.previews.length > displayCount) { + return JSON.stringify({ + lines: [ + ...column.previews.slice(0, displayCount - 1), + `(+${column.previews.length - displayCount + 1})`, + ], + }); + } else { + return JSON.stringify({ lines: column.previews.slice(0, displayCount) }); + } + } + + getErrorMessageClass(messages, type, index) { + return `alert alert-${type} m-0 p-2 ${index === messages.length - 1 ? "" : "mb-2"}`; + } + + getCommentClass(column, comment, index) { + return `alert-${comment.type} ${index < column.comments.length - 1 ? "mb-2" : "mb-0"}`; + } +} diff --git a/addons/base_import/static/src/import_data_content/import_data_content.scss b/addons/base_import/static/src/import_data_content/import_data_content.scss new file mode 100644 index 00000000000..eab6aa6a8ee --- /dev/null +++ b/addons/base_import/static/src/import_data_content/import_data_content.scss @@ -0,0 +1,73 @@ +.o_import_data_content { + .o_import_field_icon { + filter: invert(0.5); // make the icons visible in dark theme + background-image: url('/base_import/static/src/import_data_content/img/studio_icons.png'); + background-repeat: no-repeat; + width: 25px; + height: 20px; + } + + .o_import_file_column_cell { + max-width: 300px; + } + + .o_import_preview { + width: fit-content; + } +} + +@mixin o-import-sprite-icon($x: 0, $y: 0) { + background-position: $x $y; +} + +.o_import_field_icon_binary { + @include o-import-sprite-icon(0, 0); +} + +.o_import_field_icon_boolean { + @include o-import-sprite-icon(0, -20px); +} + +.o_import_field_icon_char { + @include o-import-sprite-icon(0, -40px); +} + +.o_import_field_icon_date { + @include o-import-sprite-icon(0, -80px); +} + +.o_import_field_icon_datetime { + @include o-import-sprite-icon(0, -100px); +} + +.o_import_field_icon_float { + @include o-import-sprite-icon(0, -120px); +} + +.o_import_field_icon_html { + @include o-import-sprite-icon(0, -140px); +} + +.o_import_field_icon_integer { + @include o-import-sprite-icon(0, -180px); +} + +.o_import_field_icon_many2many { + @include o-import-sprite-icon(0, -200px); +} + +.o_import_field_icon_many2one { + @include o-import-sprite-icon(0, -220px); +} + +.o_import_field_icon_one2many { + @include o-import-sprite-icon(0, -260px); +} + +.o_import_field_icon_selection { + @include o-import-sprite-icon(0, -280px); +} + +.o_import_field_icon_text { + @include o-import-sprite-icon(0, -340px); +} diff --git a/addons/base_import/static/src/import_data_content/import_data_content.xml b/addons/base_import/static/src/import_data_content/import_data_content.xml new file mode 100644 index 00000000000..570b025611f --- /dev/null +++ b/addons/base_import/static/src/import_data_content/import_data_content.xml @@ -0,0 +1,115 @@ + + + +
+
+
+

+ Import preview failed due to: " + + ". +

+

For CSV files, you may need to select the correct separator.

+

Here is the start of the file we could not import:

+
+
+
+

+ If the file contains + the column names, Odoo can try auto-detecting the + field corresponding to the column. This makes imports + simpler especially when the file has many columns. +

+
+
+ +

+ +
+
+

+
+
+ + + + + + + + + + + + + + + + + +
File ColumnOdoo FieldComments
+
+ + + Untitled + + + + +
+
+ + + + + + + + To import, select a field... + + +
+ +
+
+
+
+ +

+
+ + +
+
+
+ + +
Preview
+
+

+
+
+
diff --git a/addons/base_import/static/src/import_data_options/import_data_options.js b/addons/base_import/static/src/import_data_options/import_data_options.js new file mode 100644 index 00000000000..99606bdabcb --- /dev/null +++ b/addons/base_import/static/src/import_data_options/import_data_options.js @@ -0,0 +1,75 @@ +/** @odoo-module **/ + +import { Component, useState, onWillStart } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { useService } from "@web/core/utils/hooks"; +import { sprintf } from "@web/core/utils/strings"; + +export class ImportDataOptions extends Component { + static template = "ImportDataOptions"; + static props = { + importOptions: { type: Object, optional: true }, + fieldInfo: { type: Object }, + onOptionChanged: { type: Function }, + }; + + setup() { + this.orm = useService("orm"); + this.state = useState({ + options: [], + }); + this.currentModel = this.props.fieldInfo.comodel_name || this.props.fieldInfo.model_name; + onWillStart(async () => { + this.state.options = await this.loadOptions(); + }); + } + get isVisible() { + return ["many2one", "many2many", "selection", "boolean"].includes( + this.props.fieldInfo.type + ); + } + async loadOptions() { + const options = [["prevent", _t("Prevent import")]]; + if (this.props.fieldInfo.type === "boolean") { + options.push(["false", _t("Set to: False")]); + options.push(["true", _t("Set to: True")]); + !this.props.fieldInfo.required && + options.push(["import_skip_records", _t("Skip record")]); + } + if (["many2one", "many2many", "selection"].includes(this.props.fieldInfo.type)) { + if (!this.props.fieldInfo.required) { + options.push(["import_set_empty_fields", _t("Set value as empty")]); + options.push(["import_skip_records", _t("Skip record")]); + } + if (this.props.fieldInfo.type === "selection") { + const fields = await this.orm.call(this.currentModel, "fields_get"); + const selection = fields[this.props.fieldInfo.name].selection.map((opt) => [ + opt[0], + sprintf(_t("Set to: %s"), opt[1]), + ]); + options.push(...selection); + } else { + options.push(["name_create_enabled_fields", _t("Create new values")]); + } + } + return options; + } + onSelectionChanged(ev) { + if ( + [ + "name_create_enabled_fields", + "import_set_empty_fields", + "import_skip_records", + ].includes(ev.target.value) + ) { + this.props.onOptionChanged(ev.target.value, ev.target.value, this.props.fieldInfo.name); + } else { + const value = { + fallback_value: ev.target.value, + field_model: this.currentModel, + field_type: this.props.fieldInfo.type, + }; + this.props.onOptionChanged("fallback_values", value, this.props.fieldInfo.name); + } + } +} diff --git a/addons/base_import/static/src/import_data_options/import_data_options.xml b/addons/base_import/static/src/import_data_options/import_data_options.xml new file mode 100644 index 00000000000..f9191a2b430 --- /dev/null +++ b/addons/base_import/static/src/import_data_options/import_data_options.xml @@ -0,0 +1,13 @@ + + + +
+ When a value cannot be matched: + +
+
+
diff --git a/addons/base_import/static/src/import_data_progress/import_data_progress.js b/addons/base_import/static/src/import_data_progress/import_data_progress.js new file mode 100644 index 00000000000..b1a7ffed461 --- /dev/null +++ b/addons/base_import/static/src/import_data_progress/import_data_progress.js @@ -0,0 +1,55 @@ +/** @odoo-module **/ + +import { Component, useEffect, useState } from "@odoo/owl"; + +export class ImportDataProgress extends Component { + static template = "ImportDataProgress"; + static props = { + importProgress: { type: Object }, + stopImport: { type: Function }, + totalSteps: { type: Number }, + }; + + setup() { + this.timer = undefined; + this.timeStart = Date.now(); + this.state = useState({ + isInterrupted: false, + timeLeft: null, + }); + + useEffect( + () => { + this.updateTimer(); + return () => { + clearInterval(this.timer); + }; + }, + () => [] + ); + } + + get minutesLeft() { + return this.state.timeLeft.toFixed(2); + } + + get secondsLeft() { + return Math.round(this.state.timeLeft * 60); + } + + interrupt() { + this.state.isInterrupted = true; + this.props.stopImport(); + } + + updateTimer() { + if (this.timer) { + clearInterval(this.timer); + } + this.state.timeLeft = + ((Date.now() - this.timeStart) * + ((100 - this.props.importProgress.value) / this.props.importProgress.value)) / + 60000; + this.timer = setInterval(() => this.updateTimer(), 1000); + } +} diff --git a/addons/base_import/static/src/import_data_progress/import_data_progress.scss b/addons/base_import/static/src/import_data_progress/import_data_progress.scss new file mode 100644 index 00000000000..9d666d0586e --- /dev/null +++ b/addons/base_import/static/src/import_data_progress/import_data_progress.scss @@ -0,0 +1,6 @@ +.o_import_data_progress { + .progress { + width: 300px; + height: 25px; + } +} diff --git a/addons/base_import/static/src/import_data_progress/import_data_progress.xml b/addons/base_import/static/src/import_data_progress/import_data_progress.xml new file mode 100644 index 00000000000..c61d2c0c880 --- /dev/null +++ b/addons/base_import/static/src/import_data_progress/import_data_progress.xml @@ -0,0 +1,32 @@ + + + +
+
+ Batch out of ... +
+ Estimated time left: + + minutes + + + seconds + +
+
+ + Finalizing current batch before interrupting... + +
+
+
+ % +
+
+ +
+
+
+
diff --git a/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.js b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.js new file mode 100644 index 00000000000..925c95a1406 --- /dev/null +++ b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { CheckBox } from "@web/core/checkbox/checkbox"; + +export class ImportDataSidepanel extends Component { + static template = "ImportDataSidepanel"; + static components = { CheckBox }; + static props = { + filename: { type: String }, + formattingOptions: { type: Object, optional: true }, + options: { type: Object }, + importTemplates: { type: Array, optional: true }, + isBatched: { type: Boolean, optional: true }, + onOptionChanged: { type: Function }, + onReload: { type: Function }, + }; + + get fileName() { + return this.props.filename.split(".")[0]; + } + + get fileExtension() { + return "." + this.props.filename.split(".").pop(); + } + + getOptionValue(name) { + if (name === "skip") { + return (this.props.options.skip + 1).toString(); + } + return this.props.options[name].toString(); + } + + setOptionValue(name, value) { + this.props.onOptionChanged(name, isNaN(parseFloat(value)) ? value : Number(value)); + } + + // Start at row 1 = skip 0 lines + onLimitChange(ev) { + this.props.onOptionChanged("skip", ev.target.value ? ev.target.value - 1 : 0); + } +} diff --git a/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.scss b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.scss new file mode 100644 index 00000000000..f46a2e9f431 --- /dev/null +++ b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.scss @@ -0,0 +1,3 @@ +.o_import_data_sidepanel { + width: 280px; +} diff --git a/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.xml b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.xml new file mode 100644 index 00000000000..addd92a4df2 --- /dev/null +++ b/addons/base_import/static/src/import_data_sidepanel/import_data_sidepanel.xml @@ -0,0 +1,97 @@ + + + +
+
+

Imported file

+
+ +
+ +
+ +
+ + +
+ + Use first row as header + +
+
+

Formatting

+
+ + + +
+ +
+
+

Batch Import

+
+ The file will be imported by batches +
+
+
+ + +
+
+ + +
+
+
+ +
+

Advanced

+
+ + Track history during import + +
+ + Allow matching with subfields + +
+
+
+
diff --git a/addons/base_import/static/src/import_model.js b/addons/base_import/static/src/import_model.js new file mode 100644 index 00000000000..1b5b44a9f3e --- /dev/null +++ b/addons/base_import/static/src/import_model.js @@ -0,0 +1,727 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { pick } from "@web/core/utils/objects"; +import { groupBy, sortBy } from "@web/core/utils/arrays"; +import { sprintf } from "@web/core/utils/strings"; +import { isRelational } from "@web/views/utils"; +import { useState } from "@odoo/owl"; +import { ImportBlockUI } from "./import_block_ui"; + +const mainComponentRegistry = registry.category("main_components"); + +/** + * ------------------------------------------------------------------------- + * Base Import Business Logic + * ------------------------------------------------------------------------- + * + * Handles mapping and updating the preview data of the csv/excel files to be + * used in the different base_import components. + * + * When uploading a file some "preview data" is returned by the backend, this + * data consist of the different columns of the file and the odoo fields which + * these columns can be mapped to. + * + * Only a small selection of the lines are returned so the user can get an idea + * of how to correctly map the columns. *(this is why it is refered as "preview + * data")* + * + */ +export class BaseImportModel { + constructor({ env, resModel, context, orm }) { + this.id = 1; + this.env = env; + this.orm = orm; + this.handleInterruption = false; + + this.resModel = resModel; + this.context = context || {}; + + this.fields = []; + this.columns = []; + this.importMessages = []; + this._importOptions = {}; + + this.importTemplates = []; + + this.formattingOptionsValues = this._getCSVFormattingOptions(); + + this.importOptionsValues = { + ...this.formattingOptionsValues, + advanced: { + reloadParse: true, + value: true, + }, + has_headers: { + reloadParse: true, + value: true, + }, + keep_matches: { + value: false, + }, + limit: { + value: 2000, + }, + sheets: { + value: [], + }, + sheet: { + label: _t("Selected Sheet:"), + reloadParse: true, + value: "", + }, + skip: { + value: 0, + }, + tracking_disable: { + value: true, + }, + }; + + this.fieldsToHandle = {}; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + get formattingOptions() { + return pick(this.importOptionsValues, ...Object.keys(this.formattingOptionsValues)); + } + + get importOptions() { + const tempImportOptions = { + import_skip_records: [], + import_set_empty_fields: [], + fallback_values: {}, + name_create_enabled_fields: {}, + }; + for (const [name, option] of Object.entries(this.importOptionsValues)) { + tempImportOptions[name] = option.value; + } + + for (const key in this.fieldsToHandle) { + const value = this.fieldsToHandle[key]; + if (value) { + if (value.optionName === "import_skip_records") { + tempImportOptions.import_skip_records.push(key); + } else if (value.optionName === "import_set_empty_fields") { + tempImportOptions.import_set_empty_fields.push(key); + } else if (value.optionName === "name_create_enabled_fields") { + tempImportOptions.name_create_enabled_fields[key] = true; + } else if (value.optionName === "fallback_values") { + tempImportOptions.fallback_values[key] = value.value; + } + } + } + + this._importOptions = tempImportOptions; + return tempImportOptions; + } + + set importOptions(options) { + for (const key in options) { + this.importOptionsValues[key].value = options[key]; + } + } + + /** + * A custom BlockUI is required to add the progress bar or text when blocking + * the UI, without modifying the core ui service to handle a generic use case + */ + block(message, blockComponent) { + mainComponentRegistry.add( + "ImportBlockUI", + { + Component: ImportBlockUI, + props: { + blockComponent, + message, + }, + }, + { force: true } + ); + } + + unblock() { + mainComponentRegistry.remove("ImportBlockUI"); + } + + async init() { + [this.importTemplates, this.id] = await Promise.all([ + this.orm.call(this.resModel, "get_import_templates", [], { + context: this.context, + }), + this.orm.call("base_import.import", "create", [{ res_model: this.resModel }]), + ]); + } + + async executeImport(isTest = false, totalSteps, importProgress) { + this.handleInterruption = false; + this._updateComments(); + this.importMessages = []; + + const startRow = this.importOptions.skip; + const importRes = { + ids: [], + fields: this.columns.map((e) => Boolean(e.fieldInfo) && e.fieldInfo.name), + columns: this.columns.map((e) => e.name.trim().toLowerCase()), + hasError: false, + }; + + for (let i = 1; i <= totalSteps; i++) { + if (this.handleInterruption) { + if (importRes.hasError || isTest) { + importRes.nextrow = startRow; + this.setOption("skip", startRow); + } + break; + } + + const error = await this._executeImportStep(isTest, importRes); + + if (error) { + let message; + const errorData = error.data || {}; + if (errorData.type === "xhrerror") { + const xhr = errorData.objects[0]; + switch (xhr.status) { + case 504: // gateway timeout + message = _t( + "Import timed out. Please retry. If you still encounter this issue, the file may be too big for the system's configuration, try to split it (import less records per file)." + ); + break; + default: + message = _t( + "An unknown issue occurred during import (possibly lost connection, data limit exceeded or memory limits exceeded). Please retry in case the issue is transient. If the issue still occurs, try to split the file rather than import it at once." + ); + } + } else { + message = + (errorData.arguments && + (errorData.arguments[1] || errorData.arguments[0])) || + error.message; + } + + if (error.message) { + this._addMessage("danger", [error.message, message]); + } else { + this._addMessage("danger", [message]); + } + + importRes.hasError = true; + break; + } + + if (importProgress) { + importProgress.step = i; + importProgress.value = Math.round((100 * (i - 1)) / totalSteps); + } + } + + if (!importRes.hasError) { + if (importRes.nextrow) { + this._addMessage("warning", [ + sprintf( + _t("Click 'Resume' to proceed with the import, resuming at line %s."), + importRes.nextrow + 1 + ), + _t("You can test or reload your file before resuming the import."), + ]); + } + if (isTest) { + this._addMessage("info", [_t("Everything seems valid.")]); + } + } else { + importRes.nextrow = startRow; + } + return { res: importRes }; + } + + /** + * Ask the server for the parsing preview + * and update the data accordingly. + */ + async updateData(fileChanged = false) { + if (fileChanged) { + this.importOptionsValues.sheet.value = ""; + } + this.importMessages = []; + + const res = await this.orm.call("base_import.import", "parse_preview", [ + this.id, + this.importOptions, + ]); + + if (!res.error) { + this._onLoadSuccess(res); + } else { + this._onLoadError(); + } + + return { res, error: res.error }; + } + + async setOption(optionName, value, fieldName) { + if (fieldName) { + this.fieldsToHandle[fieldName] = { + optionName, + value, + }; + return; + } + this.importOptionsValues[optionName].value = value; + if (this.importOptionsValues[optionName].reloadParse) { + await this.updateData(); + } + } + + setColumnField(column, fieldInfo) { + column.fieldInfo = fieldInfo; + this._updateComments(column); + } + + isColumnFieldSet(column) { + return column.fieldInfo != null; + } + + /* + * We must wait the current iteration of execute_import to conclude and it + * will stop at the start of the next batch with handleInterruption + */ + stopImport() { + this.handleInterruption = true; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _addMessage(type, lines) { + const importMsgs = this.importMessages; + importMsgs.push({ + type: type.replace("error", "danger"), + lines, + }); + this.importMessages = importMsgs; + } + + async _executeImportStep(isTest, importRes) { + const importArgs = [this.id, importRes.fields, importRes.columns, this.importOptions]; + + const { ids, messages, nextrow, name, error } = await this._callImport(isTest, importArgs); + if (error) { + return error; + } + + if (ids) { + importRes.ids = importRes.ids.concat(ids); + } + + if (messages && messages.length) { + importRes.hasError = true; + this.stopImport(); + + if (messages[0].not_matching_error) { + this._addMessage(messages[0].type, [messages[0].message]); + return false; + } + + const sortedMessages = this._groupErrorsByField(messages); + if (sortedMessages[0]) { + this._addMessage(sortedMessages[0].type, [sortedMessages[0].message]); + delete sortedMessages[0]; + } else { + this._addMessage("danger", [_t("The file contains blocking errors (see below)")]); + } + + for (const [columnFieldId, errors] of Object.entries(sortedMessages)) { + // Handle errors regarding specific colums. + const column = this.columns.find( + (e) => e.fieldInfo && e.fieldInfo.id === columnFieldId + ); + if (column) { + column.resultNames = name; + column.errors = errors; + } else { + for (const error of errors) { + // Handle errors regarding specific records. + if (error.record !== undefined) { + this._addMessage("danger", [ + error.rows.from === error.rows.to + ? sprintf( + _t('Error at row %s: "%s"'), + error.record, + error.message + ) + : sprintf(_t("%s at multiple rows"), error.message), + ]); + } + // Handle global errors. + else { + this._addMessage("danger", [error.message]); + } + } + } + } + } + + this.setOption("skip", nextrow || 0); + importRes.nextrow = nextrow; + return false; + } + + async _callImport(dryrun, args) { + try { + const res = await this.orm.silent.call("base_import.import", "execute_import", args, { + dryrun, + context: { + ...this.context, + tracking_disable: this.importOptions.tracking_disable, + }, + }); + return res; + } catch (error) { + // This pattern isn't optimal but it is need to have + // similar behaviours as in legacy. That is, catching + // all import errors and showing them inside the top + // "messages" area. + return { error }; + } + } + + _groupErrorsByField(messages) { + const groupedErrors = {}; + const errorsByMessage = groupBy(this._sortErrors(messages), (f) => f.message || "0"); + for (const [message, errors] of Object.entries(errorsByMessage)) { + if (!message.record) { + const foundError = errors.find((e) => e.record === undefined); + if (foundError) { + groupedErrors[0] = foundError; + continue; + } + } + + errors[0].rows.to = errors[errors.length - 1].rows.to; + const fieldId = errors[0].field_path ? errors[0].field_path.join("/") : errors[0].field; + if (groupedErrors[fieldId]) { + groupedErrors[fieldId].push(errors[0]); + } else { + groupedErrors[fieldId] = [errors[0]]; + } + } + return groupedErrors; + } + + _sortErrors(messages) { + return sortBy(messages, (e) => ["error", "warning", "info"].indexOf(e.priority)); + } + + /** + * On the preview data succesfuly loaded, update the + * import options, columns and messages. + * @param {*} res + */ + _onLoadSuccess(res) { + // Set options + for (const key in res.options) { + if (this.importOptionsValues[key]) { + this.importOptionsValues[key].value = res.options[key]; + } + } + + if (!this.fields.length) { + this.importOptionsValues.advanced.value = res.advanced_mode; + } + + this.fields = res.fields; + + // If in advanced mode, handle subfields as well + if (this.importOptionsValues.advanced.value) { + const subfields = []; + const loadSubfields = (field) => { + for (const subfield of field.fields) { + subfield.id = `${field.id}/${subfield.id}`; + subfield.name = `${field.name}/${subfield.name}`; + subfield.string = `${field.string}/${subfield.string}`; + field.isRelation = true; + subfields.push(subfield); + loadSubfields(subfield); + } + }; + for (const field of this.fields) { + loadSubfields(field); + } + this.fields.push(...subfields); + } + + this.columns = this._getColumns(res); + + // Set import messages + if (res.headers.length === 1) { + this._addMessage("warning", [ + _t( + "A single column was found in the file, this often means the file separator is incorrect." + ), + ]); + } + + this._updateComments(); + } + + _onLoadError() { + this.columns = []; + this.importMessages = []; + } + + _getColumns(res) { + function createColumn(model, id, name, index, previews, preview) { + const fields = model._getFields(res, index); + return { + id, + name, + preview, + previews, + fields, + fieldInfo: model._findField(fields, id), + comments: [], + errors: [], + }; + } + + function getId(res, index) { + return res.matches && index in res.matches && res.matches[index].length > 0 + ? res.matches[index].join("/") + : undefined; + } + + if (this.importOptions.has_headers && res.headers && res.preview.length > 0) { + return res.headers.flatMap((header, index) => { + return createColumn( + this, + getId(res, index), + header, + index, + res.preview[index], + res.preview[index][0] + ); + }); + } else if (res.preview && res.preview.length >= 2) { + return res.preview.flatMap((preview, index) => + createColumn( + this, + preview[0], + this.importOptions.has_headers ? preview[0] : preview.join(", "), + index, + preview, + preview[1] + ) + ); + } + return []; + } + + _findField(fields, id) { + return Object.entries(fields) + .flatMap((e) => e[1]) + .find((field) => field.name === id); + } + + /** + * Sort fields into their respective categories, namely: + * - Basic => Only the ID field + * - Suggested => Non-relational fields from the header"s types + * - Additional => Non-relational fields of any other type + * - Relational => Relational fields + * @param {*} res + */ + _getFields(res, index) { + const advanced = this.importOptionsValues.advanced.value; + const acceptedTypes = res.header_types[index]; + const fields = { + basic: [], + suggested: [], + additional: [], + relational: [], + }; + + function hasType(types, field) { + return types && types.indexOf(field.type) !== -1; + } + + const sortSingleField = (field, collection, types) => { + // Get field respective category + if (!collection) { + if (field.name === "id") { + collection = fields.basic; + } else if (!isRelational(field)) { + collection = hasType(types, field) ? fields.suggested : fields.additional; + } else { + collection = fields.relational; + } + } + + // Add field to found category + collection.push(field); + + if (advanced) { + for (const subfield of field.fields) { + sortSingleField(subfield, collection, types); + } + } + }; + + // Sort fields in their respective categories + for (const field of this.fields) { + if (!field.isRelation) { + if (advanced) { + sortSingleField(field, undefined, ["all"]); + } else { + sortSingleField(field, undefined, acceptedTypes); + } + } + } + return fields; + } + + _updateComments(updatedColumn) { + for (const column of this.columns) { + column.comments = []; + column.errors = []; + column.resultNames = []; + column.importOptions = column.fieldInfo && this.fieldsToHandle[column.fieldInfo.id]; + + if (!column.fieldInfo) { + continue; + } + + // Fields of type "char", "text" or "many2many" can be specified multiple + // times and they will be concatenated, fields of other types must be unique. + if (["char", "text", "many2many"].includes(column.fieldInfo.type)) { + if (column.fieldInfo.type === "many2many") { + column.comments.push({ + type: "info", + content: _t("To import multiple values, separate them by a comma."), + }); + } + + // If multiple columns are mapped on the same field, inform + // the user that they will be concatenated. + const samefieldColumns = this.columns.filter( + (col) => col.fieldInfo && col.fieldInfo.id === column.fieldInfo.id + ); + if (samefieldColumns.length >= 2) { + column.comments.push({ + type: "info", + content: _t("This column will be concatenated in field"), + fieldName: column.fieldInfo.string, + }); + } + } else if (updatedColumn && column.id !== updatedColumn.id && updatedColumn.fieldInfo) { + // If column is mapped on an already mapped field, remove that field + // from the old column to keep it unique. + if (updatedColumn.fieldInfo.id === column.fieldInfo.id) { + column.fieldInfo = null; + } + } + } + } + + _getCSVFormattingOptions() { + return { + encoding: { + label: _t("Encoding:"), + type: "select", + value: "", + options: [ + "utf-8", + "utf-16", + "windows-1252", + "latin1", + "latin2", + "big5", + "gb18030", + "shift_jis", + "windows-1251", + "koir8_r", + ], + }, + separator: { + label: _t("Separator:"), + type: "select", + value: "", + options: [ + { value: ",", label: _t("Comma") }, + { value: ";", label: _t("Semicolon") }, + { value: "\t", label: _t("Tab") }, + { value: " ", label: _t("Space") }, + ], + }, + quoting: { + label: _t("Text Delimiter:"), + type: "input", + value: '"', + options: "", + }, + date_format: { + label: _t("Date Format:"), + type: "select", + value: "", + options: [ + "%Y-%m-%d", + "YYYY-MM-DD", + "DD/MM/YY", + "DD/MM/YYYY", + "DD-MM-YYYY", + "DD-MMM-YY", + "DD-MMM-YYYY", + "MM/DD/YY", + "MM/DD/YYYY", + "MM-DD-YY", + "MM-DD-YYYY", + "DDMMYY", + "DDMMYYYY", + "YYMMDD", + "YYYYMMDD", + "YY/MM/DD", + "YYYY/MM/DD", + "MMDDYY", + "MMDDYYYY", + ], + }, + datetime_format: { + label: _t("Datetime Format:"), + type: "input", + value: "", + options: "", + }, + float_thousand_separator: { + label: _t("Thousands Separator:"), + type: "select", + value: "", + options: [ + { value: ",", label: _t("Comma") }, + { value: ".", label: _t("Dot") }, + { value: "", label: _t("No Separator") }, + ], + }, + float_decimal_separator: { + label: _t("Decimals Separator:"), + type: "select", + value: "", + options: [ + { value: ",", label: _t("Comma") }, + { value: ".", label: _t("Dot") }, + ], + }, + }; + } +} + +/** + * @returns {BaseImportModel}columns + */ +export function useImportModel({ env, resModel, context, orm }) { + return useState(new BaseImportModel({ env, resModel, context, orm })); +} diff --git a/addons/base_import/static/src/import_records/import_records.js b/addons/base_import/static/src/import_records/import_records.js index e8c512e5bfc..8b035061d47 100644 --- a/addons/base_import/static/src/import_records/import_records.js +++ b/addons/base_import/static/src/import_records/import_records.js @@ -3,8 +3,8 @@ import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; +import { Component } from "@odoo/owl"; -const { Component } = owl; const favoriteMenuRegistry = registry.category("favoriteMenu"); /** @@ -14,6 +14,9 @@ const favoriteMenuRegistry = registry.category("favoriteMenu"); * @extends Component */ export class ImportRecords extends Component { + static template = "base_import.ImportRecords"; + static components = { DropdownItem }; + setup() { this.action = useService("action"); } @@ -32,9 +35,6 @@ export class ImportRecords extends Component { } } -ImportRecords.template = "base_import.ImportRecords"; -ImportRecords.components = { DropdownItem }; - export const importRecordsItem = { Component: ImportRecords, groupNumber: 4, diff --git a/addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png b/addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png deleted file mode 100644 index 70b5d50a577..00000000000 Binary files a/addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png and /dev/null differ diff --git a/addons/base_import/static/src/legacy/js/import_action.js b/addons/base_import/static/src/legacy/js/import_action.js deleted file mode 100644 index 843f95e0c86..00000000000 --- a/addons/base_import/static/src/legacy/js/import_action.js +++ /dev/null @@ -1,1283 +0,0 @@ -odoo.define('base_import.import', function (require) { -"use strict"; - -var AbstractAction = require('web.AbstractAction'); -var core = require('web.core'); -var session = require('web.session'); -var time = require('web.time'); -var fieldUtils = require('web.field_utils'); - -var QWeb = core.qweb; -var _t = core._t; -var _lt = core._lt; -var StateMachine = window.StateMachine; - -/** - * Safari does not deal well at all with raw JSON data being - * returned. As a result, we're going to cheat by using a - * pseudo-jsonp: instead of getting JSON data in the iframe, we're - * getting a ``script`` tag which consists of a function call and - * the returned data (the json dump). - * - * The function is an auto-generated name bound to ``window``, - * which calls back into the callback provided here. - * - * @param {Object} form the form element (DOM or jQuery) to use in the call - * @param {Object} attributes jquery.form attributes object - * @param {Function} callback function to call with the returned data - */ -function jsonp(form, attributes, callback) { - attributes = attributes || {}; - var options = {jsonp: _.uniqueId('import_callback_')}; - window[options.jsonp] = function () { - delete window[options.jsonp]; - callback.apply(null, arguments); - }; - if ('data' in attributes) { - _.extend(attributes.data, options); - } else { - _.extend(attributes, {data: options}); - } - _.extend(attributes, { - dataType: 'script', - }); - $(form).ajaxSubmit(attributes); -} -function _make_option(term) { return {id: term, text: term }; } -function _from_data(data, term) { - return _.findWhere(data, {id: term}) || _make_option(term); -} - -/** - * query returns a list of suggestion select2 objects, this function: - * - * * returns data exactly matching query by either id or text if those exist - * * otherwise it returns a select2 option matching the term and any data - * option whose id or text matches (by substring) - */ -function dataFilteredQuery(q) { - var suggestions = _.clone(this.data); - if (q.term) { - var exact = _.filter(suggestions, function (s) { - return s.id === q.term || s.text === q.term; - }); - if (exact.length) { - suggestions = exact; - } else { - suggestions = [_make_option(q.term)].concat(_.filter(suggestions, function (s) { - return s.id.indexOf(q.term) !== -1 || s.text.indexOf(q.term) !== -1 - })); - } - } - q.callback({results: suggestions}); -} - -var DataImport = AbstractAction.extend({ - hasControlPanel: true, - contentTemplate: 'ImportView', - opts: [ - {name: 'encoding', label: _lt("Encoding:"), value: ''}, - {name: 'separator', label: _lt("Separator:"), value: ''}, - {name: 'quoting', label: _lt("Text Delimiter:"), value: '"'} - ], - spreadsheet_opts: [ - {name: 'sheet', label: _lt("Selected Sheet:"), value: ''}, - ], - parse_opts_formats: [ - {name: 'date_format', label: _lt("Date Format:"), value: ''}, - {name: 'datetime_format', label: _lt("Datetime Format:"), value: ''}, - ], - parse_opts_separators: [ - {name: 'float_thousand_separator', label: _lt("Thousands Separator:"), value: ','}, - {name: 'float_decimal_separator', label: _lt("Decimal Separator:"), value: '.'} - ], - events: { - 'change .oe_import_file': 'loaded_file', - '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'](); - }, - 'click .oe_import_report a.oe_import_report_count': function (e) { - e.preventDefault(); - $(e.target).parent().find('i.arrow').toggleClass('up down'); - $(e.target).parent().parent().toggleClass('oe_import_report_showmore'); - }, - 'click .oe_import_report_see_possible_value': function (e) { - e.preventDefault(); - $(e.target).parent().toggleClass('oe_import_report_showmore'); - }, - 'click .oe_import_moreinfo_action a': function (e) { - e.preventDefault(); - // #data will parse the attribute on its own, we don't like - // that sort of things - var action = JSON.parse($(e.target).attr('data-action')); - // FIXME: when JS-side clean_action - action.views = _(action.views).map(function (view) { - var id = view[0], type = view[1]; - return [ - id, - type !== 'tree' ? type - : action.view_type === 'form' ? 'list' - : 'tree' - ]; - }); - this.do_action(_.extend(action, { - target: 'new', - })); - }, - }, - init: function (parent, action) { - this._super.apply(this, arguments); - this.action_manager = parent; - this.res_model = action.params.model; - this.parent_context = action.params.context || {}; - // import object id - this.id = null; - this.session = session; - this._title = action.name || _t('Import a File'); // Displayed in the breadcrumbs - this.do_not_change_match = false; - this.sheets = []; - this.selectionFields = {}; // Used to compute fallback values in backend. - }, - /** - * @override - */ - willStart: function () { - var self = this; - var def = this._rpc({ - model: this.res_model, - method: 'get_import_templates', - context: this.parent_context, - }).then(function (result) { - self.importTemplates = result; - }); - return Promise.all([this._super.apply(this, arguments), def]); - }, - start: function () { - var self = this; - this.$form = this.$('form'); - this.setup_encoding_picker(); - this.setup_separator_picker(); - this.setup_float_format_picker(); - this.setup_date_format_picker(); - this.setup_sheets_picker(); - this.renderButtons(); - this.controlPanelProps.cp_content = { $buttons: this.$buttons }; - - return Promise.all([ - this._super(), - self.create_model().then(function (id) { - self.id = id; - self.$('input[name=import_id]').val(id); - }), - ]); - }, - create_model: function() { - return this._rpc({ - model: 'base_import.import', - method: 'create', - args: [{res_model: this.res_model}], - kwargs: {context: session.user_context}, - }); - }, - renderButtons: function() { - var self = this; - this.$buttons = $(QWeb.render("ImportView.buttons", this)); - this.$buttons.filter('.o_import_validate').on('click', this.validate.bind(this)); - this.$buttons.filter('.o_import_import').on('click', this.import.bind(this)); - this.$buttons.filter('.oe_import_file').on('click', function () { - self.$('.o_content .oe_import_file').click(); - }); - this.$buttons.filter('.o_import_cancel').on('click', function(e) { - e.preventDefault(); - self.exit(); - }); - }, - setup_encoding_picker: function () { - this.$('input.oe_import_encoding').select2({ - width: '50%', - data: _.map(('utf-8 utf-16 windows-1252 latin1 latin2 big5 gb18030 shift_jis windows-1251 koir8_r').split(/\s+/), _make_option), - query: dataFilteredQuery, - minimumResultsForSearch: -1, - initSelection: function ($e, c) { - return c(_make_option($e.val())); - } - }); - }, - setup_separator_picker: function () { - var data = [ - {id: ',', text: _t("Comma")}, - {id: ';', text: _t("Semicolon")}, - {id: '\t', text: _t("Tab")}, - {id: ' ', text: _t("Space")} - ]; - this.$('input.oe_import_separator').select2({ - width: '50%', - data: data, - query: dataFilteredQuery, - minimumResultsForSearch: -1, - // this is not provided to initSelection so can't use this.data - initSelection: function ($e, c) { - c(_from_data(data, $e.val()) || _make_option($e.val())) - } - }); - }, - setup_float_format_picker: function () { - var data_decimal = [ - {id: ',', text: _t("Comma")}, - {id: '.', text: _t("Dot")}, - ]; - var data_digits = data_decimal.concat([{id: '', text: _t("No Separator")}]); - this.$('input.oe_import_float_thousand_separator').select2({ - width: '50%', - data: data_digits, - query: dataFilteredQuery, - minimumResultsForSearch: -1, - initSelection: function ($e, c) { - c(_from_data(data_digits, $e.val()) || _make_option($e.val())) - } - }); - this.$('input.oe_import_float_decimal_separator').select2({ - width: '50%', - data: data_decimal, - query: dataFilteredQuery, - minimumResultsForSearch: -1, - initSelection: function ($e, c) { - c(_from_data(data_decimal, $e.val()) || _make_option($e.val())) - } - }); - }, - setup_date_format_picker: function () { - var data = _([ - 'YYYY-MM-DD', - 'DD/MM/YY', - 'DD/MM/YYYY', - 'DD-MM-YYYY', - 'DD-MMM-YY', - 'DD-MMM-YYYY', - 'MM/DD/YY', - 'MM/DD/YYYY', - 'MM-DD-YY', - 'MM-DD-YYYY', - 'DDMMYY', - 'DDMMYYYY', - 'YYMMDD', - 'YYYYMMDD', - 'YY/MM/DD', - 'YYYY/MM/DD', - 'MMDDYY', - 'MMDDYYYY', - ]).map(_make_option); - this.$('input.oe_import_date_format').select2({ - width: '50%', - data: data, - query: dataFilteredQuery, - minimumResultsForSearch: -1, - initSelection: function ($e, c) { - c(_from_data(data, $e.val()) || _make_option($e.val())); - } - }) - }, - setup_sheets_picker: function () { - var data = this.sheets.map(_make_option); - this.$('input.oe_import_sheet').select2({ - width: '100%', - data: data, - query: dataFilteredQuery, - minimumResultsForSearch: -1, - initSelection: function ($e, c) { - c(_from_data(data, $e.val()) || _make_option($e.val())) - }, - }); - }, - - import_options: function () { - var self = this; - var options = { - has_headers: this.$('input.oe_import_has_header').prop('checked'), - advanced: this.$('input.oe_import_advanced_mode').prop('checked'), - keep_matches: this.do_not_change_match, - name_create_enabled_fields: {}, - import_set_empty_fields: [], - import_skip_records: [], - fallback_values: {}, - // start at row 1 = skip 0 lines - skip: Number(this.$('#oe_import_row_start').val()) - 1 || 0, - limit: Number(this.$('#oe_import_batch_limit').val()) || null, - }; - _.each(_.union(this.opts, this.spreadsheet_opts), function (opt) { - options[opt.name] = - self.$('input.oe_import_' + opt.name).val(); - }); - _(this.parse_opts_formats).each(function (opt) { - options[opt.name] = time.moment_to_strftime_format(self.$('input.oe_import_' + opt.name).val()); - }); - _(this.parse_opts_separators).each(function (opt) { - options[opt.name] = self.$('input.oe_import_' + opt.name).val(); - }); - - options['fields'] = []; - if (this.do_not_change_match) { - options['fields'] = this.$('input.oe_import_match_field').map(function (index, el) { - return $(el).select2('val') || false; - }).get(); - } - this.do_not_change_match = false; - this.$('select.o_import_create_option').each(function () { - var field = this.getAttribute('field'); - var type = this.getAttribute('type'); - if (field) { - if (['boolean', 'many2one', 'many2many', 'selection'].includes(type) && this.value === 'skip_record') { - options.import_skip_records.push(field); - } else if (['many2one', "many2many", "selection"].includes(type)) { - if (this.value === 'set_empty') { - options.import_set_empty_fields.push(field); - } else { - options.name_create_enabled_fields[field] = this.value === 'create'; - } - // for selection, include also 'skip' that will be interpreted as 'None' in backend. - } else if (['boolean','selection'].includes(type) && this.value !== 'prevent') { - options.fallback_values[field] = { - fallback_value: this.value, - field_model: this.getAttribute('model'), - field_type: type - }; - } - } - }); - return options; - }, - - //- File & settings change section - 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'); - if (!this.$('input.oe_import_file').val()) { return this['settings_changed'](); } - this.$('.oe_import_date_format').select2('val', ''); - this.$('.oe_import_datetime_format').val(''); - this.$('.oe_import_sheet').val(''); - - this.$form.removeClass('oe_import_preview oe_import_error'); - var file = this.$('input.oe_import_file')[0].files[0]; - // some platforms send text/csv, application/csv, or other things if Excel is prevent - var isCSV = ((file.type && _.last(file.type.split('/')) === "csv") || ( _.last(file.name.split('.')) === "csv")) - this.$form.find('.o_import_formatting').toggleClass('d-none', !isCSV); - - // get file name and extension separately, to apply ellipsis to the name and still keep the extension visible. - // E.g. : superLongFileName.csv -> "superLongFi....csv" instead of "superLongFileNa..." - var fileName = file.name.split('.'); - var fileExtension = fileName.pop(); - fileName = fileName.join('.'); - this.$('#oe_imported_file').text(fileName); - this.$('#oe_imported_file_extension').text('.' + fileExtension); - - this.$form.find('.oe_import_box').toggle(true); - jsonp(this.$form, { - url: '/base_import/set_file' - }, this.proxy('settings_changed')); - }, - onpreviewing: function () { - var self = this; - this.$buttons.filter('.o_import_import, .o_import_validate').addClass('d-none'); - this.$form.addClass('oe_import_with_file'); - // TODO: test that write // succeeded? - this.$form.removeClass('oe_import_preview_error oe_import_error'); - this.$form.toggleClass( - 'oe_import_noheaders', - !this.$('input.oe_import_has_header').prop('checked')); - - // Clear the input value to allow onchange to be triggered - // if the file is the same (for all browsers) - this.$('input.oe_import_file').val(''); - this.$('.oe_import_options_cell,.oe_import_options_header').addClass('d-none'); - - this._cleanComments(); - - // Block UI during loading file. - $.blockUI({message: QWeb.render('Throbber')}); - $(document.body).addClass('o_ui_blocked'); - $('.oe_throbber_message').text(_t("Loading file...")); - - this._rpc({ - model: 'base_import.import', - method: 'parse_preview', - args: [this.id, this.import_options()], - kwargs: {context: session.user_context}, - }).then(function (result) { - var signal = result.error ? 'preview_failed' : 'preview_succeeded'; - self[signal](result); - $(document.body).removeClass('o_ui_blocked'); - $.unblockUI(); - }); - }, - onpreview_error: function (event, from, to, result) { - this.$('.oe_import_options').show(); - this.$form.addClass('oe_import_preview_error oe_import_error'); - this.$form.find('.oe_import_box, .oe_import_with_file').removeClass('d-none'); - this.$form.find('.o_view_nocontent').addClass('d-none'); - this.$('.oe_import_error_report').html( - QWeb.render('ImportView.preview.error', result)); - }, - onpreview_success: function (event, from, to, result) { - var self = this; - this.$buttons.filter('.oe_import_file') - .text(_t('Load File')) - .removeClass('btn-primary').addClass('btn-secondary') - .blur(); - this.$buttons.filter('.o_import_import, .o_import_validate').removeClass('d-none'); - this.$form.find('.oe_import_box, .oe_import_with_file').removeClass('d-none'); - this.$form.find('.o_view_nocontent').addClass('d-none'); - this.$form.addClass('oe_import_preview'); - this.$('input.oe_import_advanced_mode').prop('checked', result.advanced_mode); - this.$('.oe_import_grid').html(QWeb.render('ImportView.preview', result)); - this.$('.oe_import_grid .o_import_preview').each((index, element) => { - $(element).popover({ - title: _t("Preview"), - trigger: 'hover', - html: true, - content: QWeb.render('ImportView.preview_popover', { preview: result.preview[index] }), - }); - }); - // 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.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 = []; - if (result.headers.length === 1) { - messages.push({type: 'warning', message: _t("A single column was found in the file, this often means the file separator is incorrect")}); - } - - if (!_.isEmpty(messages)) { - this.$('.oe_import_options').show(); - this.onresults(null, null, null, {'messages': messages}); - } - - if (!_.isEqual(this.sheets, result.options.sheets)) { - this.sheets = result.options.sheets || []; - this.setup_sheets_picker(); - } - this.$('div.oe_import_has_multiple_sheets').toggle( - this.sheets.length > 1 - ); - - // merge option values back in case they were updated/guessed - _.each(['encoding', 'separator', 'float_thousand_separator', 'float_decimal_separator', 'sheet'], function (id) { - self.$('.oe_import_' + id).select2('val', result.options[id]) - }); - this.$('.oe_import_date_format').select2('val', time.strftime_to_moment_format(result.options.date_format)); - this.$('.oe_import_datetime_format').val(time.strftime_to_moment_format(result.options.datetime_format)); - // hide all "true debug" options when not in debug mode - this.$('.oe_import_debug_option').toggleClass('d-none', !result.debug); - - var $fields = this.$('.oe_import_match_field'); - this.render_fields_matches(result, $fields); - var data = this._generate_fields_completion(result); - var item_finder = function (id, items) { - items = items || data; - for (var i=0; i < items.length; ++i) { - var item = items[i]; - if (item.id === id) { - return item; - } - var val; - if (item.children && (val = item_finder(id, item.children))) { - return val; - } - } - return ''; - }; - $fields.each(function (k,v) { - var $fieldInput = $(v); - var filteredData = self._generate_fields_completion(result, k); - - var updateFieldInformation = function (fieldInfo) { - // Get the comment cell on the same row in the table. - var $commentCell = $fieldInput.closest('tr.oe_import_grid-row').find('.oe_import_comment_cell'); - var $optionsDiv = $commentCell.find('.oe_import_options_div'); - var isRelational = fieldInfo.type === 'many2many' || fieldInfo.type === 'many2one'; - var setEmpty = !fieldInfo.required && (isRelational || fieldInfo.type === 'selection'); - - // if relational field is child of one2many then do not display skip record option - var isO2MField = false; - if (isRelational && fieldInfo.id.includes('/')) { - var fieldName = fieldInfo.id.split('/')[0]; - var field = result.fields.find((field) => field.name === fieldName); - if (field && field.type === 'one2many') { - isO2MField = true; - } - } - var showSkipRecord = setEmpty && !isO2MField || (!fieldInfo.required && fieldInfo.type === 'boolean'); - - // get options cell related to that field - if (isRelational || fieldInfo.type === 'boolean') { - $optionsDiv.empty(); - $optionsDiv.append( - QWeb.render('ImportView.create_record_option', { - data: fieldInfo, - is_relational: isRelational, - set_empty: setEmpty, - show_skip_record: showSkipRecord - }) - ); - } else if (fieldInfo.type === 'selection') { - self._rpc({ - model: fieldInfo.comodel_name || fieldInfo.model_name, - method: 'fields_get', - }).then(function (values) { - var selectionField = fieldInfo.id.split('/').pop(); - var selectionLabels = values[selectionField]["selection"].map(value => value[1]); - - $optionsDiv.empty(); - $optionsDiv.append( - QWeb.render('ImportView.create_record_option', { - data: fieldInfo, - values: selectionLabels, - set_empty: setEmpty, - show_skip_record: showSkipRecord - }) - ); - self.selectionFields[data.id] = values; - }); - } else if ($optionsDiv.find('.o_import_create_option').length == 1) { - $optionsDiv.empty(); - } - - // re-attribute comment cell to the new selected field - $commentCell.attr('field', fieldInfo.id || ""); - $commentCell.attr('model', fieldInfo.comodel_name || fieldInfo.model_name || ""); - - // assign class for field icon - var $fieldDropdown = $fieldInput.closest('.oe_import_match_cell').find('.select2-choice'); - var oldType = $fieldDropdown.getAttributes()['type']; - $fieldDropdown.removeClass(`o_import_field_${oldType}`).addClass(`o_import_field_icon o_import_field_${fieldInfo.type}`); - $fieldDropdown.attr('type', fieldInfo.type); - }; - - var default_value = $fieldInput.val(); - var fieldInfo = item_finder(default_value); - if (!fieldInfo) { - $fieldInput.val(''); - } - - $fieldInput.select2({ - allowClear: true, - minimumInputLength: 0, - data: filteredData, - initSelection: function (element, callback) { - if (!default_value) { - callback(''); - return; - } - - updateFieldInformation(fieldInfo); - callback(fieldInfo); - self._handleMappingComments(v, fieldInfo); - }, - // Format the tooltip. - formatSelection: function (object, container) { - var fieldTooltipString = `%(label)s: %(labelValue)s -%(name)s: %(nameValue)s -%(type)s: %(typeValue)s -%(model)s: %(modelValue)s`; - var tooltip = _.str.sprintf(fieldTooltipString, { - 'label': _t("Label"), 'labelValue': object.text, - 'name': _t("Name"), 'nameValue': object.id, - 'type': _t("Type"), 'typeValue': object.type, - 'model': _t("Model"), 'modelValue': object.comodel_name || object.model_name, - }); - $(container[0]).closest('a').attr('title', tooltip); - return object.text; - }, - // For each choice, if field is required, make it bold in the list - formatResultCssClass: function (object) { - if (object.required) { return "fw-bold text-decoration-underline"; } - return ""; - }, - placeholder: _t('To import, select a field...'), - width: 'resolve', - dropdownCssClass: 'oe_import_selector' - }).on('change', function (event) { - var changedField = event.currentTarget; - var fieldRemovedId = event.removed ? event.removed.id : false; - self._cleanFieldComments(changedField, fieldRemovedId); - - var fieldInfo = item_finder(changedField.value); - updateFieldInformation(fieldInfo); - self._handleMappingComments(changedField, fieldInfo); - - if (!event.val) { - var $matchingCell = $(changedField).closest('.oe_import_match_cell') - $matchingCell.find('.o_import_field_icon').removeClass('o_import_field_icon'); - $matchingCell.find('a.select2-choice').removeAttr("title"); - } - }); - - $fieldInput.closest('.oe_import_match_cell').find('.select2-input').attr('placeholder', _t('Search for a field...')); - }); - }, - - /** - * This method is called when changing the field to map with a file column, or when removing it. - * It will clean the eventual comments, errors and the mapping options related to the previously selected field. - * - * @private - */ - _cleanFieldComments: function (changedField, fieldRemovedId) { - // Check that the column was not mapped to same field than another column - if (fieldRemovedId) { - var $sameMappedFields = this.$(`.oe_import_comment_cell[field=\"${fieldRemovedId}\"]`).find('.oe_import_same_mapped_field'); - if ($sameMappedFields.length == 2) { - // remove all same mapped field comments - $sameMappedFields.remove(); - } - } - // remove all comments for this header-field mapping row - var $fieldRow = $(changedField).closest('tr.oe_import_grid-row'); - $fieldRow.find('.oe_import_comments_div').empty(); - $fieldRow.find('.oe_import_options_div').addClass("d-none"); - }, - - /** - * This method is called when selecting a sheet or a new file to import, but also when running to import or the - * import test. It will remove all the general comments and/or field specific warnings and errors - * - * @private - */ - _cleanComments: function () { - this.$('.oe_import_error_report').empty(); - this.$('.oe_import_comments_div').find('.alert-error,.alert-warning').remove(); - 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. - * 1. In case the field type is char or text: - * This method will display / remove the comments informing the user that two columns content will be - * concatenated on the field they have chosen. - * 2. Else: - * This method will remove the field mapping from the old column, as each field, except for the case 1., can only - * be mapped once. - * - * @private - */ - _handleMappingComments: function (changedField, fieldInfo) { - // check if two columns are mapped on the same fields (for char/text fields) - var commentsToAdd = []; - var $sameMappedFields = this.$('.oe_import_comment_cell[field="' + fieldInfo.id + '"]'); - - if (fieldInfo.type == 'many2many') { - commentsToAdd.push(QWeb.render('ImportView.comment_m2m_field')); - } - if ($sameMappedFields.length >= 2) { - if (['char', 'text', "many2many"].includes(fieldInfo.type)) { - commentsToAdd.push(QWeb.render('ImportView.comment_same_mapped_field', { - field: fieldInfo.text, - })); - } else { // if column is mapped on an already mapped field, remove that field from the old column. - var $targetMappedFieldId = $(changedField).parent().find('div.oe_import_match_field').getAttributes()['id']; - _.each($sameMappedFields, function(fieldComment) { - var $mappingCell = $(fieldComment).parent().find('div.oe_import_match_field'); - if ($mappingCell.getAttributes()['id'] !== $targetMappedFieldId) { - $mappingCell.find('.select2-search-choice-close').trigger('mousedown').trigger('click'); - } - }); - } - } - - var $commentDiv = $sameMappedFields.find(".oe_import_comments_div"); - $commentDiv.empty(); - _.each($commentDiv, function(fieldComment) { - _.each(commentsToAdd, function(comment) { - $(fieldComment).append(comment); - }); - }); - }, - - _generate_fields_completion: function (root, index) { - var self = this; - var basic = []; - var regulars = []; - var o2m = []; - var suggested = []; - var header_types = root.header_types; - function traverse(field, ancestors, collection, type) { - var subfields = field.fields; - var advanced_mode = self.$('input.oe_import_advanced_mode').prop('checked'); - var field_path = ancestors.concat(field); - var label = _(field_path).pluck('string').join(' / '); - var id = _(field_path).pluck('name').join('/'); - // If non-relational, m2o or m2m, collection is either suggested if field type is in header_types, either regulars if type does not match - if (!collection) { - if (field.name === 'id') { - collection = basic; - } else if (_.isEmpty(subfields) - || _.isEqual(_.pluck(subfields, 'name'), ['id', '.id'])) { - collection = (type && type.indexOf(field['type']) !== -1) ? suggested : regulars; - } else { - collection = o2m; - } - } - - collection.push({ - id: id, - text: label, - required: field.required, - type: field.type, - default_value: field.default_value, - comodel_name: field.comodel_name, - model_name: field.model_name, - }); - - if (advanced_mode){ - for(var i=0, end=subfields.length; i 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) { - var error = reason.message; - var event = reason.event; - // In case of unexpected exception, convert - // "JSON-RPC error" to an import failure, and - // prevent default handling (warning dialog) - if (event) { event.preventDefault(); } - - var msg; - var errordata = error.data || {}; - if (errordata.type === 'xhrerror') { - var xhr = errordata.objects[0]; - switch (xhr.status) { - case 504: // gateway timeout - msg = _t("Import timed out. Please retry. If you still encounter this issue, the file may be too big for the system's configuration, try to split it (import less records per file)."); - break; - default: - msg = _t("An unknown issue occurred during import (possibly lost connection, data limit exceeded or memory limits exceeded). Please retry in case the issue is transient. If the issue still occurs, try to split the file rather than import it at once."); - } - } else { - msg = errordata.arguments && (errordata.arguments[1] || errordata.arguments[0]) - || error.message; - } - - return Promise.resolve({'messages': [{ - type: 'error', - record: false, - message: msg, - }]}); - }).finally(function () { - $(document.body).removeClass('o_ui_blocked'); - $.unblockUI(); - }); - }, /** - * - * @param opts import options - * @param args positional arguments to pass along (augmented with the options) - * @param kwargs keyword arguments to pass along (directly) - * @param {Object} rec recursion information record - * @param {Number} rec.done how many records have been loaded so far - * @param {Number} rec.prev nextrow of the previous call so we can know - * how many rows the call we're here performing - * will have consumed, and thus by how much we - * need to offset the messages of the *next* call - * @returns {Promise<{name, ids, messages}>} - * @private - */ - _batchedImport: function (opts, args, kwargs, rec) { - 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', - args: args.concat([opts]), - kwargs: kwargs - }, { - shadow: true, - }).then(function (results) { - _.each(results.messages, offset_by(opts.skip)); - if (!kwargs.dryrun && !results.ids) { - // update skip to failed batch - self.$('#oe_import_row_start').val(opts.skip + 1); - if (opts.skip) { - // there's been an error during a "proper" import, stop & warn - // about partial import maybe - results.messages.push({ - type: 'info', - priority: true, - message: _.str.sprintf(_t("This file has been successfully imported up to line %d."), opts.skip) - }); - } - return results; - } - if (!results.nextrow) { - // we're done - return results; - } - - // do the next batch - return self._batchedImport( - // avoid modifying opts in-place - _.defaults({skip: results.nextrow}, opts), - args, kwargs, { - done: rec.done + (results.ids || []).length, - prev: results.nextrow - } - ).then(function (r2) { - return { - name: _.zip(results.name, r2.name).map(function (names) { - return names[0] || names[1]; - }), - ids: (results.ids || []).concat(r2.ids || []), - messages: r2.messages ? results.messages.concat(r2.messages) : results.messages, - skip: r2.skip || results.nextrow, - nextrow: r2.nextrow - } - }); - }); - }, - onvalidate: function () { - var prom = this.call_import({ dryrun: true, tracking_disable: true }); - prom.then(this.proxy('validated')); - return prom; - }, - onimport: function () { - var self = this; - var prom = this.call_import({ dryrun: false }); - prom.then(function (results) { - 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; - } - self['import_failed'](results); - }); - return prom; - }, - onimported: function (event, from, to, results) { - this.displayNotification({ message: _.str.sprintf( - _t("%d records successfully imported"), - results.ids.length - ) }); - this.exit(); - }, - exit: function () { - this.trigger_up('history_back'); - }, - onresults: function (event, from, to, results) { - var self = this; - var fields = this.$('input.oe_import_match_field').map(function (index, el) { - return $(el).select2('val') || false; - }).get(); - - var error_type = "warning"; - var errorMessages = results.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); - } - - // row indexes come back 0-indexed, spreadsheets - // display 1-indexed. - var offset = 1; - // offset more if header - if (this.import_options().has_headers) { offset += 1; } - - // Sort errors by error gravity then by field - var errorsSorted = _.sortBy(_(errorMessages).groupBy('message'), function (group) { - if (group[0].priority){ - return -2; - } - - // sort by gravity, then, order of field in list - var order = 0; - switch (group[0].type) { - case 'error': order = 0; error_type = 'error'; break; - case 'warning': order = fields.length + 1; break; - case 'info': order = 2 * (fields.length + 1); break; - default: order = 3 * (fields.length + 1); break; - } - return order + _.indexOf(fields, group[0].field); - }); - - // regroup errors by field - var errorsByFields = this._regroupErrorsByFields(errorsSorted); - - // If no general comments (index "0"), add general warning that there are some errors/warnings - if (!errorsByFields[0] && error_type) { - var message = error_type === "warning" ? - _t("The file contains non-blocking warnings (see below)") : - _t("The file contains blocking errors (see below)"); - errorsByFields[0] = [{ - 'type': error_type, - 'message': message, - }]; - } - - // Clean old comments - self._cleanComments(); - - // Add new comments - _.each(errorsByFields, function (errors, field) { - var $errorCell = self.$('.oe_import_comment_cell[field="'+ field +'"]'); - var $errorDiv = $errorCell.find(".oe_import_comments_div"); - var errorTemplate = 'ImportView.fieldError'; - // If error not linked to a targeted Odoo field, show in global error div - if ($errorDiv.length === 0) { - $errorDiv = self.$('.oe_import_error_report'); - self.$form.addClass('oe_import_error'); - field = 0; - errorTemplate = 'ImportView.error'; - } - $errorDiv.append( - QWeb.render(errorTemplate, { - errors: errors, - field: field, - result_names: results.name, - offset: offset, - }) - ); - var mainError = errors[0]; - if (!mainError.not_matching_error && mainError.type === "error") { - $errorCell.find(".oe_import_options_div").removeClass('d-none'); - } - }); - }, - - /** - * This method regroups the errors by their 'field' key. - * Errors of same nature encountered on multiple rows for the same field are regrouped into the same unique error - * and rows_from and rows_to are adapted accordingly. - * The goal is to ease the error attribution by looping through each mapped field and add their corresponding errors - * to the comment cell of that mapped field. - * Some errors are not linked to a mapped field and will be displayed in the general error container. - * Those general errors will be identified with key "0". - * - * @private - * @param {Array} errorsSorted: list of errors sorted by error gravity, then by field. - * Each item of the list is a list of error of the same nature and can be therefore composed of 1 or more errors. - * E.g.: [ - * [ - * { - * 'type': 'error', - * 'field_path': 'product_id/price', - * 'rows': {'from': 1, 'to': 1}, // This error have been encountered between row 1 and row 1 (at row 1) - * 'message': {"Missing Value"} - * }, - * { - * 'type': 'error', - * 'field_path': 'product_id/price', - * 'rows': {'from': 2, 'to': 2}, // This error have been encountered between row 2 and row 2 (at row 2) - * 'message': {"Missing Value"} - * } - * ], - * [ // this one is a general error, not linked specifically to a mapped field. - * { - * 'type': 'error', - * 'message': {"Found more than 10 errors and more than one error per 10 records, interrupted to avoid showing too many errors."} - * } - * ], - * [ - * { - * 'type': 'warning', - * 'field': 'quantity', - * 'rows': {'from': 2, 'to': 4}, // This error have been encountered between row 2 and row 4 (at 3 different rows) - * 'message': {} - * } - * ], - * ] - * @returns {Dict} errorsByFields: {fieldName: [error1, error2, ...]} - * E.g.: { - * 0: [ - * { - * 'type': 'error', - * 'message': {"Found more than 10 errors and more than one error per 10 records, interrupted to avoid showing too many errors."} - * } - * ], - * 'product_id/price': [ - * { - * 'type': 'error', - * 'field_path': 'product_id/price', - * 'rows': {'from': 1, 'to': 2}, // This error have been encountered between row 1 and row 2 - * 'message': {"Missing Value"} - * } - * ], - * 'quantity': [ - * { - * 'type': 'warning', - * 'field': 'quantity', - * 'rows': {'from': 2, 'to': 4}, // This error have been encountered between row 2 and row 4 - * 'message': {} - * } - * ], - * } - */ - _regroupErrorsByFields: function (errorsSorted) { - var errorsByFields = {} - _.each(errorsSorted, function (errors) { - var mainError = errors[0]; - var field = mainError.field_path ? mainError.field_path.join('/') : mainError.field || 0; - - // regroup errors for same value at different rows in the same error. - if (errors.length > 1 && mainError.rows) { - var rowFrom = mainError.rows.from; - var rowTo = errors[errors.length - 1].rows.to; - errors = mainError; - mainError.rows.from = rowFrom; - mainError.rows.to = rowTo; - } else { - errors = mainError; - } - - if (!errorsByFields[field]) { - errorsByFields[field] = [errors]; - } else { - errorsByFields[field].push(errors); - } - }); - - return errorsByFields; - }, - - toggle_partial: function (result) { - var $form = this.$('.oe_import'); - var $partial_warning = this.$('.o_import_partial_alert'); - var $partial_count = this.$('.o_import_partial_count'); - if (result == null) { - $partial_warning.addClass('d-none'); - $form.add(this.$buttons).removeClass('o_import_partial_mode'); - var $skip = this.$('#oe_import_row_start'); - $skip.val($skip.attr('value')); - $partial_count.text(''); - return; - } - - this.$('.o_import_batch_alert').addClass('d-none'); - $partial_warning.removeClass('d-none'); - $form.add(this.$buttons).addClass('o_import_partial_mode'); - $partial_count.text((result.skip || 0) + 1); - } -}); -core.action_registry.add('import', DataImport); - -// FSM-ize DataImport -StateMachine.create({ - target: DataImport.prototype, - events: [ - { name: 'loaded_file', - from: ['none', 'file_loaded', 'preview_error', 'preview_success', 'results', 'imported'], - to: 'file_loaded' }, - { name: 'settings_changed', - from: ['file_loaded', 'preview_error', 'preview_success', 'results'], - to: 'previewing' }, - { name: 'preview_failed', from: 'previewing', to: 'preview_error' }, - { name: 'preview_succeeded', from: 'previewing', to: 'preview_success' }, - { name: 'validate', from: 'preview_success', to: 'validating' }, - { name: 'validate', from: 'results', to: 'validating' }, - { 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' } - ], -}); - -function offset_by(by) { - return function offset_message(msg) { - if (msg.rows) { - msg.rows.from += by; - msg.rows.to += by; - } - } -} - -return { - DataImport: DataImport, -}; - -}); diff --git a/addons/base_import/static/src/legacy/js/import_menu.js b/addons/base_import/static/src/legacy/js/import_menu.js deleted file mode 100644 index ed3f6cc0616..00000000000 --- a/addons/base_import/static/src/legacy/js/import_menu.js +++ /dev/null @@ -1,63 +0,0 @@ -odoo.define('base_import.ImportMenu', function (require) { - "use strict"; - - const { DropdownItem } = require('@web/core/dropdown/dropdown_item'); - const FavoriteMenu = require('web.FavoriteMenu'); - const { useModel } = require('web.Model'); - const { LegacyComponent } = require("@web/legacy/legacy_component"); - - /** - * Import Records menu - * - * This component is used to import the records for particular model. - */ - class ImportMenu extends LegacyComponent { - setup() { - this.model = useModel('searchModel'); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - */ - importRecords() { - const action = { - type: 'ir.actions.client', - tag: 'import', - params: { - model: this.model.config.modelName, - context: this.model.config.context, - } - }; - this.trigger('do-action', {action: action}); - } - - //--------------------------------------------------------------------- - // Static - //--------------------------------------------------------------------- - - /** - * @param {Object} env - * @returns {boolean} - */ - static shouldBeDisplayed(env) { - return env.view && - ['kanban', 'list'].includes(env.view.type) && - env.action.type === 'ir.actions.act_window' && - !env.device.isMobile && - !!JSON.parse(env.view.arch.attrs.import || '1') && - !!JSON.parse(env.view.arch.attrs.create || '1'); - } - } - - ImportMenu.props = {}; - ImportMenu.template = "base_import.ImportRecords"; - ImportMenu.components = { DropdownItem }; - - FavoriteMenu.registry.add('import-menu', ImportMenu, 1); - - return ImportMenu; -}); diff --git a/addons/base_import/static/src/legacy/scss/base_import.scss b/addons/base_import/static/src/legacy/scss/base_import.scss deleted file mode 100644 index 9f8c96c89a2..00000000000 --- a/addons/base_import/static/src/legacy/scss/base_import.scss +++ /dev/null @@ -1,249 +0,0 @@ -.oe_import { - padding: 0px; - overflow: hidden; - position: absolute; // Needed for chrome - top: 0; - right: 0; - bottom: 0; - left: 0; - @include media-breakpoint-down(md) { - position: static; - } - - > p { - text-align: justify - } - h2 { - margin-top: 0.5em; - font-size: large; // override h2 font-size which is too large - } - .oe_padding { - padding: 13px 0; - } - - .oe_import_box { - flex: 0 0 250px; - padding: 10px 14px; - border-radius: $border-radius; - border-right: solid 1px #dddddd; - label { - font-weight: normal; - } - .oe_import_file { - display: inline-block; - } - } - - /* ----------- INITIAL SETUP ------------ */ - dd, - .oe_import_grid, - .oe_import_error_report, - .oe_import_noheaders, - .oe_import_report_more { - display: none; - } - - .oe_import_with_file { - flex: 1 1 100%; - background-color: white; - overflow: auto; - label { - font-weight: normal; - &.form-check-label { - left: 20px; - } - } - h4 { - margin: 10px 0px; - font-size: 16px; - font-weight: 500; - } - input:checked + label:after { - background-image: url('/web_studio/static/src/img/ui/checkbox_active.svg'); - background-size: cover; - } - .truncate { - max-width: 178px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .o_import_batch_alert { - color: #46646d; - } - } - - &.oe_import_preview .oe_import_grid { - display: table; - } - &.oe_import_error .oe_import_error_report, - &.oe_import_noheaders .oe_import_noheaders{ - display: block; - } - .oe_import_report_showmore .oe_import_report_more { - display: list-item; - list-style-type: none; - li { - list-style-type: disc; - } - } - .oe_import_report_count, .oe_import_report_count_less { - .arrow { - border: solid black; - border-width: 0 2px 2px 0; - display: inline-block; - padding: 2px; - margin-right: 2px; - pointer-events: none; - } - - .down { - transform: rotate(45deg); - -webkit-transform: rotate(45deg); - margin-bottom: 2px; - } - - .up { - transform: rotate(-135deg); - -webkit-transform: rotate(-135deg); - } - } - - /* ------------- ERRORS AND WARNINGS REPORT ------------ */ - .oe_import_error_report { - border-bottom: 1px solid lightgrey; - > ul { - padding: 0; - } - } - .oe_import_report { - list-style: none; - } - .alert { - padding: 0.50rem 1.25rem; - margin: 0.25rem 0; - - a { - @extend .alert-link; - &:hover {opacity: 0.8;} - } - - // alias -error to -danger - &.alert-error { - @extend .alert-danger; - } - &.text-error { - @extend .text-danger; - } - } - - /* ------------- THE CSV TABLE ------------ */ - - $cell-max-width: 350px; - $cell-padding: 8px; - .oe_import_grid { - tr { - border-bottom: 1px solid lightgrey; - &.oe_import_grid-header:first-child { - line-height: 24px; - font-weight: normal; - } - .oe_import_grid-cell { - cursor:default; - border-left: 0px; - border-right: 0px; - max-width: $cell-max-width; - padding: $cell-padding 12px; - vertical-align: top; - .o_multi_line_text { - word-break: break-word; - } - .o_single_line_text { - @include o-text-overflow($display: table-cell, $max-width: $cell-max-width - $cell-padding); - } - } - } - } - - /* Default Color for placeholder on import fields*/ - .select2-default{ - color: #D67D00 !important; - font-weight: 500; - } - - /* Common dropdown style */ - .oe_import_dropdown { - width: 100% !important; - - select { - border-radius: 4px; - padding-left: 8px; - } - - select, .select2-choice { - border: 1px solid #aaa; - height: 26px; - padding-right: 24px; - border-bottom-right-radius: 4px; - border-top-right-radius: 4px; - background-image: url('/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png'); - background-position: center right; - background-repeat: no-repeat; - appearance: none; - width: auto; - background-color: white; - } - - .select2-arrow { - display: none !important; - } - } - - input.oe_import_dropdown { - border-radius: 4px; - border: 1px solid darkgrey; - height: 26px; - padding-left: 8px; - } -} -/* ------------- PARTIAL MODE buttons ------------ */ -// hide import in partial mode, resume otherwise -.o_import_import_full.o_import_partial_mode, -.o_import_import_partial:not(.o_import_partial_mode) { - display: none; -} - -/* Field dropdown */ -.oe_import_selector { - font-size: $font-size-sm; - ul, li { - margin: 0; padding: 0; - } - .select2-results { - max-height: 192px; - .text-decoration-underline { - text-decoration: underline; - } - } -} - -/* 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/legacy/scss/icons.scss b/addons/base_import/static/src/legacy/scss/icons.scss deleted file mode 100644 index 12e23fd7e9f..00000000000 --- a/addons/base_import/static/src/legacy/scss/icons.scss +++ /dev/null @@ -1,100 +0,0 @@ -@mixin o-import-sprite-icon($x: 0, $y: 0) { - background-position: $x $y; -} - -/* - * Override select style - */ - -/* Field Type Dropdown */ -.oe_import_dropdown.oe_import_match_field { - .o_import_field_icon { - padding-left: 34px; - - abbr { - top: 6px; - } - - /* Show icon */ - &:before { - width: 30px; - height: 26px; - display: block; - float: left; - margin: 0px 5px 0 0; - border-right: 1px solid #a0abb9; - background-color: white; - content: ""; - position: absolute; - left: -1px; - top: -1px; - border-bottom-left-radius: 4px; - border-top-left-radius: 4px; - border: 1px solid darkgrey; - } - &:after{ - background-image: url('/base_import/static/src/legacy/img/ui/studio_icons.png'); - background-repeat: no-repeat; - content: ''; - width: 20px; - height: 20px; - position: absolute; - top: 2px; - left: 4px; - bottom: auto; - right: auto; - } - } -} - -.o_import_field_binary:after { - @include o-import-sprite-icon(0, 0); -} - -.o_import_field_boolean:after { - @include o-import-sprite-icon(0, -20px); -} - -.o_import_field_char:after { - @include o-import-sprite-icon(0, -40px); -} - -.o_import_field_date:after { - @include o-import-sprite-icon(0, -80px); -} - -.o_import_field_datetime:after { - @include o-import-sprite-icon(0, -100px); -} - -.o_import_field_float:after { - @include o-import-sprite-icon(0, -120px); -} - -.o_import_field_html:after { - @include o-import-sprite-icon(0, -140px); -} - -.o_import_field_integer:after { - @include o-import-sprite-icon(0, -180px); -} - -.o_import_field_many2many:after { - @include o-import-sprite-icon(0, -200px); -} - -.o_import_field_many2one:after { - @include o-import-sprite-icon(0, -220px); -} - -.o_import_field_one2many:after { - @include o-import-sprite-icon(0, -260px); -} - -.o_import_field_selection:after { - @include o-import-sprite-icon(0, -280px); -} - -.o_import_field_text:after { - @include o-import-sprite-icon(0, -340px); -} diff --git a/addons/base_import/static/src/legacy/xml/base_import.xml b/addons/base_import/static/src/legacy/xml/base_import.xml deleted file mode 100644 index 8e7f7c59b59..00000000000 --- a/addons/base_import/static/src/legacy/xml/base_import.xml +++ /dev/null @@ -1,347 +0,0 @@ - - - -
- - - -
- - -
- -
-
-

- Upload an Excel or CSV file to import -

-

- Excel files are recommended as formatting is automatic. -

-
Need Help?
-
- - - -
- Import FAQ -
-
- -
- - - - - - - - - - -
- When a value cannot be matched: - -
-
- - - - - - File Column - - Odoo Field - - Comments - - - - - - - - - Untitled - - - - - - - - - -
-
- - - - - - -
- This column will be concatenated in field -
-
- - -
- To import multiple values, separate them by a comma -
-
- - -
-
-
- - - -
-

Import preview failed due to: .

-

For CSV files, you may need to select the correct separator.

-

Here is the start of the file we could not import:

-
-
-
- - -
- - - - - - - - - -
-
- -
- - - - -
- - - - No matching records found for '' in field - - - - - - - - - - - - -
- - No matching records found for the following - Multiple errors occurred - in field : - -
- - - - - - - - - - - - at row () - - - at multiple rows - - - - -
- -
- - -
- - -
-
-

If the file contains - the column names, Odoo can try auto-detecting the - 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. -
-
-
- - - - - - -
- -
-

Imported file

-
- -
- -
- -
- - -
- - - - -
-

Formatting

-
-
- - - -
-
-
- -
-

Batch Import

-
- The file will be imported by batches -
-
-
- - -
-
- - -
-
-
- -

Help

- - - Go to Import FAQ - - -
-

Advanced

-
- - -
- -
- - -
-
- -
-
-
- -
- -
-
- batch 1 out of ... -
- Estimated time left: - - minutes -
-
- - Finalizing current batch before interrupting... - -
-
-
- 0% -
-
- -
-
-
- ... -
-
- diff --git a/addons/base_import/static/tests/import_action_tests.js b/addons/base_import/static/tests/import_action_tests.js new file mode 100644 index 00000000000..c6e664fd711 --- /dev/null +++ b/addons/base_import/static/tests/import_action_tests.js @@ -0,0 +1,1119 @@ +/** @odoo-module */ + +import { browser } from "@web/core/browser/browser"; +import { + click, + editInput, + editSelect, + editSelectMenu, + getFixture, + nextTick, + patchWithCleanup, +} from "@web/../tests/helpers/utils"; +import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; +import { registry } from "@web/core/registry"; +import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services"; +import { ImportDataProgress } from "../src/import_data_progress/import_data_progress"; +import { ImportAction } from "../src/import_action/import_action"; +import { ImportBlockUI } from "../src/import_block_ui"; +import { useEffect } from "@odoo/owl"; + +const serviceRegistry = registry.category("services"); + +// ----------------------------------------------------------------------------- +//#region Helpers +// ----------------------------------------------------------------------------- + +let serverData; +let target; +let totalRows; + +function registerFakeHTTPService(validate = (route, params) => {}) { + const fakeHTTPService = { + start() { + return { + post: (route, params) => { + validate(route, params); + const file = { + id: 10, + name: params.ufile[0].name, + mimetype: "text/plain", + }; + return JSON.stringify([file]); + }, + }; + }, + }; + serviceRegistry.add("http", fakeHTTPService); +} + +function getFieldsTree(serverData) { + const fields = Object.entries(serverData.models.partner.fields); + fields.forEach(([k, v]) => { + v.id = k; + v.fields = []; + }); + const mappedFields = fields.map((e) => e[1]); + return mappedFields.filter((e) => ["id", "__last_update", "name"].includes(e.id) === false); +} + +function getMatches(serverData, headers) { + // basic implementation for testing purposes which matches if the first line is the + // name of a field, or corresponds to the string value of a field from serverData + const matches = []; + for (const header of headers) { + if (serverData.models.partner.fields[header]) { + matches.push([header]); + } + const serverDataIndex = Object.values(serverData.models.partner.fields).findIndex( + (e) => e.string === header + ); + if (serverDataIndex !== -1) { + matches.push([Object.keys(serverData.models.partner.fields)[serverDataIndex]]); + } + } + return Object.assign({}, matches); +} + +async function executeImport(data, shouldWait = false) { + const res = { + ids: [], + }; + const matching = data[1].filter((f) => f !== false); + if (matching.length) { + res.ids.push(1); + } else { + res.messages = [ + { + type: "error", + not_matching_error: true, + message: "You must configure at least one field to import", + }, + ]; + } + if (data[3].skip + 1 < (data[3].has_headers ? totalRows - 1 : totalRows)) { + res.nextrow = data[3].skip + data[3].limit; + } + if (shouldWait) { + // make sure the progress bar is shown + await nextTick(); + } + return res; +} + +function parsePreview(opts) { + const fakePreviewData = [ + ["Foo", "Deco addict", "Azure Interior", "Brandon Freeman"], + ["Bar", "1", "1", "0"], + ["Display name", "Azure Interior"], + ]; + const headers = opts.has_headers && fakePreviewData.map((col) => col[0]); + totalRows = [...fakePreviewData].sort((a, b) => (a.length > b.length ? -1 : 1))[0].length; + + return Promise.resolve({ + advanced_mode: opts.advanced, + batch: false, + fields: getFieldsTree(serverData), + file_length: opts.has_headers ? totalRows - 1 : totalRows, + header_types: false, + headers: headers, + matches: opts.has_headers && getMatches(serverData, headers), + options: { + ...opts, + sheet: opts.sheet.length ? opts.sheet : "Template", + sheets: ["Template", "Template 2"], + }, + preview: opts.has_headers + ? fakePreviewData.map((col) => col.shift() && col) + : [...fakePreviewData], + }); +} + +// since executing a real import would be difficult, this method simply returns +// some error messages to help testing the UI +function executeFailingImport(field, isMultiline) { + let moreInfo = []; + if (serverData.models.partner.fields[field].type === "selection") { + moreInfo = serverData.models.partner.fields[field].selection; + } + return { + ids: false, + messages: isMultiline + ? [ + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Invalid value", + moreInfo, + record: 0, + rows: { from: 0, to: 0 }, + value: "Invalid value", + priority: "info", + }, + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Duplicate value", + moreInfo, + record: 0, + rows: { from: 1, to: 1 }, + priority: "error", + }, + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Wrong values", + moreInfo, + record: 0, + rows: { from: 2, to: 3 }, + priority: "warning", + }, + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Bad value here", + moreInfo, + record: 0, + rows: { from: 4, to: 4 }, + value: "Bad value", + priority: "warning", + }, + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Duplicate value", + moreInfo, + record: 0, + rows: { from: 5, to: 5 }, + priority: "error", + }, + ] + : [ + { + field, + field_name: serverData.models.partner.fields[field].string, + field_path: "", + message: "Incorrect value", + moreInfo, + record: 0, + rows: { from: 0, to: 0 }, + }, + ], + name: ["Some invalid content", "Wrong content", "Bad content"], + nextrow: 0, + }; +} + +async function createImportAction(customRouter = {}) { + const router = { + "/web/dataset/call_kw/partner/get_import_templates": (route, args) => Promise.resolve([]), + "/web/dataset/call_kw/base_import.import/parse_preview": (route, args) => + parsePreview(args[1]), + "/web/dataset/call_kw/base_import.import/execute_import": (route, args) => + executeImport(args), + "/web/dataset/call_kw/base_import.import/create": (route, args) => Promise.resolve(11), + "base_import.import/get_fields": (route, args) => + Promise.resolve(serverData.models.partner.fields), + }; + + for (const key in customRouter) { + router["/web/dataset/call_kw/" + key] = customRouter[key]; + } + + const webClient = await createWebClient({ + serverData, + mockRPC: function (route, { args }) { + if (route in router) { + return router[route](route.replace("/web/dataset/call_kw/", ""), args); + } + }, + }); + + await doAction(webClient, 1); +} + +// ----------------------------------------------------------------------------- +//#endregion +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +QUnit.module("Base Import Tests", (hooks) => { + hooks.beforeEach(async () => { + target = getFixture(); + serverData = { + actions: { + 1: { + name: "Import Data", + tag: "import", + target: "current", + type: "ir.actions.client", + params: { + model: "partner", + }, + }, + }, + models: { + partner: { + fields: { + display_name: { string: "Display name", type: "char" }, + foo: { string: "Foo", type: "char" }, + bar: { string: "Bar", type: "boolean", model_name: "partner" }, + selection: { + string: "Selection", + type: "selection", + selection: [ + ["item_1", "First Item"], + ["item_2", "Second item"], + ], + model_name: "partner", + }, + many2many_field: { + string: "Many2Many", + type: "many2many", + relation: "partner", + comodel_name: "comodel.test", + }, + }, + records: [], + }, + }, + }; + target = getFixture(); + }); + + QUnit.module("ImportAction"); + + QUnit.test("Import view: UI before file upload", async function (assert) { + const templateURL = "/myTemplateURL.xlsx"; + + await createImportAction({ + "partner/get_import_templates": (route, args) => { + assert.step(route); + return Promise.resolve([ + { + label: "Some Import Template", + template: templateURL, + }, + ]); + }, + "base_import.import/create": (route, args) => { + assert.step(route); + return Promise.resolve(11); + }, + }); + + assert.containsOnce(target, ".o_import_action", "import view is displayed"); + assert.strictEqual( + target.querySelector(".o_nocontent_help .btn-outline-primary").textContent, + " Some Import Template" + ); + assert.strictEqual( + target.querySelector(".o_nocontent_help .btn-outline-primary").href, + window.location.origin + templateURL, + "button has the right download url" + ); + assert.verifySteps(["partner/get_import_templates", "base_import.import/create"]); + assert.containsN( + target, + ".o_control_panel button", + 2, + "only two buttons are visible by default" + ); + }); + + QUnit.test("Import view: import a file with multiple sheets", async function (assert) { + registerFakeHTTPService((route, params) => { + assert.strictEqual(route, "/base_import/set_file"); + assert.strictEqual( + params.ufile[0].name, + "fake_file.xlsx", + "file is correctly uploaded to the server" + ); + }); + + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + await createImportAction({ + "partner/get_import_templates": (route, args) => { + assert.step(route); + return Promise.resolve([]); + }, + "base_import.import/parse_preview": (route, args) => { + assert.step(route); + return parsePreview(args[1]); + }, + "base_import.import/create": (route, args) => { + assert.step(route); + return Promise.resolve(11); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xlsx", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + assert.verifySteps([ + "partner/get_import_templates", + "base_import.import/create", + "base_import.import/parse_preview", + ]); + assert.containsOnce( + target, + ".o_import_action .o_import_data_sidepanel", + "side panel is visible" + ); + assert.containsOnce( + target, + ".o_import_action .o_import_data_content", + "content panel is visible" + ); + assert.strictEqual( + target.querySelector(".o_import_data_sidepanel .fst-italic.truncate").textContent, + "fake_file", + "filename is shown and can be truncated" + ); + assert.strictEqual( + target.querySelector(".o_import_data_sidepanel .fst-italic:not(.truncate)").textContent, + ".xlsx", + "file extension is displayed on its own" + ); + assert.strictEqual( + target.querySelector(".o_import_data_sidepanel [name=o_import_sheet]") + .selectedOptions[0].textContent, + "Template", + "first sheet is selected by default" + ); + + assert.containsN( + target, + ".o_import_data_content tbody > tr", + 3, + "recognized values are displayed in the view" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content tr:first-child td span:first-child") + .textContent, + "Foo", + "column title is shown" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content tr:first-child td span:nth-child(2)") + .textContent, + "Deco addict", + "first example is shown" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content tr:first-child td span:nth-child(2)") + .dataset.tooltipInfo, + '{"lines":["Deco addict","Azure Interior","Brandon Freeman"]}', + "tooltip contains other examples" + ); + assert.containsNone( + target, + ".o_import_data_content tbody td:nth-child(3) .alert-info", + "no comments are shown" + ); + + // Select a field already selected for another column + await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Display name"); + assert.containsN( + target, + ".o_import_data_content tbody td:nth-child(3) .alert-info", + 2, + "two comments are shown" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content tbody td:nth-child(3) .alert-info") + .textContent, + "This column will be concatenated in field Display name" + ); + + // Preview the second sheet + await editSelect(target, ".o_import_data_sidepanel [name=o_import_sheet]", "Template 2"); + assert.verifySteps( + ["base_import.import/parse_preview"], + "changing sheet has sent a new parse_preview request" + ); + assert.strictEqual( + target.querySelector(".o_import_data_sidepanel [name=o_import_sheet]") + .selectedOptions[0].textContent, + "Template 2", + "second sheet is now selected" + ); + assert.containsNone( + target, + ".o_import_data_content tbody td:nth-child(3) .alert-info", + "no comments are shown" + ); + }); + + QUnit.test("Import view: import a CSV file with one sheet", async function (assert) { + registerFakeHTTPService((route, params) => { + assert.strictEqual(route, "/base_import/set_file"); + assert.strictEqual( + params.ufile[0].name, + "fake_file.csv", + "file is correctly uploaded to the server" + ); + }); + await createImportAction(); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.csv", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + assert.containsOnce( + target, + ".o_import_data_sidepanel .o_import_formatting", + "formatting options are present in the side panel" + ); + assert.containsOnce( + target, + ".o_import_action .o_import_data_content", + "content panel is visible" + ); + }); + + QUnit.test("Import view: additional options in debug", async function (assert) { + patchWithCleanup(odoo, { debug: true }); + registerFakeHTTPService(); + + await createImportAction({ + "base_import.import/parse_preview": (route, args) => { + assert.strictEqual( + args[1].advanced, + true, + "in debug, advanced_mode is set in parse_preview" + ); + return parsePreview(args[1]); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.csv", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + + await nextTick(); + assert.containsOnce( + target, + ".o_import_data_sidepanel .o_import_debug_options", + "additional options are present in the side panel in debug mode" + ); + }); + + QUnit.test( + "Import view: execute import with option 'use first row as headers'", + async function (assert) { + registerFakeHTTPService(); + const notificationMock = (message) => { + assert.step(message); + return () => {}; + }; + registry + .category("services") + .add("notification", makeFakeNotificationService(notificationMock), { + force: true, + }); + + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + await createImportAction({ + "base_import.import/parse_preview": async (route, args) => { + assert.step(route); + return parsePreview(args[1]); + }, + "base_import.import/execute_import": (route, args) => { + assert.step(route); + return executeImport(args); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xls", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + assert.strictEqual( + target.querySelector(".o_import_data_sidepanel input[type=checkbox]").checked, + true, + "by default, the checkbox is enabled" + ); + assert.verifySteps(["base_import.import/parse_preview"]); + assert.strictEqual( + target.querySelector(".o_import_data_content tr:first-child td span:first-child") + .textContent, + "Foo", + "first row is used as column title" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content .o_select_menu").textContent, + "Foo", + "as the column header could match with a database field, it is selected by default" + ); + + await click(target.querySelector(".o_import_data_sidepanel input[type=checkbox]")); + assert.verifySteps(["base_import.import/parse_preview"]); + assert.strictEqual( + target.querySelector(".o_import_data_content tr:first-child td span:first-child") + .textContent, + "Foo, Deco addict, Azure Interior, Brandon Freeman", + "column title is shown as a list of rows elements" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content .o_select_menu").textContent, + "To import, select a field...", + "as the column couldn't match with the database, user must make a choice" + ); + + await click(target.querySelector(".o_control_panel button:first-child")); + assert.containsNone( + target, + ".o_notification_body", + "should not display a notification" + ); + assert.verifySteps(["base_import.import/execute_import"]); + assert.containsOnce( + target, + ".o_import_data_content .alert-info", + "if no fields were selected to match, the import fails with a message" + ); + assert.containsOnce( + target, + ".o_import_data_content .alert-danger", + "an error is also displayed" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-danger").textContent, + "You must configure at least one field to import" + ); + + await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Display name"); + await click(target.querySelector(".o_control_panel button:first-child")); + assert.verifySteps([ + "base_import.import/execute_import", + "1 records successfully imported", + ]); + } + ); + + QUnit.test("Import view: import data that don't match (selection)", async function (assert) { + serverData.models.partner.fields.selection.required = true; + let shouldFail = true; + + registerFakeHTTPService(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => { + if (shouldFail) { + shouldFail = false; + return executeFailingImport(args[1][0]); + } + assert.deepEqual( + args[3].fallback_values, + { + selection: { + fallback_value: "item_2", + field_model: "partner", + field_type: "selection", + }, + }, + "selected fallback value has been given to the request" + ); + return executeImport(args); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xlsx", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + // For this test, we force the display of an error message if this field is set + await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Selection"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-danger").textContent, + "The file contains blocking errors (see below)", + "a message is shown if the import was blocked" + ); + assert.strictEqual( + target.querySelector(".o_import_report p").textContent, + "Incorrect value", + "the message is displayed in the view" + ); + assert.containsOnce( + target, + ".o_import_field_selection", + "an action can be set when the column cannot match a field" + ); + assert.strictEqual( + target.querySelector(".o_import_field_selection select").textContent, + "Prevent importSet to: First ItemSet to: Second item", + "'skip' option is not available, since the field is required" + ); + assert.strictEqual( + target.querySelector(".o_import_field_selection select").selectedOptions[0].textContent, + "Prevent import", + "prevent option is selected by default" + ); + editSelect(target, ".o_import_field_selection select", "item_2"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid.", + "import is now successful" + ); + assert.containsOnce( + target, + ".o_import_field_selection", + "options are still present to change the action to do when the column don't match" + ); + }); + + QUnit.test("Import view: import data that don't match (boolean)", async function (assert) { + let shouldFail = true; + + registerFakeHTTPService(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => { + if (shouldFail) { + shouldFail = false; + // return executeFailingImport(args, args[1][0]); + return executeFailingImport(args[1][0]); + } + assert.deepEqual( + args[3].fallback_values, + { + bar: { + fallback_value: "false", + field_model: "partner", + field_type: "boolean", + }, + }, + "selected fallback value has been given to the request" + ); + return executeImport(args); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xlsx", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + // For this test, we force the display of an error message if this field is set + await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Bar"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-danger").textContent, + "The file contains blocking errors (see below)", + "a message is shown if the import was blocked" + ); + assert.strictEqual( + target.querySelector(".o_import_field_boolean select").textContent, + "Prevent importSet to: FalseSet to: TrueSkip record", + "options are 'prevent', choose a default boolean value or 'skip'" + ); + editSelect(target, ".o_import_field_boolean select", "false"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid.", + "import is now successful" + ); + }); + + QUnit.test("Import view: import data that don't match (many2many)", async function (assert) { + let executeCount = 0; + + registerFakeHTTPService(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => { + executeCount++; + if (executeCount === 1) { + return executeFailingImport(args[1][0]); + } + if (executeCount === 2) { + assert.deepEqual( + args[3].name_create_enabled_fields, + { + many2many_field: true, + }, + "selected fallback value has been given to the request" + ); + } else { + assert.deepEqual( + args[3].name_create_enabled_fields, + {}, + "selected fallback value has been given to the request" + ); + assert.deepEqual( + args[3].import_skip_records, + ["many2many_field"], + "selected fallback value has been given to the request" + ); + } + return executeImport(args); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xlsx", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + // For this test, we force the display of an error message if this field is set + await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Many2Many"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-danger").textContent, + "The file contains blocking errors (see below)", + "a message is shown if the import was blocked" + ); + assert.strictEqual( + target.querySelector(".o_import_field_many2many select").textContent, + "Prevent importSet value as emptySkip recordCreate new values", + "options are 'prevent', choose a default boolean value or 'skip'" + ); + editSelect(target, ".o_import_field_many2many select", "name_create_enabled_fields"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid.", + "import is now successful" + ); + editSelect(target, ".o_import_field_many2many select", "import_skip_records"); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid.", + "import is still successful" + ); + }); + + QUnit.test("Import view: import messages are grouped and sorted", async function (assert) { + const fakeHTTPService = { + start() { + return { + post: (route, params) => { + const file = { + id: 10, + name: params.ufile[0].name, + mimetype: "text/plain", + }; + return JSON.stringify([file]); + }, + }; + }, + }; + serviceRegistry.add("http", fakeHTTPService); + + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + + const webClient = await createWebClient({ + serverData, + mockRPC: function (route, { args }) { + if (route === "/web/dataset/call_kw/partner/get_import_templates") { + return Promise.resolve([]); + } + if (route === "/web/dataset/call_kw/base_import.import/parse_preview") { + return parsePreview(args[1]); + } + if (route === "/web/dataset/call_kw/base_import.import/get_fields") { + assert.step(route); + return Promise.resolve(serverData.models.partner.fields); + } + if (route === "/web/dataset/call_kw/base_import.import/execute_import") { + return executeFailingImport(args[1][0], true); + } + if (route === "/web/dataset/call_kw/base_import.import/create") { + return Promise.resolve(11); + } + }, + }); + + await doAction(webClient, 1); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xlsx", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + await click(target.querySelector(".o_control_panel button:nth-child(1)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-danger").textContent, + "The file contains blocking errors (see below)", + "a message is shown if the import was blocked" + ); + // Check that errors have been sorted and grouped + assert.strictEqual( + target.querySelector(".o_import_report p").textContent.trim(), + "Multiple errors occurred in field foo:" + ); + assert.strictEqual( + target.querySelector(".o_import_report li:first-child").textContent.trim(), + "Duplicate value at multiple rows" + ); + assert.strictEqual( + target.querySelector(".o_import_report li:nth-child(2)").textContent.trim(), + "Wrong values at multiple rows" + ); + assert.strictEqual( + target.querySelector(".o_import_report li:nth-child(3)").textContent.trim(), + "Bad value at row 5" + ); + assert.containsN(target, ".o_import_report li", 3, "only 3 errors are visible by default"); + assert.strictEqual( + target.querySelector(".o_import_report_count").textContent.trim(), + "1 more" + ); + + await click(target, ".o_import_report_count"); + assert.strictEqual( + target.querySelector(".o_import_report_count + li").textContent.trim(), + "Invalid value at row 1 (Some invalid content)" + ); + }); + + QUnit.test("Import view: test import in batches", async function (assert) { + let executeImportCount = 0; + registerFakeHTTPService(); + + patchWithCleanup(ImportAction.prototype, { + get isBatched() { + // make sure the UI displays the batched import options + return true; + }, + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => { + assert.deepEqual( + args[1], + ["foo", "bar", "display_name"], + "param contains the list of matching fields" + ); + assert.deepEqual( + args[2], + ["foo", "bar", "display name"], + "param contains the list of associated columns" + ); + assert.strictEqual( + args[3].limit, + 1, + "limit option is equal to the value set in the view" + ); + assert.strictEqual( + args[3].skip, + executeImportCount * args[3].limit, + "skip option increments at each import" + ); + executeImportCount++; + return executeImport(args); + }, + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xls", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + assert.strictEqual( + target.querySelector("input#o_import_batch_limit").value, + "2000", + "by default, the batch limit is set to 2000 rows" + ); + assert.strictEqual( + target.querySelector("input#o_import_row_start").value, + "1", + "by default, the import starts at line 1" + ); + + await editInput(target, "input#o_import_batch_limit", 1); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid.", + "a message is shown if the import test was successfull" + ); + assert.strictEqual(executeImportCount, 3, "execute_import was called 3 times"); + }); + + QUnit.test("Import view: execute and pause import in batches", async function (assert) { + registerFakeHTTPService(); + + patchWithCleanup(ImportAction.prototype, { + get isBatched() { + // make sure the UI displays the batched import options + return true; + }, + }); + + patchWithCleanup(ImportBlockUI.prototype, { + setup() { + this._super(); + if (this.props.message === "Importing") { + assert.step("Block UI received the right text"); + } + }, + }); + + patchWithCleanup(ImportDataProgress.prototype, { + setup() { + this._super(); + useEffect( + () => { + if (this.props.importProgress.step === 1) { + // Trigger a pause at this step to resume later from the view + assert.step("pause triggered during step 2"); + this.interrupt(); + } + }, + () => [this.props.importProgress.step] + ); + + assert.strictEqual( + this.props.totalSteps, + 3, + "progress bar receives the number of steps" + ); + assert.deepEqual( + this.props.importProgress, + { + value: 0, + step: 1, + }, + "progress status has been given to the progress bar" + ); + }, + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => executeImport(args, true), + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xls", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + await editInput(target, "input#o_import_batch_limit", 1); + await click(target.querySelector(".o_control_panel button:first-child")); + // Since a nextTick is added to each batch, we must wait twice before the end of the second batch + await nextTick(); + await nextTick(); + assert.verifySteps(["Block UI received the right text", "pause triggered during step 2"]); + assert.containsOnce( + target, + ".o_import_data_content div .alert-warning", + "a message is shown to indicate the user to resume from the third row" + ); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-warning b:first-child").textContent, + "Click 'Resume' to proceed with the import, resuming at line 2.", + "a message is shown to indicate the user to resume from the third row" + ); + assert.strictEqual( + target.querySelector(".o_control_panel button:first-child").textContent, + "Resume", + "button contains the right text" + ); + assert.strictEqual( + target.querySelector("input#o_import_row_start").value, + "2", + "the import will resume at line 2" + ); + assert.strictEqual( + target.querySelector(".o_notification_body").textContent, + "1 records successfully imported", + "display a notification with the quantity of imported values" + ); + }); + + QUnit.test("Import view: test and pause import in batches", async function (assert) { + registerFakeHTTPService(); + + patchWithCleanup(ImportAction.prototype, { + get isBatched() { + // make sure the UI displays the batched import options + return true; + }, + }); + + patchWithCleanup(ImportBlockUI.prototype, { + setup() { + this._super(); + if (this.props.message === "Testing") { + assert.step("Block UI received the right text"); + } + }, + }); + + patchWithCleanup(ImportDataProgress.prototype, { + setup() { + this._super(); + useEffect( + () => { + if (this.props.importProgress.step === 1) { + // Trigger a pause at this step to resume later from the view + assert.step("pause triggered during step 2"); + this.interrupt(); + } + }, + () => [this.props.importProgress.step] + ); + + assert.strictEqual( + this.props.totalSteps, + 3, + "progress bar receives the number of steps" + ); + assert.deepEqual( + this.props.importProgress, + { + value: 0, + step: 1, + }, + "progress status has been given to the progress bar" + ); + }, + }); + + await createImportAction({ + "base_import.import/execute_import": (route, args) => executeImport(args, true), + }); + + // Set and trigger the change of a file for the input + const file = new File(["fake_file"], "fake_file.xls", { type: "text/plain" }); + await editInput(target, "input[type='file']", file); + await editInput(target, "input#o_import_batch_limit", 1); + await click(target.querySelector(".o_control_panel button:nth-child(2)")); + // Since a nextTick is added to each batch, we must wait twice before the end of the second batch + await nextTick(); + await nextTick(); + assert.verifySteps(["Block UI received the right text", "pause triggered during step 2"]); + assert.strictEqual( + target.querySelector(".o_import_data_content .alert-info").textContent, + "Everything seems valid." + ); + assert.strictEqual( + target.querySelector(".o_control_panel button:first-child").textContent, + "Import", + "after testing, 'Resume' text is not shown" + ); + assert.strictEqual( + target.querySelector("input#o_import_row_start").value, + "1", + "the import will resume at line 1" + ); + }); +}); diff --git a/addons/web/static/src/core/file_input/file_input.js b/addons/web/static/src/core/file_input/file_input.js index e66f4644e1a..b089a2b4a0d 100644 --- a/addons/web/static/src/core/file_input/file_input.js +++ b/addons/web/static/src/core/file_input/file_input.js @@ -71,7 +71,8 @@ export class FileInput extends Component { */ async onFileInputChange() { const parsedFileData = await this.uploadFiles(this.httpParams); - this.props.onUpload(parsedFileData); + // When calling onUpload, also pass the files to allow to get data like their names + this.props.onUpload(parsedFileData, this.fileInputRef.el ? this.fileInputRef.el.files : []); } /** diff --git a/addons/web/static/src/views/fields/field_tooltip.js b/addons/web/static/src/views/fields/field_tooltip.js index 69a4d18cc7b..5648cf542d0 100644 --- a/addons/web/static/src/views/fields/field_tooltip.js +++ b/addons/web/static/src/views/fields/field_tooltip.js @@ -11,7 +11,6 @@ export function getTooltipInfo(params) { resModel: params.resModel, debug: Boolean(odoo.debug), field: { - label: params.field.string, name: params.field.name, help: params.fieldInfo.help !== null ? params.fieldInfo.help : params.field.help, type: params.field.type, diff --git a/addons/web/static/src/views/fields/field_tooltip.xml b/addons/web/static/src/views/fields/field_tooltip.xml index 6b7c01c541f..600727520ec 100644 --- a/addons/web/static/src/views/fields/field_tooltip.xml +++ b/addons/web/static/src/views/fields/field_tooltip.xml @@ -8,6 +8,10 @@