[REF] base_import, web: convert import action to Owl

This commit reimplements the import action view to fully use Owl instead
of its legacy implementation. Different components have been created to
separate the UI in smaller blocks, and a model file contains the main
aspects of the import logic.

The SelectMenu component is now used instead of the jquery select2, and some
other web components and hooks have been used or modified to bring this
action to a better state. A custom ImportBlockUI component is used, to
support custom messages and the use of components. It was required to
reimplement the loading progress and indicator used by the view.

The set_file route in the base_import controller has been adapted to remove
the usage of jsonp type of code for this situation since it is no longer
needed nowadays.

On top of the rewrite, js tests have been added to make sure the feature
behaves correctly, and to assert the intended specs.

Finally, legacy files have been removed, since they are no longer required.

Enterprise PR: https://github.com/odoo/enterprise/pull/34249

task-3092474

Part-of: odoo/odoo#106265
Co-authored-by: Bastien Fafchamps <bafa@odoo.com>
This commit is contained in:
luvi
2022-12-23 17:03:37 +01:00
co-authored by Bastien Fafchamps
parent 26a1eee340
commit 0566b5f6ba
32 changed files with 2830 additions and 2052 deletions
+5 -4
View File
@@ -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})
@@ -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);
@@ -0,0 +1,3 @@
.o_import_action {
grid-template-rows: auto 1fr;
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportAction" owl="1">
<div class="h-100 d-flex flex-column">
<Layout className="'o_import_action d-flex h-100 overflow-auto bg-white'" display="display">
<t t-set-slot="layout-buttons">
<t t-if="isPreviewing">
<button t-if="!state.isPaused" type="button" class="btn btn-primary m-1" t-on-click="() => this.handleImport(false)">Import</button>
<button t-else="" type="button" class="btn btn-primary m-1" t-on-click="() => this.handleImport(false)">Resume</button>
<button type="button" class="btn btn-secondary m-1" t-on-click="() => this.handleImport(true)">Test</button>
</t>
<FileInput
acceptedFileExtensions="'.csv, .xls, .xlsx, .xlsm, .ods'"
onUpload.bind="(data, files) => this.handleFilesUpload(files)"
resId="model.id"
resModel="this.resModel"
route="'/base_import/set_file'"
>
<button t-if="isPreviewing" type="button" class="btn btn-secondary m-1">Load File</button>
<button t-else="" type="button" class="btn btn-primary m-1 o_import_file">Upload File</button>
</FileInput>
<button t-on-click="exit" type="button" class="btn btn-secondary m-1">Cancel</button>
</t>
<t t-if="isPreviewing">
<ImportDataSidepanel
filename="state.filename"
options="importOptions"
formattingOptions="formattingOptions"
importTemplates="importTemplates"
isBatched="isBatched"
onOptionChanged.bind="onOptionChanged"
onReload.bind="reload"
/>
<ImportDataContent
columns="model.columns"
onFieldChanged.bind="onFieldChanged"
onOptionChanged.bind="onOptionChanged"
options="importOptions"
isFieldSet.bind="isFieldSet"
previewError="state.previewError"
importMessages="model.importMessages"
/>
</t>
<div t-else="" class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
Upload an Excel or CSV file to import
</p>
<p>
Excel files are recommended as formatting is automatic.
</p>
<div class="mt16 mb4">Need Help?</div>
<div t-foreach="importTemplates" t-as="template" t-key="template">
<a class="btn btn-outline-primary mb32 mt8" t-att-href="template.template" aria-label="Download" data-tooltip="Download">
<i class="fa fa-download"/> <span><t t-esc="template.label"/></span>
</a>
</div>
<a href="https://www.odoo.com/documentation/16.0/applications/general/export_import_data.html" target="new">Import FAQ</a>
</div>
</div>
</Layout>
</div>
</t>
</templates>
@@ -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";
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="base_import.BlockUI" owl="1">
<div class="o_blockUI fixed-top d-flex justify-content-center align-items-center flex-column vh-100 bg-black-50">
<div class="o_spinner mb-4">
<img src="/web/static/img/spin.svg" alt="Loading..."/>
</div>
<div t-if="props.message or props.blockComponent">
<div class="o_message text-center px-4" t-esc="props.message" />
<t t-if="props.blockComponent" t-component="props.blockComponent.class" t-props="props.blockComponent.props"/>
</div>
</div>
</t>
</templates>
@@ -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);
}
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportDataColumnError" owl="1">
<div class="o_import_report alert alert-danger mb-2">
<t t-if="props.errors.length > 1">
<p>
<t t-if="props.errors[0].value">No matching records found for the following name </t>
<t t-else="">Multiple errors occurred </t>
in field <b t-esc="props.fieldInfo.name"/>:
</p>
<ul>
<t t-foreach="props.errors" t-as="error" t-key="error_index">
<a t-if="error_index === 3" href="#" class="o_import_report_count" t-on-click="() => this.state.isExpanded = !this.state.isExpanded">
<i t-attf-class="fa fa-chevron-{{state.isExpanded ? 'up' : 'down'}}"></i> <t t-esc="props.errors.length - error_index"/> more
</a>
<li t-if="isErrorVisible(error_index)" t-attf-class="o_import_report_more p-0 text-{{error.priority}} {{error_index > 2 ? 'list-unstyled' : ''}}">
<t t-if="error.rows.from === error.rows.to">
<b t-esc="error.value"/> at row <t t-esc="error.rows.from + 1"/>
<t t-if="props.resultNames and props.resultNames.length > error.rows.from and props.resultNames[error.rows.from] !== ''">
(<t t-esc="props.resultNames[error.rows.from]"/>)
</t>
</t>
<t t-else=""><b t-esc="error.value || error.message"/> at multiple rows</t>
</li>
</t>
</ul>
</t>
<p t-else="" t-attf-class="mb-0 p-2 alert-{{props.errors[0].type}}" t-esc="props.errors[0].message" />
<t t-if="moreInfo">
<t t-if="typeof moreInfo === 'string'" t-esc="moreInfo"/>
<div t-else="" class="o_import_moreinfo" t-on-click="onMoreInfoClicked">
<a href="#" class="o_import_see_all">
<i class="fa fa-arrow-right"></i> See possible values
</a>
</div>
<ul t-if="state.moreInfoContent" class="o_import_report_more">
<li t-foreach="state.moreInfoContent" t-as="msg" t-key="msg_index"><t t-esc="msg"/></li>
</ul>
</t>
</div>
</t>
</templates>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -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"}`;
}
}
@@ -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);
}
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportDataContent" owl="1">
<div class="o_import_data_content flex-grow-1 overflow-auto border-start">
<div t-if="props.previewError" class="border-bottom p-2 bg-light">
<div class="alert alert-danger mb-0">
<p>
Import preview failed due to: "
<t t-esc="props.previewError" />
".
</p>
<p>For CSV files, you may need to select the correct separator.</p>
<p t-if="props.columns.length > 0">Here is the start of the file we could not import:</p>
</div>
</div>
<div t-if="!props.options.has_headers" class="border-bottom p-0 bg-light">
<p class="alert alert-info mb-0">
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.
</p>
</div>
<div t-if="props.importMessages.length > 0" class="border-bottom p-2 bg-light">
<t t-foreach="props.importMessages" t-as="message" t-key="`${message.lines[0]}-${message_index}`">
<p t-att-class="getErrorMessageClass(props.importMessages, message.type, message_index)">
<t t-foreach="message.lines" t-as="line" t-key="`${line}-${line_index}`">
<b t-esc="line" /><br/>
</t>
</p>
</t>
</div>
<table t-if="!props.previewError" class="table table-borderless w-100">
<thead>
<tr class="border-bottom">
<th scope="col" style="width: 20%;">File Column</th>
<th scope="col" style="width: 20%;">Odoo Field</th>
<th scope="col">Comments</th>
</tr>
</thead>
<tbody>
<t t-foreach="props.columns" t-as="column" t-key="`${column_index}-${column.id}`">
<tr class="border-bottom align-middle">
<td>
<div class="o_import_file_column_cell d-flex flex-column user-select-none">
<span class="fw-bold text-truncate">
<span class="text-truncate" t-if="column.name" t-esc="column.name" />
<span t-else="">Untitled</span>
</span>
<span
t-if="props.options.has_headers"
class="o_import_preview fst-italic small pe-2"
data-tooltip-template="ImportDataContent.tooltip"
t-att-data-tooltip-info="getTooltip(column)"
data-tooltip-position="right"
>
<t t-esc="column.preview" />
</span>
</div>
</td>
<td>
<SelectMenu
value="column.fieldInfo"
groups="getGroups(column)"
onSelect="fieldInfo => this.props.onFieldChanged(column, fieldInfo)"
searchPlaceholder="searchPlaceholder"
togglerClass="column.fieldInfo ? 'ps-1' : ''"
>
<t t-if="column.fieldInfo">
<i
class="o_import_field_icon border-end"
t-att-class="'position-absolute d-inline-block o_import_field_icon_' + column.fieldInfo.type"
data-tooltip-template="web.FieldTooltip"
t-att-data-tooltip-info="getTooltipDetails(column.fieldInfo)"
></i>
<span class="ps-5">
<t t-esc="column.fieldInfo.string" />
</span>
</t>
<span t-else="" class="text-warning">To import, select a field...</span>
<t t-set-slot="choice" t-slot-scope="choice">
<div t-att-class="`${choice.data.value and choice.data.value.required ? 'fw-bolder text-decoration-underline' : ''}`">
<t t-esc="choice.data.label" />
</div>
</t>
</SelectMenu>
</td>
<td>
<t t-foreach="column.comments" t-as="comment" t-key="comment_index">
<p class="m-0 p-2 alert" t-att-class="getCommentClass(column, comment, comment_index)"><t t-out="comment.content" /> <b t-if="comment.fieldName" t-out="comment.fieldName"/></p>
</t>
<ImportDataColumnError t-if="column.errors.length" errors="column.errors" resultNames="column.resultNames" fieldInfo="column.fieldInfo" />
<ImportDataOptions t-if="column.errors.length or column.importOptions" importOptions="column.importOptions" fieldInfo="column.fieldInfo" onOptionChanged="props.onOptionChanged" />
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-name="ImportDataContent.tooltip" owl="1">
<h5 class="border-bottom p-2">Preview</h5>
<div class="p-2 pt-0 pe-3">
<p
t-foreach="lines"
t-as="line"
t-key="line_index"
t-out="line"
class="mb-0 fs-6"
></p>
</div>
</t>
</templates>
@@ -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);
}
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportDataOptions" owl="1">
<div t-if="isVisible and state.options.length > 1" t-attf-class="o_import_dropdown o_import_field_#{props.fieldInfo.type} alert alert-danger mb-2">
<b>When a value cannot be matched:</b>
<select class="o_import_create_option form-select w-auto bg-light" t-att-type="props.fieldInfo.type" t-on-change="onSelectionChanged">
<option t-foreach="state.options" t-as="option" t-key="option" t-att-value="option[0]">
<t t-esc="option[1]"/>
</option>
</select>
</div>
</t>
</templates>
@@ -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);
}
}
@@ -0,0 +1,6 @@
.o_import_data_progress {
.progress {
width: 300px;
height: 25px;
}
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportDataProgress" owl="1">
<div t-if="props.importProgress.step > 1" class="o_import_data_progress d-flex align-items-center flex-column">
<div class="o_import_progress_dialog_batch text-center">
Batch <span class="o_import_progress_dialog_batch_count"><t t-esc="props.importProgress.step"/></span> out of <span t-esc="props.totalSteps"/>...
<div t-if="props.importProgress.value and state.timeLeft and !state.isInterrupted" class="o_import_progress_dialog_time_left">
<span>Estimated time left:</span>
<t t-if="state.timeLeft >= 1">
<span class="o_import_progress_dialog_time_left_text mx-1" t-esc="minutesLeft"/><span>minutes</span>
</t>
<t t-else="">
<span class="o_import_progress_dialog_time_left_text mx-1" t-esc="secondsLeft"/><span>seconds</span>
</t>
</div>
</div>
<span t-if="state.isInterrupted" class="o_import_progress_dialog_stop">
Finalizing current batch before interrupting...
</span>
<div t-else="" class="d-flex align-items-center mt-2">
<div class="progress flex-grow-1 rounded-3">
<div class="progress-bar progress-bar-striped" role="progressbar"
aria-valuenow="props.importProgress" aria-valuemin="0" aria-valuemax="100"
t-att-style="'width: ' + props.importProgress.value + '%'">
<span class="fs-4"><t t-esc="props.importProgress.value" />%</span>
</div>
</div>
<a class="o_progress_stop_import ms-2" role="button" t-on-click="interrupt"><i class="fa fa-close fs-2 text-danger" aria-label="Stop Import" title="Stop Import"/></a>
</div>
</div>
</t>
</templates>
@@ -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);
}
}
@@ -0,0 +1,3 @@
.o_import_data_sidepanel {
width: 280px;
}
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="ImportDataSidepanel" owl="1">
<div class="o_import_data_sidepanel p-3 overflow-auto">
<div class="pb-4">
<h4>Imported file</h4>
<div class="mb-2 d-flex align-items-center">
<i class="fa fa-file white me-2"></i>
<div class="fst-italic truncate"><t t-esc="fileName"/></div>
<span class="fst-italic"><t t-esc="fileExtension"/></span>
</div>
<div t-if="props.options.sheets.length > 0" class="flex-row my-2">
<label for="o_import_sheet">Sheet:</label>
<select
class="o_select o_import_sheet w-100 mb-3 form-select"
name="o_import_sheet"
t-on-change="ev => this.props.onOptionChanged('sheet', ev.target.value)"
t-att-value="props.options.sheet"
>
<option t-foreach="props.options.sheets" t-as="sheet" t-key="sheet" t-att-value="sheet" t-att-selected="sheet === props.options.sheet">
<t t-esc="sheet" />
</option>
</select>
</div>
<CheckBox value="props.options.has_headers" onChange.bind="isChecked => this.props.onOptionChanged('has_headers', isChecked)">
Use first row as header
</CheckBox>
</div>
<div t-if="fileExtension === '.csv'" class="o_import_formatting pt-3 pb-4 border-top">
<h4>Formatting</h4>
<div t-foreach="Object.entries(props.formattingOptions)" t-as="option" t-key="`${option[0]}-${option_index}`" class="o_import_options">
<label t-att-for="`${option[0]}-${option_index}`">
<t t-esc="option[1].label" />
</label>
<input
t-if="option[1].type === 'input'"
t-att-id="`${option[0]}-${option_index}`"
t-attf-class="o_import_#{option[0]} o_import_dropdown w-100 mb-3"
t-att-value="option[1].value"
t-on-change="(ev) => this.setOptionValue(option[0], ev.target.value)"
/>
<select
t-elif="option[1].type === 'select'"
t-att-class="`o_select form-select w-100 mb-3 o_import_${option[0]} o_import_dropdown`"
t-att-name="`${option[0]}-${option_index}`"
t-on-change="(ev) => this.setOptionValue(option[0], ev.target.value)"
>
<option t-foreach="option[1].options" t-as="opt" t-key="opt_index" t-att-value="opt.value or opt" t-att-selected="opt.value ? opt.value === option[1].value : opt === option[1].value">
<t t-esc="opt.label ? opt.label : opt" />
</option>
</select>
</div>
<button class="btn btn-primary w-100" t-on-click="() => this.props.onReload()">Reimport</button>
</div>
<div t-if="props.isBatched" class="pt-3 pb-4 border-top" >
<h4>Batch Import</h4>
<div class="o_import_batch_alert fw-bold pb-2">
The file will be imported by batches
</div>
<div class="d-flex">
<div class="o_import_batch_limit w-50 pe-1">
<label class="mb-1" for="o_import_batch_limit">Batch limit</label>
<input class="w-100" id="o_import_batch_limit" t-att-value="getOptionValue('limit')" t-on-change="(ev) => this.setOptionValue('limit', ev.target.value)" />
</div>
<div class="w-50 ps-1" data-tooltip="Warning: ignores the labels line, empty lines and lines composed only of empty cells">
<label class="mb-1" for="o_import_row_start">Start at line</label>
<input class="w-100" id="o_import_row_start" t-att-value="getOptionValue('skip')" t-on-change="onLimitChange" />
</div>
</div>
</div>
<div class="pt-3 pb-4 border-top">
<h4>Help</h4>
<t t-foreach="props.importTemplates" t-as="template" t-key="template">
<a class="d-block my-2" t-att-href="template.template" aria-label="Download" data-tooltip="Download">
<i class="fa fa-download"/> <span><t t-esc="template.label"/></span>
</a>
</t>
<a href="https://www.odoo.com/documentation/16.0/applications/general/export_import_data.html" target="new">
<i class="fa fa-external-link"></i>
Go to Import FAQ
</a>
</div>
<div t-if="env.debug" class="o_import_debug_options pt-3 pb-4 border-top">
<h4>Advanced</h4>
<div data-tooltip="If the model uses openchatter, history tracking will set up subscriptions and send notifications during the import, but lead to a slower import.">
<CheckBox value="!props.options.tracking_disable" onChange.bind="(isChecked) => this.props.onOptionChanged('tracking_disable', !isChecked)">
Track history during import
</CheckBox>
</div>
<CheckBox value="props.options.advanced" onChange.bind="(isChecked) => this.props.onOptionChanged('advanced', isChecked)">
Allow matching with subfields
</CheckBox>
</div>
</div>
</t>
</templates>
@@ -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 }));
}
@@ -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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 B

