/** @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], }); } function customParsePreview(opts, { fields, headers, rowCount, matches, preview }) { totalRows = rowCount; return Promise.resolve({ advanced_mode: opts.advanced, batch: false, fields: fields, file_length: opts.has_headers ? totalRows - 1 : totalRows, header_types: false, headers: headers, matches: matches, options: { ...opts, sheet: opts.sheet.length ? opts.sheet : "Template", sheets: ["Template", "Template 2"], }, preview: preview, }); } // since executing a real import would be difficult, this method simply returns // some error messages to help testing the UI function executeFailingImport(field, isMultiline, field_path = "") { 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"]); // Contains invisible mobile buttons assert.containsN( target, ".o_control_panel button", 5, "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, ".o_control_panel_main_buttons .d-none 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: default import options are correctly", async function (assert) { registerFakeHTTPService(); await createImportAction({ "base_import.import/parse_preview": async (route, args) => { return parsePreview(args[1]); }, "base_import.import/execute_import": (route, args) => { assert.step("execute_import"); assert.deepEqual( args[3], { advanced: true, date_format: "", datetime_format: "", encoding: "", fallback_values: {}, float_decimal_separator: ".", float_thousand_separator: ",", has_headers: true, import_set_empty_fields: [], import_skip_records: [], keep_matches: false, limit: 2000, name_create_enabled_fields: {}, quoting: '"', separator: "", sheet: "Template", sheets: ["Template", "Template 2"], skip: 0, tracking_disable: true, }, "options are defaulted as expected" ); 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await click( target.querySelector(".o_control_panel_main_buttons .d-none button:first-child") ); assert.verifySteps(["execute_import"]); }); 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, ".o_control_panel_main_buttons .d-none 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, ".o_control_panel_main_buttons .d-none 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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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_main_buttons .d-none 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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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_main_buttons .d-none 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[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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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_main_buttons .d-none 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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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_main_buttons .d-none 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_main_buttons .d-none 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await click( target.querySelector(".o_control_panel_main_buttons .d-none 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().toLowerCase(), "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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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() { super.setup(); if (this.props.message === "Importing") { assert.step("Block UI received the right text"); } }, }); patchWithCleanup(ImportDataProgress.prototype, { setup() { super.setup(); 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await editInput(target, "input#o_import_batch_limit", 1); await click( target.querySelector(".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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() { super.setup(); if (this.props.message === "Testing") { assert.step("Block UI received the right text"); } }, }); patchWithCleanup(ImportDataProgress.prototype, { setup() { super.setup(); 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await editInput(target, "input#o_import_batch_limit", 1); await click( target.querySelector(".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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" ); }); QUnit.test( "Import view: relational fields correctly mapped on preview", async function (assert) { assert.expect(4); registerFakeHTTPService(); await createImportAction({ "base_import.import/parse_preview": async (route, args) => { return customParsePreview(args[1], { fields: [ { id: "id", name: "id", string: "External ID", fields: [], type: "id" }, { id: "display_name", name: "display_name", string: "Display Name", fields: [], type: "id", }, { id: "many2many_field", name: "many2many_field", string: "Many2Many", fields: [ { id: "id", name: "id", string: "External ID", fields: [], type: "id", }, ], type: "id", }, ], headers: ["id", "display_name", "many2many_field/id"], rowCount: 5, matches: { 0: ["id"], 1: ["display_name"], 2: ["many2many_field", "id"], }, preview: [ ["0", "1", "2"], ["Name 1", "Name 2", "Name 3"], ["", "1", "2"], ], }); }, "base_import.import/execute_import": (route, args) => { assert.deepEqual( args[1], ["id", "display_name", "many2many_field/id"], "The proper arguments are given for the import" ); assert.deepEqual( args[2], ["id", "display_name", "many2many_field/id"], "The proper arguments are given for the import" ); 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, ".o_control_panel_main_buttons .d-none input[type='file']", file ); assert.strictEqual( target.querySelector( "tr:nth-child(3) .o_import_file_column_cell span.text-truncate" ).innerText, "many2many_field/id", "The third row should be the relational field" ); assert.strictEqual( target.querySelector("tr:nth-child(3) .o_select_menu_toggler_slot span").innerText, "Many2Many / External ID", "The relational field should be selected by default and the name should be the full path." ); await click( target.querySelector(".o_control_panel_main_buttons .d-none button:first-child") ); } ); QUnit.test("Import view: import errors with relational fields", async function (assert) { registerFakeHTTPService(); patchWithCleanup(browser, { setTimeout: (fn) => fn(), }); await createImportAction({ "base_import.import/parse_preview": async (route, args) => { return customParsePreview(args[1], { fields: [ { id: "id", name: "id", string: "External ID", fields: [], type: "id" }, { id: "display_name", name: "display_name", string: "Display Name", fields: [], type: "id", }, { id: "many2many_field", name: "many2many_field", string: "Many2Many", fields: [ { id: "id", name: "id", string: "External ID", fields: [], type: "id", }, ], type: "id", }, ], headers: ["id", "display_name", "many2many_field/id"], rowCount: 5, matches: { 0: ["id"], 1: ["display_name"], 2: ["many2many_field", "id"], }, preview: [ ["0", "1", "2"], ["Name 1", "Name 2", "Name 3"], ["", "1", "2"], ], }); }, "base_import.import/execute_import": (route, args) => { return executeFailingImport(args[1][0], true, ["many2many_field", "id"]); }, }); // 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, ".o_control_panel_main_buttons .d-none 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_main_buttons .d-none 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("tr:nth-child(3) .o_import_file_column_cell span.text-truncate") .innerText, "many2many_field/id", "The third row should be the relational field" ); assert.strictEqual( target.querySelector("tr:nth-child(3) .o_select_menu_toggler_slot span").innerText, "Many2Many / External ID", "The relational field is properly mapped" ); assert.containsOnce( target, "tr:nth-child(3) .o_import_report.alert", "The relational field should have error messages on his row" ); assert.strictEqual( target.querySelector("tr:nth-child(3) .o_import_report.alert p b").innerText, "Many2Many / External ID", "The error should contain the full path of the relational field" ); }); QUnit.test("Import view: date format should be converted to strftime", async function (assert) { assert.expect(5); registerFakeHTTPService(); let parseCount = 0; await createImportAction({ "base_import.import/parse_preview": async (route, args) => { parseCount++; const response = await parsePreview(args[1]); if (parseCount === 2) { assert.strictEqual( response.options.date_format, "%Y%m%d", "server sends back a strftime formatted date" ); } return response; }, "base_import.import/execute_import": (route, args) => { assert.step("execute_import"); assert.strictEqual( args[3].date_format, "%Y%m%d", "date is converted to strftime as expected during the import" ); return executeImport(args); }, }); // 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await editInput(target, ".o_import_date_format#date_format-3", "YYYYMMDD"); // Parse the file again with the updated date format to check that // the format is correctly formatted in the UI await click(target.querySelector(".o_import_formatting button")); await click( $(target).find( ".o_control_panel_main_buttons > div:visible > button:contains(Import)" )[0] ); assert.verifySteps(["execute_import"]); assert.strictEqual( target.querySelector(".o_import_date_format").value, "YYYYMMDD", "UI displays the human formatted date" ); }); QUnit.test("Import action: field selection has a clear button", async function (assert) { registerFakeHTTPService(); patchWithCleanup(browser, { setTimeout: (fn) => fn(), }); await createImportAction(); // 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, ".o_control_panel_main_buttons .d-none input[type='file']", file); await editSelectMenu(target, ".o_import_data_content .o_select_menu", "Bar"); assert.containsN( target, ".o_select_menu_toggler_clear", 2, "clear button is present for each field to unselect it" ); await click(target.querySelector(".o_select_menu_toggler_clear")); assert.strictEqual( target.querySelector("tr:nth-child(2) .o_select_menu").textContent, "To import, select a field...", "field has been unselected" ); }); QUnit.test( "Import a CSV: formatting options for date and datetime options", 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, ".o_control_panel_collapsed_create input[type='file']", file); assert.strictEqual( target.querySelector(".o_import_date_format").list.id, "list-3", "a datalist is associated to the date input" ); assert.containsOnce( target.querySelector(".o_import_date_format").previousElementSibling, "sup", "a tooltip is displayed on the label of the option" ); assert.strictEqual( target.querySelector(".o_import_date_format").list.options[0].value, "YYYY-MM-DD", "an option for datetime has the right value" ); assert.strictEqual( target.querySelector(".o_import_datetime_format").list.id, "list-4", "a datalist is associated to the datetime input" ); assert.containsOnce( target.querySelector(".o_import_datetime_format").previousElementSibling, "sup", "a tooltip is displayed on the label of the option" ); assert.strictEqual( target.querySelector(".o_import_datetime_format").list.options[0].value, "YYYY-MM-DD HH:mm:SS", "an option for datetime has the right value" ); assert.containsOnce( target, ".o_import_action .o_import_data_content", "content panel is visible" ); } ); });