File diff suppressed because it is too large Load Diff
@@ -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;
});
@@ -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);
}
}
}
@@ -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);
}
@@ -1,347 +0,0 @@
<templates>
<t t-name="ImportView">
<t t-set="_id" t-value="_.uniqueId('export')"/>
<form action="" method="post" enctype="multipart/form-data" class="oe_import">
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
<input type="hidden" name="import_id"/>
<div class="d-flex h-100">
<t t-call="ImportView.side_panel"/>
<t t-call="ImportView.data_matching"/>
</div>
<div class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
Upload an Excel or CSV file to import
</p>
<p>
Excel files are recommended as formatting is automatic.
</p>
<div class="mt16 mb4">Need Help?</div>
<div t-foreach="widget.importTemplates" t-as="template">
<a class="btn btn-outline-primary mb32 mt8" t-att-href="template.template" aria-label="Download" title="Download">
<i class="fa fa-download"/> <span><t t-esc="template.label"/></span>
</a>
</div>
<a href="https://www.odoo.com/documentation/master/applications/general/export_import_data.html" target="new">Import FAQ</a>
</div>
</div>
</form>
</t>
<t t-name="ImportView.buttons">
<button type="button" class="btn btn-primary o_import_import o_import_import_full d-none">Import</button>
<button type="button" class="btn btn-primary o_import_import o_import_import_partial d-none">Resume</button>
<button type="button" class="btn btn-secondary o_import_validate d-none">Test</button>
<button type="button" class="btn btn-primary oe_import_file">Upload File</button>
<button type="button" class="btn btn-secondary o_import_cancel">Cancel</button>
</t>
<t t-name="ImportView.create_record_option">
<div t-attf-class="oe_import_dropdown o_import_field_#{data.type} position-relative alert alert-error">
<b>When a value cannot be matched:</b>
<select class="o_import_create_option" t-att-type="data.type" t-att-field="data.id"
t-att-model="data.comodel_name || data.model_name">
<option value="prevent">Prevent import</option>
<t t-if="data.type === 'boolean'">
<option value="false">Set to: False</option>
<option value="true" >Set to: True</option>
</t>
<option t-if="set_empty" value="set_empty">Set value as empty</option>
<option t-if="show_skip_record" value="skip_record">Skip record</option>
<option t-if="is_relational" value="create">Create new values</option>
<t t-if="data.type === 'selection'">
<option t-foreach="values" t-as="value" t-att-value="value">Set to: <t t-esc="value"/></option>
</t>
</select>
</div>
</t>
<t t-name="ImportView.preview">
<thead>
<tr class="oe_import_grid-header">
<td class="oe_import_grid-cell border-0 fw-bold" style="width: 20%;">
<span class="o_single_line_text" title="File Column">File Column</span></td>
<td class="oe_import_grid-cell border-0 fw-bold" style="width: 15%;" >
<span class="o_single_line_text" title="File Column">Odoo Field</span></td>
<td class="oe_import_grid-cell border-0 fw-bold">
<span class="o_single_line_text" title="File Column">Comments</span></td>
</tr>
</thead>
<tbody>
<t t-set="columns" t-value="headers || preview"/>
<tr t-if="columns" t-foreach="columns" t-as="column" class="oe_import_grid-row">
<td class="oe_import_grid-cell oe_import_name_cell">
<span class="o_import_header_name o_single_line_text fw-bold" t-att-title="column">
<span t-if="column" t-esc="column"/>
<span t-else="">Untitled</span>
</span>
<span t-if="options['has_headers']" class="fst-italic small o_import_preview">
<t t-esc="preview[column_index][0]"/>
</span>
</td>
<td class="oe_import_grid-cell oe_import_match_cell">
<input class="oe_import_match_field oe_import_dropdown"/>
</td>
<td class="oe_import_grid-cell oe_import_comment_cell">
<div class="oe_import_comments_div"/>
<div class="oe_import_options_div d-none"/>
</td>
</tr>
</tbody>
</t>
<t t-name="ImportView.comment_same_mapped_field">
<div class="oe_import_same_mapped_field alert alert-info">
This column will be concatenated in field <b t-esc="field"/>
</div>
</t>
<t t-name="ImportView.comment_m2m_field">
<div class="oe_import_m2m_field alert alert-info">
To import multiple values, separate them by a comma
</div>
</t>
<t t-name="ImportView.preview_popover">
<div class="o_import_preview_popover" style="position: relative">
<div t-foreach="preview" t-as="preview_value" t-esc="preview_value"/>
</div>
</t>
<t t-name="ImportView.preview.error">
<div class="oe_import_report alert alert-danger">
<p>Import preview failed due to: <t t-esc="error"/>.</p>
<p>For CSV files, you may need to select the correct separator.</p>
<p t-if="preview">Here is the start of the file we could not import:</p>
</div>
<pre t-if="preview"><t t-esc="preview"/></pre>
</t>
<t t-name="ImportView.error">
<div t-foreach="errors" t-as="error" t-attf-class="oe_import_report alert alert-#{error.type}">
<span class="oe_import_report_message">
<b><t t-esc="error.message"/></b>
<t t-if="error.rows" t-call="ImportView.error.at">
<t t-set="rows" t-value="error.rows"/>
</t>
</span>
<t t-if="error.moreinfo" t-call="ImportView.error.more_info">
<t t-set="messages" t-value="error.moreinfo"/>
</t>
</div>
</t>
<div t-name="ImportView.fieldError" t-attf-class="oe_import_report alert alert-#{errors[0].type}">
<t t-if="errors.length &gt; 1" t-call="ImportView.fieldError.multi"></t>
<t t-else="" t-call="ImportView.fieldError.single">
<t t-set="error" t-value="errors[0]"/>
</t>
</div>
<t t-name="ImportView.fieldError.single">
<span class="oe_import_report_message">
<t t-if="error.value &amp;&amp; !error.error_message">
No matching records found for <t t-esc="error.field_type"/> <b>'<t t-esc="error.value"/>'</b> in field <b t-esc="error.field_name"/>
</t>
<b t-else="" t-esc="error.message"/>
</span>
<t t-if="error.rows" t-call="ImportView.error.at">
<t t-set="rows" t-value="error.rows"/>
</t>
<t t-if="error.moreinfo" t-call="ImportView.error.more_info">
<t t-set="messages" t-value="error.moreinfo"/>
</t>
</t>
<t t-name="ImportView.fieldError.multi">
<div class="oe_import_report_message">
<t t-if="errors[0].value">
No matching records found for the following <t t-esc="errors[0].field_type"/> </t>
<t t-else="">Multiple errors occurred </t>
in field <b t-esc="errors[0].field_name"/>:
<ul>
<t t-foreach="errors.length" t-as="index">
<li t-att-class="index &gt; 2 ? 'oe_import_report_more':''">
<b t-if="errors[index].error_message" t-esc="errors[index].message"/>
<b t-else="" t-esc="errors[index].value || errors[index].message"/>
<t t-if="errors[index].rows" t-call="ImportView.error.at">
<t t-set="rows" t-value="errors[index].rows"/>
</t>
</li>
<li t-if="errors.length &gt; 3 and index == 2" style="display: block;">
<a href="#" class="oe_import_report_count">
( <i class="arrow down"></i> <t t-esc="errors.length - 3"/> more)
</a>
</li>
</t>
</ul>
</div>
<t t-if="errors[0].moreinfo" t-call="ImportView.error.more_info">
<t t-set="messages" t-value="errors[0].moreinfo"/>
</t>
</t>
<t t-name="ImportView.error.at">
<t t-set="from" t-value="rows.from + offset"/>
<t t-set="to" t-value="rows.to + offset"/>
<t t-if="from === to">
at row <t t-esc="from"/> <t t-if="result_names.length > rows.from &amp;&amp; result_names[rows.from] !== ''">(<t t-esc="result_names[rows.from]"/>)</t>
</t>
<t t-else="">
at multiple rows
</t>
</t>
<t t-name="ImportView.error.more_info">
<div t-if="typeof messages === 'string'" class="oe_import_moreinfo oe_import_moreinfo_action">
<t t-esc="messages"/>
</div>
<div t-elif="messages instanceof Array" class="oe_import_moreinfo oe_import_moreinfo_choices">
<a href="#" class="oe_import_report_see_possible_value oe_import_see_all">
<i class="fa fa-arrow-right"/> See possible values </a>
<ul class="oe_import_report_more">
<li t-foreach="messages" t-as="msg"><t t-esc="msg"/></li>
</ul>
</div>
<div t-else="" class="oe_import_moreinfo oe_import_moreinfo_action">
<a href="#" t-att-data-action="JSON.stringify(messages)" class="oe_import_see_all">
<i class="fa fa-arrow-right"/> See possible values </a>
</div>
</t>
<t t-name="ImportView.data_matching">
<div class="oe_import_with_file d-none">
<div class="oe_import_noheaders">
<p class="alert alert-info">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.</p>
</div>
<div class="o_import_partial_alert alert alert-warning d-none mx-2 my-2 fw-bold">
Click 'Resume' to proceed with the import, resuming at line
<span class="o_import_partial_count">0</span>.<br/>
You can test or reload your file before resuming the import.
</div>
<div class="oe_import_error_report px-3 py-2"></div>
<div class="table-responsive">
<table class="table-striped oe_import_grid w-100 overflow-hidden" />
</div>
</div>
</t>
<t t-name="ImportView.side_panel">
<div class="oe_import_box d-none bg-white overflow-auto">
<input accept=".csv, .xls, .xlsx, .xlsm, .ods" t-attf-id="file_#{_id}"
name="file" class="oe_import_file" type="file" style="display:none;"/>
<div class="oe_import_with_file">
<h4>Imported file</h4>
<div class="mb-2 d-flex align-items-center">
<i class="fa fa-file white me-2"></i>
<div id="oe_imported_file" class="fst-italic truncate"></div>
<span id="oe_imported_file_extension" class="fst-italic"></span>
</div>
<div class="oe_import_has_multiple_sheets flex-column mt-2">
<label for="oe_import_sheet">Sheet:</label>
<input class="oe_import_sheet oe_import_dropdown" id="oe_import_sheet"/>
</div>
<input type="checkbox" class="oe_import_has_header form-check-input"
id="oe_import_has_header" checked="checked"/>
<label class="form-check-label" for="oe_import_has_header">Use first row as header</label>
<div class="o_import_formatting">
<h4 class="mt-3">Formatting</h4>
<div t-foreach="[widget.opts, widget.parse_opts_formats, widget.parse_opts_separators]" t-as="options" class="oe_import_options">
<div t-attf-class="mb-1 oe_import_#{option.type}_box" t-foreach="options" t-as="option">
<!-- no @name, avoid submission when file_update called -->
<label t-attf-for="#{option.name}_#{_id}">
<t t-esc="option.label"/></label>
<input
t-attf-id="#{option.name}_#{_id}"
t-attf-class="oe_import_#{option.name} oe_import_dropdown w-100 mb-3"
t-att-value="option.value"/>
</div>
</div>
</div>
<div class="o_import_batch">
<h4 class="mt-3">Batch Import</h4>
<div class="o_import_batch_alert fw-bold d-none pb-2">
The file will be imported by batches
</div>
<div class="d-flex">
<div class="oe_import_options oe_import_batch_limit w-50 pe-1">
<label class="mb-1" for="oe_import_batch_limit">Batch limit</label>
<input class="w-100" id="oe_import_batch_limit" value="2000"/>
</div>
<div class="oe_import_options w-50 ps-1" title="Warning: ignores the labels line, empty lines and
lines composed only of empty cells">
<label class="mb-1" for="oe_import_row_start">Start at line</label>
<input class="w-100" id="oe_import_row_start" value="1"/>
</div>
</div>
</div>
<h4 class="mt-3">Help</h4>
<div t-foreach="widget.importTemplates" t-as="template">
<a t-att-href="template.template" aria-label="Download" title="Download">
<i class="fa fa-download"/> <span>Download Template</span>
</a>
</div>
<a href="https://www.odoo.com/documentation/master/applications/general/export_import_data.html" target="new">
<i class="fa fa-external-link"></i> Go to Import FAQ
</a>
<div class="oe_import_debug_option">
<h4 class="mt-3">Advanced</h4>
<div title="If the model uses openchatter, history tracking will set up subscriptions and send notifications during the import, but lead to a slower import.">
<input type="checkbox" id="oe_import_tracking" class="form-check-input"/>
<label class="form-check-label" for="oe_import_tracking">
Track history during import
</label>
</div>
<div>
<input type="checkbox" class="oe_import_advanced_mode form-check-input" checked="checked"
id="oe_import_advanced_mode"/>
<label class="form-check-label" for="oe_import_advanced_mode">Allow matching with subfields</label>
</div>
</div>
</div>
</div>
</t>
<div t-name="base_import.progressDialog" class="o_import_progress_dialog text-white">
<span class="fa fa-spin fa-circle-o-notch fa-2x mb-2"/>
<div t-if="isBatch">
<div class="o_import_progress_dialog_batch">
<span t-esc="importMode"/> batch <span class="o_import_progress_dialog_batch_count">1</span> out of <span t-esc="totalSteps"/>...
<div class="o_import_progress_dialog_time_left d-none">
<span>Estimated time left:</span>
<span class="o_import_progress_dialog_time_left_text"/>
<span>minutes</span>
</div>
</div>
<span class="o_import_progress_dialog_stop d-none">
Finalizing current batch before interrupting...
</span>
<div class="d-flex align-items-center mt-2">
<div class="progress flex-grow-1">
<div class="progress-bar progress-bar-striped" role="progressbar"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<span>0%</span>
</div>
</div>
<a class="o_progress_stop_import ms-2" role="button"><i class="fa fa-close" aria-label="Stop Import"/></a>
</div>
</div>
<div t-else="">
<span t-esc="task"/>...
</div>
</div>
</templates>
File diff suppressed because it is too large Load Diff
@@ -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 : []);
}
/**
@@ -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,
@@ -8,6 +8,10 @@
</p>
<ul class="o-tooltip--technical" t-if="debug" role="tooltip">
<li data-item="field" t-if="field and field.label">
<span class="o-tooltip--technical--title">Label:</span>
<t t-esc="field.label"/>
</li>
<li data-item="field" t-if="field and field.name">
<span class="o-tooltip--technical--title">Field:</span>
<t t-esc="field.name"/>