summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/views/list_tests.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/views/list_tests.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/tests/views/list_tests.js')
-rw-r--r--addons/web/static/tests/views/list_tests.js11702
1 files changed, 11702 insertions, 0 deletions
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
new file mode 100644
index 00000000..0efeea89
--- /dev/null
+++ b/addons/web/static/tests/views/list_tests.js
@@ -0,0 +1,11702 @@
+odoo.define('web.list_tests', function (require) {
+"use strict";
+
+var AbstractFieldOwl = require('web.AbstractFieldOwl');
+var AbstractStorageService = require('web.AbstractStorageService');
+var BasicModel = require('web.BasicModel');
+var core = require('web.core');
+const Domain = require('web.Domain')
+var basicFields = require('web.basic_fields');
+var fieldRegistry = require('web.field_registry');
+var fieldRegistryOwl = require('web.field_registry_owl');
+var FormView = require('web.FormView');
+var ListRenderer = require('web.ListRenderer');
+var ListView = require('web.ListView');
+var mixins = require('web.mixins');
+var NotificationService = require('web.NotificationService');
+var RamStorage = require('web.RamStorage');
+var testUtils = require('web.test_utils');
+var widgetRegistry = require('web.widget_registry');
+var Widget = require('web.Widget');
+
+
+var _t = core._t;
+const cpHelpers = testUtils.controlPanel;
+var createActionManager = testUtils.createActionManager;
+var createView = testUtils.createView;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ foo: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ bar: {string: "Bar", type: "boolean"},
+ date: {string: "Some Date", type: "date"},
+ int_field: {string: "int_field", type: "integer", sortable: true, group_operator: "sum"},
+ text: {string: "text field", type: "text"},
+ qux: {string: "my float", type: "float"},
+ m2o: {string: "M2O field", type: "many2one", relation: "bar"},
+ o2m: {string: "O2M field", type: "one2many", relation: "bar"},
+ m2m: {string: "M2M field", type: "many2many", relation: "bar"},
+ amount: {string: "Monetary field", type: "monetary"},
+ currency_id: {string: "Currency", type: "many2one",
+ relation: "res_currency", default: 1},
+ datetime: {string: "Datetime Field", type: 'datetime'},
+ reference: {string: "Reference Field", type: 'reference', selection: [
+ ["bar", "Bar"], ["res_currency", "Currency"], ["event", "Event"]]},
+ },
+ records: [
+ {
+ id: 1,
+ bar: true,
+ foo: "yop",
+ int_field: 10,
+ qux: 0.4,
+ m2o: 1,
+ m2m: [1, 2],
+ amount: 1200,
+ currency_id: 2,
+ date: "2017-01-25",
+ datetime: "2016-12-12 10:55:05",
+ reference: 'bar,1',
+ },
+ {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13,
+ m2o: 2, m2m: [1, 2, 3], amount: 500, reference: 'res_currency,1'},
+ {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3,
+ m2o: 1, m2m: [], amount: 300, reference: 'res_currency,2'},
+ {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9,
+ m2o: 1, m2m: [1], amount: 0},
+ ]
+ },
+ bar: {
+ fields: {},
+ records: [
+ {id: 1, display_name: "Value 1"},
+ {id: 2, display_name: "Value 2"},
+ {id: 3, display_name: "Value 3"},
+ ]
+ },
+ res_currency: {
+ fields: {
+ symbol: {string: "Symbol", type: "char"},
+ position: {
+ string: "Position",
+ type: "selection",
+ selection: [['after', 'A'], ['before', 'B']],
+ },
+ },
+ records: [
+ {id: 1, display_name: "USD", symbol: '$', position: 'before'},
+ {id: 2, display_name: "EUR", symbol: '€', position: 'after'},
+ ],
+ },
+ event: {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ name: {string: "name", type: "char"},
+ },
+ records: [
+ {id: "2-20170808020000", name: "virtual"},
+ ]
+ },
+ "ir.translation": {
+ fields: {
+ lang_code: {type: "char"},
+ src: {type: "char"},
+ value: {type: "char"},
+ res_id: {type: "integer"},
+ name: {type: "char"},
+ lang: {type: "char"},
+ },
+ records: [{
+ id: 99,
+ res_id: 1,
+ value: '',
+ lang_code: 'en_US',
+ lang: 'en_US',
+ name: 'foo,foo'
+ },{
+ id: 100,
+ res_id: 1,
+ value: '',
+ lang_code: 'fr_BE',
+ lang: 'fr_BE',
+ name: 'foo,foo'
+ }]
+ },
+ };
+ }
+}, function () {
+
+ QUnit.module('ListView');
+
+ QUnit.test('simple readonly list', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ assert.doesNotHaveClass(list.$el, 'o_cannot_create',
+ "should not have className 'o_cannot_create'");
+
+ // 3 th (1 for checkbox, 2 for columns)
+ assert.containsN(list, 'th', 3, "should have 3 columns");
+
+ assert.strictEqual(list.$('td:contains(gnap)').length, 1, "should contain gnap");
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ assert.containsOnce(list, 'th.o_column_sortable', "should have 1 sortable column");
+
+ assert.strictEqual(list.$('thead th:nth(2)').css('text-align'), 'right',
+ "header cells of integer fields should be right aligned");
+ assert.strictEqual(list.$('tbody tr:first td:nth(2)').css('text-align'), 'right',
+ "integer cells should be right aligned");
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'));
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'));
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'));
+ list.destroy();
+ });
+
+ QUnit.test('on_attach_callback is properly called', async function (assert) {
+ assert.expect(3);
+
+ testUtils.mock.patch(ListRenderer, {
+ on_attach_callback() {
+ assert.step('on_attach_callback');
+ this._super(...arguments);
+ },
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="display_name"/></tree>',
+ });
+
+ assert.verifySteps(['on_attach_callback']);
+ await list.reload();
+ assert.verifySteps([]);
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('list with create="0"', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree create="0"><field name="foo"/></tree>',
+ });
+
+ assert.hasClass(list.$el,'o_cannot_create',
+ "should have className 'o_cannot_create'");
+ assert.containsNone(list.$buttons, '.o_list_button_add',
+ "should not have the 'Create' button");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with delete="0"', async function (assert) {
+ assert.expect(3);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree delete="0"><field name="foo"/></tree>',
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus .o_dropdown_menu');
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with edit="0"', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree editable="top" edit="0"><field name="foo"/></tree>',
+ });
+
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+
+ await testUtils.dom.click(list.$('tr td:not(.o_list_record_selector)').first());
+ assert.containsNone(list, 'tbody tr.o_selected_row', "should not have editable row");
+
+ list.destroy();
+ });
+
+ QUnit.test('export feature in list for users not in base.group_allow_export', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: { hasActionMenus: true },
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_buttons .o_list_export_xlsx');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(cpHelpers.getMenuItemTexts(list), ['Delete'],
+ 'action menu should not contain the Export button');
+
+ list.destroy();
+ });
+
+ QUnit.test('list with export button', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.ok(list.$('tbody td.o_list_record_selector').length, 'should have at least one record');
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_buttons .o_list_export_xlsx');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(
+ cpHelpers.getMenuItemTexts(list),
+ ['Export', 'Delete'],
+ 'action menu should have Export button'
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('export button in list view', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.isVisible(list.$('.o_list_export_xlsx'));
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.isNotVisible(list.$('.o_list_export_xlsx'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+ assert.isVisible(list.$('.o_list_export_xlsx'));
+
+ list.destroy();
+ });
+
+ QUnit.test('export button in empty list view', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: "foo",
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ domain: [["id", "<", 0]], // such that no record matches the domain
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_allow_export') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+
+ assert.isNotVisible(list.el.querySelector('.o_list_export_xlsx'));
+
+ await list.reload({ domain: [['id', '>', 0]] });
+ assert.isVisible(list.el.querySelector('.o_list_export_xlsx'));
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" invisible="1"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and invisible field (modifier)', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" attrs="{'invisible': [['foo', '=', 'blip']]}"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 4,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons and optional field', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="a" type="object" icon="fa-car"/>
+ <field name="foo" optional="hide"/>
+ <button name="x" type="object" icon="fa-star"/>
+ <button name="y" type="object" icon="fa-refresh"/>
+ <button name="z" type="object" icon="fa-exclamation"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsN(list.$('.o_data_row:first'), 'td.o_list_button', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with adjacent buttons with invisible modifier', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="x" type="object" icon="fa-star" attrs="{'invisible': [['foo', '=', 'blip']]}"/>
+ <button name="y" type="object" icon="fa-refresh" attrs="{'invisible': [['foo', '=', 'yop']]}"/>
+ <button name="z" type="object" icon="fa-exclamation" attrs="{'invisible': [['foo', '=', 'gnap']]}"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'th', 3,
+ "adjacent buttons in the arch must be grouped in a single column");
+ assert.containsOnce(list.$('.o_data_row:first'), 'td.o_list_button');
+ assert.strictEqual(list.$('.o_field_cell').text(), 'yopblipgnapblip');
+ assert.containsN(list, 'td button i.fa-star:visible', 2);
+ assert.containsN(list, 'td button i.fa-refresh:visible', 3);
+ assert.containsN(list, 'td button i.fa-exclamation:visible', 3);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with icon buttons', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records.splice(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <button name="x" type="object" icon="fa-asterisk"/>
+ <button name="x" type="object" icon="fa-star" class="o_yeah"/>
+ <button name="x" type="object" icon="fa-refresh" string="Refresh" class="o_yeah"/>
+ <button name="x" type="object" icon="fa-exclamation" string="Danger" class="o_yeah btn-danger"/>
+ </tree>`,
+ });
+
+ assert.containsOnce(list, 'button.btn.btn-link i.fa.fa-asterisk');
+ assert.containsOnce(list, 'button.btn.btn-link.o_yeah i.fa.fa-star');
+ assert.containsOnce(list, 'button.btn.btn-link.o_yeah:contains("Refresh") i.fa.fa-refresh');
+ assert.containsOnce(list, 'button.btn.btn-danger.o_yeah:contains("Danger") i.fa.fa-exclamation');
+ assert.containsNone(list, 'button.btn.btn-link.btn-danger');
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button in controlPanel basic rendering', async function (assert) {
+ assert.expect(11);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ <button name="y" type="object" class="plouf" string="plouf" invisible="not context.get('bim')"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ });
+ let cpButtons = cpHelpers.getButtons(list);
+ assert.containsNone(cpButtons[0], 'button[name="x"]');
+ assert.containsNone(cpButtons[0], '.o_list_selection_box');
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ cpButtons = cpHelpers.getButtons(list);
+ assert.containsOnce(cpButtons[0], 'button[name="x"]');
+ assert.hasClass(cpButtons[0].querySelector('button[name="x"]'), 'btn btn-secondary');
+ assert.containsOnce(cpButtons[0], '.o_list_selection_box');
+ assert.strictEqual(
+ cpButtons[0].querySelector('button[name="x"]').previousElementSibling,
+ cpButtons[0].querySelector('.o_list_selection_box')
+ );
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ cpButtons = cpHelpers.getButtons(list);
+ assert.containsNone(cpButtons[0], 'button[name="x"]');
+ assert.containsNone(cpButtons[0], '.o_list_selection_box');
+ assert.containsNone(cpButtons[0], 'button[name="y"]');
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click: buttons are disabled and re-enabled', async function (assert) {
+ assert.expect(3);
+
+ const executeActionDef = testUtils.makeTestPromise();
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ const { on_success } = ev.data;
+ await executeActionDef;
+ on_success();
+ }
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => !btn.disabled)
+ );
+
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => btn.disabled)
+ );
+
+ executeActionDef.resolve();
+ await testUtils.nextTick();
+ assert.ok(
+ Array.from(cpButtons[0].querySelectorAll('button')).every(btn => !btn.disabled)
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click: correct parameters', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf" context="{'plouf': 'plif'}"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ const {
+ action_data: {
+ context, name, type
+ },
+ env,
+ } = ev.data;
+ // Action's own properties
+ assert.strictEqual(name, "x");
+ assert.strictEqual(type, "object");
+
+ // The action's execution context
+ assert.deepEqual(context, {
+ active_domain: [],
+ active_id: 1,
+ active_ids: [1],
+ active_model: 'foo',
+ plouf: 'plif',
+ });
+ // The current environment (not owl's, but the current action's)
+ assert.deepEqual(env, {
+ context: {},
+ model: 'foo',
+ resIDs: [1],
+ });
+ }
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ list.destroy();
+ });
+
+ QUnit.test('list view: action button executes action on click with domain selected: correct parameters', async function (assert) {
+ assert.expect(10);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree limit="1">
+ <header>
+ <button name="x" type="object" class="plaf" string="plaf"/>
+ </header>
+ <field name="foo" />
+ </tree>`,
+ intercepts: {
+ async execute_action(ev) {
+ assert.step('execute_action');
+ const {
+ action_data: {
+ context, name, type
+ },
+ env,
+ } = ev.data;
+ // Action's own properties
+ assert.strictEqual(name, "x");
+ assert.strictEqual(type, "object");
+
+ // The action's execution context
+ assert.deepEqual(context, {
+ active_domain: [],
+ active_id: 1,
+ active_ids: [1, 2, 3, 4],
+ active_model: 'foo',
+ });
+ // The current environment (not owl's, but the current action's)
+ assert.deepEqual(env, {
+ context: {},
+ model: 'foo',
+ resIDs: [1, 2, 3, 4],
+ });
+ }
+ },
+ mockRPC(route, args) {
+ if (args.method === 'search') {
+ assert.step('search');
+ assert.strictEqual(args.model, 'foo');
+ assert.deepEqual(args.args, [[]]); // empty domain since no domain in searchView
+ }
+ return this._super.call(this, ...arguments);
+ }
+ });
+ await testUtils.dom.click(
+ list.el.querySelector('.o_data_row .o_list_record_selector input[type="checkbox"]')
+ );
+ const cpButtons = cpHelpers.getButtons(list);
+
+ await testUtils.dom.click(cpButtons[0].querySelector('.o_list_select_domain'));
+ assert.verifySteps([]);
+
+ await testUtils.dom.click(cpButtons[0].querySelector('button[name="x"]'));
+ assert.verifySteps([
+ 'search',
+ 'execute_action',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('column names (noLabel, label, string and default)', async function (assert) {
+ assert.expect(4);
+
+ const FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('nolabel_char', FieldChar.extend({
+ noLabel: true,
+ }));
+ fieldRegistry.add('label_char', FieldChar.extend({
+ label: "Some static label",
+ }));
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="display_name" widget="nolabel_char"/>
+ <field name="foo" widget="label_char"/>
+ <field name="int_field" string="My custom label"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ assert.strictEqual(list.$('thead th[data-name=display_name]').text(), '');
+ assert.strictEqual(list.$('thead th[data-name=foo]').text(), 'Some static label');
+ assert.strictEqual(list.$('thead th[data-name=int_field]').text(), 'My custom label');
+ assert.strictEqual(list.$('thead th[data-name=text]').text(), 'text field');
+
+ list.destroy();
+ });
+
+ QUnit.test('simple editable rendering', async function (assert) {
+ assert.expect(15);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, 'th', 3, "should have 2 th");
+ assert.containsN(list, 'th', 3, "should have 3 th");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+ assert.containsOnce(list, 'td:contains(yop)', "should contain yop");
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'),
+ "should not have a visible save button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'),
+ "should not have a visible discard button");
+
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').first());
+
+ assert.isNotVisible(list.$buttons.find('.o_list_button_add'),
+ "should not have a visible Create button");
+ assert.isVisible(list.$buttons.find('.o_list_button_save'),
+ "should have a visible save button");
+ assert.isVisible(list.$buttons.find('.o_list_button_discard'),
+ "should have a visible discard button");
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'),
+ "should not have a visible save button");
+ assert.isNotVisible(list.$buttons.find('.o_list_button_discard'),
+ "should not have a visible discard button");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable rendering with handle and no data', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.records = [];
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="currency_id"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, 'thead th', 4, "there should be 4 th");
+ assert.hasClass(list.$('thead th:eq(0)'), 'o_list_record_selector');
+ assert.hasClass(list.$('thead th:eq(1)'), 'o_handle_cell');
+ assert.strictEqual(list.$('thead th:eq(1)').text(), '',
+ "the handle field shouldn't have a header description");
+ assert.strictEqual(list.$('thead th:eq(2)').attr('style'), "width: 50%;");
+ assert.strictEqual(list.$('thead th:eq(3)').attr('style'), "width: 50%;");
+ list.destroy();
+ });
+
+ QUnit.test('invisible columns are not displayed', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '</tree>',
+ });
+
+ // 1 th for checkbox, 1 for 1 visible column
+ assert.containsN(list, 'th', 2, "should have 2 th");
+ list.destroy();
+ });
+
+ QUnit.test('boolean field has no title', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar"/></tree>',
+ });
+ assert.equal(list.$('tbody tr:first td:eq(1)').attr('title'), "");
+ list.destroy();
+ });
+
+ QUnit.test('field with nolabel has no title', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" nolabel="1"/></tree>',
+ });
+ assert.strictEqual(list.$('thead tr:first th:eq(1)').text(), "");
+ list.destroy();
+ });
+
+ QUnit.test('field titles are not escaped', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].foo = '<div>Hello</div>';
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ assert.strictEqual(list.$('tbody tr:first .o_data_cell').text(), "<div>Hello</div>");
+ assert.strictEqual(list.$('tbody tr:first .o_data_cell').attr('title'), "<div>Hello</div>");
+
+ list.destroy();
+ });
+
+ QUnit.test('record-depending invisible lines are correctly aligned', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody tr:first td', 4,
+ "there should be 4 cells in the first row");
+ assert.containsOnce(list, 'tbody td.o_invisible_modifier',
+ "there should be 1 invisible bar cell");
+ assert.hasClass(list.$('tbody tr:first td:eq(2)'),'o_invisible_modifier',
+ "the 3rd cell should be invisible");
+ assert.containsN(list, 'tbody tr:eq(0) td:visible', list.$('tbody tr:eq(1) td:visible').length,
+ "there should be the same number of visible cells in different rows");
+ list.destroy();
+ });
+
+ QUnit.test('do not perform extra RPC to read invisible many2one fields', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.m2o.default = 2;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="m2o" invisible="1"/>' +
+ '</tree>',
+ mockRPC: function (route) {
+ assert.step(_.last(route.split('/')));
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.verifySteps(['search_read', 'onchange'], "no nameget should be done");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list datetimepicker destroy widget (edition)', async function (assert) {
+ assert.expect(7);
+ var eventPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': async function () {
+ assert.containsOnce(list, '.o_selected_row');
+ assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget');
+
+ await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape');
+
+ assert.containsOnce(list, '.o_selected_row');
+ assert.containsNone($('body'), '.bootstrap-datetimepicker-widget');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.containsNone(list, '.o_selected_row');
+
+ eventPromise.resolve();
+ }
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.dom.openDatepicker(list.$('.o_datepicker'));
+
+ await eventPromise;
+ list.destroy();
+ });
+
+ QUnit.test('editable list datetimepicker destroy widget (new line)', async function (assert) {
+ assert.expect(10);
+ var eventPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': async function () {
+ assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.containsOnce(list, '.o_selected_row');
+
+ await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape');
+
+ assert.containsNone($('body'), '.bootstrap-datetimepicker-widget');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.containsOnce(list, '.o_selected_row');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_selected_row');
+
+ eventPromise.resolve();
+ }
+ });
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.openDatepicker(list.$('.o_datepicker'));
+
+ await eventPromise;
+ list.destroy();
+ });
+
+ QUnit.test('at least 4 rows are rendered, even if less data', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar"/></tree>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('discard a new record in editable="top" list with less than 4 records', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="bar"/></tree>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsN(list, 'tbody tr', 4);
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.hasClass(list.$('tbody tr:first'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_list_button_discard'));
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsN(list, 'tbody tr', 4);
+ assert.hasClass(list.$('tbody tr:first'), 'o_data_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Foo)').length, 1, "should contain Foo");
+ assert.strictEqual(list.$('th:contains(Bar)').length, 1, "should contain Bar");
+ assert.containsN(list, 'tr.o_group_header', 2, "should have 2 .o_group_header");
+ assert.containsN(list, 'th.o_group_name', 2, "should have 2 .o_group_name");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering with widget="handle" col', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Foo)').length, 1, "should contain Foo");
+ assert.strictEqual(list.$('th:contains(Bar)').length, 1, "should contain Bar");
+ assert.containsN(list, 'tr.o_group_header', 2, "should have 2 .o_group_header");
+ assert.containsN(list, 'th.o_group_name', 2, "should have 2 .o_group_name");
+ assert.containsNone(list, 'th:contains(int_field)', "Should not have int_field in grouped list");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 1 col without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 1,
+ "group header should have exactly 1 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "1",
+ "the header should span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 1 col with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 1,
+ "group header should have exactly 1 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the header should span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 2 cols without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 column");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "1",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 3 cols without selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/><field name="text"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: false,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the first header should span two columns");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 2 col with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "2",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 3 cols with selector', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree ><field name="foo"/><field name="bar"/><field name="text"/></tree>',
+ groupBy: ['bar'],
+ hasSelectors: true,
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 2,
+ "group header should have exactly 2 columns");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3",
+ "the header should not span the whole table");
+ list.destroy();
+ });
+
+ QUnit.test('basic grouped list rendering 7 cols with aggregates and selector', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="datetime"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum1"/>' +
+ '<field name="bar"/>' +
+ '<field name="qux" sum="Sum2"/>' +
+ '<field name="date"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.strictEqual(list.$('.o_group_header:first').children().length, 5,
+ "group header should have exactly 5 columns (one before first aggregate, one after last aggregate, and all in between");
+ assert.strictEqual(list.$('.o_group_header:first th').attr('colspan'), "3",
+ "header name should span on the two first fields + selector (colspan 3)");
+ assert.containsN(list, '.o_group_header:first td', 3,
+ "there should be 3 tds (aggregates + fields in between)");
+ assert.strictEqual(list.$('.o_group_header:first th:last').attr('colspan'), "2",
+ "header last cell should span on the two last fields (to give space for the pager) (colspan 2)");
+ list.destroy();
+ });
+
+ QUnit.test('ordered list, sort attribute in context', async function (assert) {
+ assert.expect(1);
+ // Equivalent to saving a custom filter
+
+ this.data.foo.fields.foo.sortable = true;
+ this.data.foo.fields.date.sortable = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+
+ // Descending order on Foo
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Foo")'));
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Foo")'));
+
+ // Ascending order on Date
+ await testUtils.dom.click(list.$('th.o_column_sortable:contains("Date")'));
+
+ var listContext = list.getOwnedQueryParams();
+ assert.deepEqual(listContext,
+ {
+ orderedBy: [{
+ name: 'date',
+ asc: true,
+ }, {
+ name: 'foo',
+ asc: false,
+ }]
+ }, 'the list should have the right orderedBy in context');
+ list.destroy();
+ });
+
+ QUnit.test('Loading a filter with a sort attribute', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.fields.foo.sortable = true;
+ this.data.foo.fields.date.sortable = true;
+
+ var searchReads = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="date"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ if (searchReads === 0) {
+ assert.strictEqual(args.sort, 'date ASC, foo DESC',
+ 'The sort attribute of the filter should be used by the initial search_read');
+ } else if (searchReads === 1) {
+ assert.strictEqual(args.sort, 'date DESC, foo ASC',
+ 'The sort attribute of the filter should be used by the next search_read');
+ }
+ searchReads += 1;
+ }
+ return this._super.apply(this,arguments);
+ },
+ favoriteFilters : [
+ {
+ context: "{}",
+ domain: "[]",
+ id: 7,
+ is_default: true,
+ name: "My favorite",
+ sort: "[\"date asc\", \"foo desc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }, {
+ context: "{}",
+ domain: "[]",
+ id: 8,
+ is_default: false,
+ name: "My second favorite",
+ sort: "[\"date desc\", \"foo asc\"]",
+ user_id: [2, "Mitchell Admin"],
+ }
+ ]
+ });
+
+
+ await cpHelpers.toggleFavoriteMenu(list);
+ await cpHelpers.toggleMenuItem(list, "My second favorite");
+
+ list.destroy();
+ });
+
+ QUnit.test('many2one field rendering', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="m2o"/></tree>',
+ });
+
+ assert.ok(list.$('td:contains(Value 1)').length,
+ "should have the display_name of the many2one");
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view, with 1 open group', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
+ groupBy: ['foo'],
+ });
+
+ await testUtils.dom.click(list.$('th.o_group_name:nth(1)'));
+ await testUtils.nextTick();
+ assert.containsN(list, 'tbody:eq(1) tr', 2, "open group should contain 2 records");
+ assert.containsN(list, 'tbody', 3, "should contain 3 tbody");
+ assert.containsOnce(list, 'td:contains(9)', "should contain 9");
+ assert.containsOnce(list, 'td:contains(-4)', "should contain -4");
+ assert.containsOnce(list, 'td:contains(10)', "should contain 10");
+ assert.containsOnce(list, 'tr.o_group_header td:contains(10)', "but 10 should be in a header");
+ list.destroy();
+ });
+
+ QUnit.test('opening records when clicking on record', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, "open_record", function () {
+ assert.ok("list view should trigger 'open_record' event");
+ });
+
+ await testUtils.dom.click(list.$('tr td:not(.o_list_record_selector)').first());
+ list.update({groupBy: ['foo']});
+ await testUtils.nextTick();
+
+ assert.containsN(list, 'tr.o_group_header', 3, "list should be grouped");
+ await testUtils.dom.click(list.$('th.o_group_name').first());
+
+ testUtils.dom.click(list.$('tr:not(.o_group_header) td:not(.o_list_record_selector)').first());
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: readonly fields cannot be edited', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.foo.readonly = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '</tree>',
+ });
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ var $second_td = list.$('td:not(.o_list_record_selector)').eq(1);
+ var $third_td = list.$('td:not(.o_list_record_selector)').eq(2);
+ await testUtils.dom.click($td);
+ assert.hasClass($td.parent(),'o_selected_row',
+ "row should be in edit mode");
+ assert.hasClass($td,'o_readonly_modifier',
+ "foo cell should be readonly in edit mode");
+ assert.doesNotHaveClass($second_td, 'o_readonly_modifier',
+ "bar cell should be editable");
+ assert.hasClass($third_td,'o_readonly_modifier',
+ "int_field cell should be readonly in edit mode");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: line with no active element', async function (assert) {
+ assert.expect(3);
+
+ this.data.bar = {
+ fields: {
+ titi: {string: "Char", type: "char"},
+ grosminet: {string: "Bool", type: "boolean"},
+ },
+ records: [
+ {id: 1, titi: 'cui', grosminet: true},
+ {id: 2, titi: 'cuicui', grosminet: false},
+ ],
+ };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>'+
+ '<field name="o2m">'+
+ '<tree editable="top">'+
+ '<field name="titi" readonly="1"/>'+
+ '<field name="grosminet" widget="boolean_toggle"/>'+
+ '</tree>'+
+ '</field>'+
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {
+ o2m: [[1, 1, {grosminet: false}], [4, 2, false]],
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var $td = form.$('.o_data_cell').first();
+ var $td2 = form.$('.o_data_cell').eq(1);
+ assert.hasClass($td, 'o_readonly_modifier');
+ assert.hasClass($td2, 'o_boolean_toggle_cell');
+ await testUtils.dom.click($td);
+ await testUtils.dom.click($td2.find('.o_boolean_toggle input'));
+ await testUtils.nextTick();
+
+ await testUtils.form.clickSave(form);
+ await testUtils.nextTick();
+ form.destroy();
+ });
+
+ QUnit.test('editable list view: click on last element after creation empty new line', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar = {
+ fields: {
+ titi: {string: "Char", type: "char", required: true},
+ int_field: {string: "int_field", type: "integer", sortable: true, required: true}
+ },
+ records: [
+ {id: 1, titi: 'cui', int_field: 2},
+ {id: 2, titi: 'cuicui', int_field: 4},
+ ],
+ };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>'+
+ '<field name="o2m">'+
+ '<tree editable="top">'+
+ '<field name="int_field" widget="handle"/>'+
+ '<field name="titi"/>'+
+ '</tree>'+
+ '</field>'+
+ '</form>',
+ });
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ await testUtils.dom.click(form.$('.o_data_row:last() > td.o_list_char'));
+ // This test ensure that they aren't traceback when clicking on the last row.
+ assert.containsN(form, '.o_data_row', 2, "list should have exactly 2 rows");
+ form.destroy();
+ });
+
+ QUnit.test('edit field in editable field without editing the row', async function (assert) {
+ // some widgets are editable in readonly (e.g. priority, boolean_toggle...) and they
+ // thus don't require the row to be switched in edition to be edited
+ assert.expect(13);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="foo"/>
+ <field name="bar" widget="boolean_toggle"/>
+ </tree>`,
+ mockRPC(route, args) {
+ if (args.method === 'write') {
+ assert.step('write: ' + args.args[1].bar);
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // toggle the boolean value of the first row without editing the row
+ assert.ok(list.$('.o_data_row:first .o_boolean_toggle input')[0].checked);
+ assert.containsNone(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_data_row:first .o_boolean_toggle'));
+ assert.notOk(list.$('.o_data_row:first .o_boolean_toggle input')[0].checked);
+ assert.containsNone(list, '.o_selected_row');
+ assert.verifySteps(['write: false']);
+
+ // toggle the boolean value after switching the row in edition
+ assert.containsNone(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_data_row .o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row');
+ await testUtils.dom.click(list.$('.o_selected_row .o_boolean_toggle'));
+ assert.containsOnce(list, '.o_selected_row');
+ assert.verifySteps([]);
+
+ // save
+ await testUtils.dom.click(list.$('.o_list_button_save'));
+ assert.containsNone(list, '.o_selected_row');
+ assert.verifySteps(['write: true']);
+
+ list.destroy();
+ });
+
+ QUnit.test('basic operations for editable list renderer', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ assert.doesNotHaveClass($td.parent(), 'o_selected_row', "td should not be in edit mode");
+ await testUtils.dom.click($td);
+ assert.hasClass($td.parent(),'o_selected_row', "td should be in edit mode");
+ list.destroy();
+ });
+
+ QUnit.test('editable list: add a line and discard', async function (assert) {
+ assert.expect(11);
+
+ testUtils.mock.patch(basicFields.FieldChar, {
+ destroy: function () {
+ assert.step('destroy');
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ domain: [['foo', '=', 'yop']],
+ });
+
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should contain 4 rows");
+ assert.containsOnce(list, '.o_data_row',
+ "list should contain one record (and thus 3 empty rows)");
+
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-1',
+ "pager should be correct");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should still contain 4 rows");
+ assert.containsN(list, '.o_data_row', 2,
+ "list should contain two record (and thus 2 empty rows)");
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2',
+ "pager should be correct");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ assert.containsN(list, 'tbody tr', 4,
+ "list should still contain 4 rows");
+ assert.containsOnce(list, '.o_data_row',
+ "list should contain one record (and thus 3 empty rows)");
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-1',
+ "pager should be correct");
+ assert.verifySteps(['destroy'],
+ "should have destroyed the widget of the removed line");
+
+ testUtils.mock.unpatch(basicFields.FieldChar);
+ list.destroy();
+ });
+
+ QUnit.test('field changes are triggered correctly', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+
+ var n = 0;
+ testUtils.mock.intercept(list, "field_changed", function () {
+ n += 1;
+ });
+ await testUtils.dom.click($td);
+ await testUtils.fields.editInput($td.find('input'), 'abc');
+ assert.strictEqual(n, 1, "field_changed should have been triggered");
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').eq(2));
+ assert.strictEqual(n, 1, "field_changed should not have been triggered");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: basic char field edition', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ var $td = list.$('td:not(.o_list_record_selector)').first();
+ await testUtils.dom.click($td);
+ await testUtils.fields.editInput($td.find('input'), 'abc');
+ assert.strictEqual($td.find('input').val(), 'abc', "char field has been edited correctly");
+
+ var $next_row_td = list.$('tbody tr:eq(1) td:not(.o_list_record_selector)').first();
+ await testUtils.dom.click($next_row_td);
+ assert.strictEqual(list.$('td:not(.o_list_record_selector)').first().text(), 'abc',
+ 'changes should be saved correctly');
+ assert.doesNotHaveClass(list.$('tbody tr').first(), 'o_selected_row',
+ 'saved row should be in readonly mode');
+ assert.strictEqual(this.data.foo.records[0].foo, 'abc',
+ "the edition should have been properly saved");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: save data when list sorting in edit mode', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1], {foo: 'xyz'}],
+ "should correctly save the edited record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('input[name="foo"]'), 'xyz');
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should still be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.doesNotHaveClass(list.$buttons, 'o-editing',
+ "list buttons should be back to their readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: check that controlpanel buttons are updating when groupby applied', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.foo = {string: "Foo", type: "char", required:true};
+
+ var actionManager = await createActionManager({
+ actions: [{
+ id: 11,
+ name: 'Partners Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ }],
+ archs: {
+ 'foo,3,list': '<tree editable="top"><field name="display_name"/><field name="foo"/></tree>',
+
+ 'foo,9,search': '<search>'+
+ '<filter string="candle" name="itsName" context="{\'group_by\': \'foo\'}"/>' +
+ '</search>',
+ },
+ data: this.data,
+ });
+
+ await actionManager.doAction(11);
+ await testUtils.dom.click(actionManager.$('.o_list_button_add'));
+
+ assert.isNotVisible(actionManager.$('.o_list_button_add'),
+ "create button should be invisible");
+ assert.isVisible(actionManager.$('.o_list_button_save'), "save button should be visible");
+
+ await testUtils.dom.click(actionManager.$('.o_dropdown_toggler_btn:contains("Group By")'));
+ await testUtils.dom.click(actionManager.$('.o_group_by_menu .o_menu_item a:contains("candle")'));
+
+ assert.isNotVisible(actionManager.$('.o_list_button_add'), "create button should be invisible");
+ assert.isNotVisible(actionManager.$('.o_list_button_save'),
+ "save button should be invisible after applying groupby");
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('list view not groupable', async function (assert) {
+ assert.expect(2);
+
+ const searchMenuTypesOriginal = ListView.prototype.searchMenuTypes;
+ ListView.prototype.searchMenuTypes = ['filter', 'favorite'];
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="display_name"/>
+ <field name="foo"/>
+ </tree>
+ `,
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <filter context="{'group_by': 'foo'}" name="foo"/>
+ </search>
+ `,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ throw new Error("Should not do a read_group RPC");
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: { search_default_foo: 1, },
+ });
+
+ assert.containsNone(list, '.o_control_panel div.o_search_options div.o_group_by_menu',
+ "there should not be groupby menu");
+ assert.deepEqual(cpHelpers.getFacetTexts(list), []);
+
+ list.destroy();
+ ListView.prototype.searchMenuTypes = searchMenuTypesOriginal;
+ });
+
+ QUnit.test('selection changes are triggered correctly', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+ var $tbody_selector = list.$('tbody .o_list_record_selector input').first();
+ var $thead_selector = list.$('thead .o_list_record_selector input');
+
+ var n = 0;
+ testUtils.mock.intercept(list, "selection_changed", function () {
+ n += 1;
+ });
+
+ // tbody checkbox click
+ testUtils.dom.click($tbody_selector);
+ assert.strictEqual(n, 1, "selection_changed should have been triggered");
+ assert.ok($tbody_selector.is(':checked'), "selection checkbox should be checked");
+ testUtils.dom.click($tbody_selector);
+ assert.strictEqual(n, 2, "selection_changed should have been triggered");
+ assert.ok(!$tbody_selector.is(':checked'), "selection checkbox shouldn't be checked");
+
+ // head checkbox click
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(n, 3, "selection_changed should have been triggered");
+ assert.containsN(list, 'tbody .o_list_record_selector input:checked',
+ list.$('tbody tr').length, "all selection checkboxes should be checked");
+
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(n, 4, "selection_changed should have been triggered");
+
+ assert.containsNone(list, 'tbody .o_list_record_selector input:checked',
+ "no selection checkbox should be checked");
+ list.destroy();
+ });
+
+ QUnit.test('Row selection checkbox can be toggled by clicking on the cell', async function (assert) {
+ assert.expect(9);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, "selection_changed", function (ev) {
+ assert.step(ev.data.selection.length.toString());
+ });
+
+ testUtils.dom.click(list.$('tbody .o_list_record_selector:first'));
+ assert.containsOnce(list, 'tbody .o_list_record_selector input:checked');
+ testUtils.dom.click(list.$('tbody .o_list_record_selector:first'));
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ testUtils.dom.click(list.$('thead .o_list_record_selector'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+ testUtils.dom.click(list.$('thead .o_list_record_selector'));
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ assert.verifySteps(['1', '0', '4', '0']);
+
+ list.destroy();
+ });
+
+ QUnit.test('head selector is toggled by the other selectors', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ data: this.data,
+ groupBy: ['bar'],
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('.o_group_header:first()'));
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsN(list, 'tbody .o_list_record_selector input:checked',
+ 3, "All visible checkboxes should be checked");
+
+ await testUtils.dom.click(list.$('.o_group_header:last()'));
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector input:last()'));
+
+ assert.ok(list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be checked");
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:first() input'));
+
+ assert.ok(!list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be unchecked");
+
+ await testUtils.dom.click(list.$('.o_group_header:first()'));
+
+ assert.ok(list.$('thead .o_list_record_selector input')[0].checked,
+ "Head selector should be checked");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is properly displayed (single page)', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected');
+
+ // select all records of first page
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '4 selected');
+
+ // unselect a record
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '3 selected');
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is properly displayed (multi pages)', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsNone(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), '1 selected');
+
+ // select all records of first page
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.containsOnce(list.$('.o_list_selection_box'), '.o_list_select_domain');
+ assert.strictEqual(list.$('.o_list_selection_box').text().replace(/\s+/g, ' ').trim(),
+ '3 selected Select all 4');
+
+ // select all domain
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('.o_list_selection_box').text().trim(), 'All 4 selected');
+
+ list.destroy();
+ });
+
+ QUnit.test('selection box is removed after multi record edition', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "there should be 4 records");
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should not be displayed");
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should be displayed");
+ assert.containsN(list, '.o_data_row .o_list_record_selector input:checked', 4,
+ "all 4 records should be selected");
+
+ // edit selected records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'legion');
+ await testUtils.dom.click($('.modal-dialog button.btn-primary'));
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box',
+ "list selection box should not be displayed");
+ assert.containsNone(list, '.o_data_row .o_list_record_selector input:checked',
+ "no records should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is reset on reload', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32',
+ "total should be 32 (no record selected)");
+
+ // select first record
+ var $firstRowSelector = list.$('tbody .o_list_record_selector input').first();
+ testUtils.dom.click($firstRowSelector);
+ assert.ok($firstRowSelector.is(':checked'), "first row should be selected");
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '10',
+ "total should be 10 (first record selected)");
+
+ // reload
+ await list.reload();
+ $firstRowSelector = list.$('tbody .o_list_record_selector input').first();
+ assert.notOk($firstRowSelector.is(':checked'),
+ "first row should no longer be selected");
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '32',
+ "total should be 32 (no more record selected)");
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is kept on render without reload', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // open blip grouping and check all lines
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ await testUtils.dom.click(list.$('.o_data_row:first input'));
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // open yop grouping and verify blip are still checked
+ await testUtils.dom.click(list.$('.o_group_header:contains("yop (1)")'));
+ assert.containsOnce(list, '.o_data_row input:checked',
+ "opening a grouping does not uncheck others");
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ // close and open blip grouping and verify blip are unchecked
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ await testUtils.dom.click(list.$('.o_group_header:contains("blip (2)")'));
+ assert.containsNone(list, '.o_data_row input:checked',
+ "opening and closing a grouping uncheck its elements");
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsNone(list.$('.o_cp_buttons'), '.o_list_selection_box');
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are computed correctly', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" sum="Sum"/></tree>',
+ });
+ var $tbody_selectors = list.$('tbody .o_list_record_selector input');
+ var $thead_selector = list.$('thead .o_list_record_selector input');
+
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32", "total should be 32");
+
+ testUtils.dom.click($tbody_selectors.first());
+ testUtils.dom.click($tbody_selectors.last());
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "6",
+ "total should be 6 as first and last records are selected");
+
+ testUtils.dom.click($thead_selector);
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "32",
+ "total should be 32 as all records are selected");
+
+ // Let's update the view to dislay NO records
+ await list.update({domain: ['&', ['bar', '=', false], ['int_field', '>', 0]]});
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), "0", "total should have been recomputed to 0");
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are computed correctly in grouped lists', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['m2o'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
+ });
+
+ var $groupHeader1 = list.$('.o_group_header').filter(function (index, el) {
+ return $(el).data('group').res_id === 1;
+ });
+ var $groupHeader2 = list.$('.o_group_header').filter(function (index, el) {
+ return $(el).data('group').res_id === 2;
+ });
+ assert.strictEqual($groupHeader1.find('td:last()').text(), "23", "first group total should be 23");
+ assert.strictEqual($groupHeader2.find('td:last()').text(), "9", "second group total should be 9");
+ assert.strictEqual(list.$('tfoot td:last()').text(), "32", "total should be 32");
+
+ await testUtils.dom.click($groupHeader1);
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector input').first());
+ assert.strictEqual(list.$('tfoot td:last()').text(), "10",
+ "total should be 10 as first record of first group is selected");
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are updated when a line is edited', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="int_field" sum="Sum"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td[title="Sum"]').text(), "32", "current total should be 32");
+
+ await testUtils.dom.click(list.$('tr.o_data_row td.o_data_cell').first());
+ await testUtils.fields.editInput(list.$('td.o_data_cell input'), "15");
+
+ assert.strictEqual(list.$('td[title="Sum"]').text(), "37",
+ "current total should now be 37");
+ list.destroy();
+ });
+
+ QUnit.test('aggregates are formatted according to field widget', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="qux" widget="float_time" sum="Sum"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tfoot td:nth(2)').text(), '19:24',
+ "total should be formatted as a float_time");
+
+ list.destroy();
+ });
+
+ QUnit.test('aggregates digits can be set with digits field attribute', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="amount" widget="monetary" sum="Sum" digits="[69,3]"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_row td:nth(1)').text(), '1200.00',
+ "field should still be formatted based on currency");
+ assert.strictEqual(list.$('tfoot td:nth(1)').text(), '2000.000',
+ "aggregates monetary use digits attribute if available");
+
+ list.destroy();
+ });
+
+ QUnit.test('groups can be sorted on aggregates', async function (assert) {
+ assert.expect(10);
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.kwargs.orderby || 'default order');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '10517',
+ "initial order should be 10, 5, 17");
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should be 32");
+
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should still be 32");
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '51017',
+ "order should be 5, 10, 17");
+
+ await testUtils.dom.click(list.$('.o_column_sortable'));
+ assert.strictEqual(list.$('tbody .o_list_number').text(), '17105',
+ "initial order should be 17, 10, 5");
+ assert.strictEqual(list.$('tfoot td:last()').text(), '32', "total should still be 32");
+
+ assert.verifySteps(['default order', 'int_field ASC', 'int_field DESC']);
+
+ list.destroy();
+ });
+
+ QUnit.test('groups cannot be sorted on non-aggregable fields', async function (assert) {
+ assert.expect(6);
+ this.data.foo.fields.sort_field = {string: "sortable_field", type: "sting", sortable: true, default: "value"};
+ _.each(this.data.records, function (elem) {
+ elem.sort_field = "value" + elem.id;
+ });
+ this.data.foo.fields.foo.sortable = true;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch: '<tree editable="bottom"><field name="foo" /><field name="int_field"/><field name="sort_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.kwargs.orderby || 'default order');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ //we cannot sort by sort_field since it doesn't have a group_operator
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(2)'));
+ //we can sort by int_field since it has a group_operator
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(1)'));
+ //we keep previous order
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(2)'));
+ //we can sort on foo since we are groupped by foo + previous order
+ await testUtils.dom.click(list.$('.o_column_sortable:eq(0)'));
+
+ assert.verifySteps([
+ 'default order',
+ 'default order',
+ 'int_field ASC',
+ 'int_field ASC',
+ 'foo ASC, int_field ASC'
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('properly apply onchange in simple case', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length + 1000;
+ },
+ };
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ var $foo_td = list.$('td:not(.o_list_record_selector)').first();
+ var $int_field_td = list.$('td:not(.o_list_record_selector)').eq(1);
+
+ assert.strictEqual($int_field_td.text(), '10', "should contain initial value");
+
+ await testUtils.dom.click($foo_td);
+ await testUtils.fields.editInput($foo_td.find('input'), 'tralala');
+
+ assert.strictEqual($int_field_td.find('input').val(), "1007",
+ "should contain input with onchange applied");
+ list.destroy();
+ });
+
+ QUnit.test('column width should not change when switching mode', async function (assert) {
+ assert.expect(4);
+
+ // Warning: this test is css dependant
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="m2o"/>' +
+ '<field name="m2m" widget="many2many_tags"/>' +
+ '</tree>',
+ });
+
+ var startWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var startWidth = list.$('table').addBack('table').width();
+
+ // start edition of first row
+ await testUtils.dom.click(list.$('td:not(.o_list_record_selector)').first());
+
+ var editionWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var editionWidth = list.$('table').addBack('table').width();
+
+ // leave edition
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ var readonlyWidths = _.pluck(list.$('thead th'), 'offsetWidth');
+ var readonlyWidth = list.$('table').addBack('table').width();
+
+ assert.strictEqual(editionWidth, startWidth,
+ "table should have kept the same width when switching from readonly to edit mode");
+ assert.deepEqual(editionWidths, startWidths,
+ "width of columns should remain unchanged when switching from readonly to edit mode");
+ assert.strictEqual(readonlyWidth, editionWidth,
+ "table should have kept the same width when switching from edit to readonly mode");
+ assert.deepEqual(readonlyWidths, editionWidths,
+ "width of columns should remain unchanged when switching from edit to readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths should depend on the content when there is data', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records[0].foo = 'Some very very long value for a char field';
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '</tree>',
+ viewOptions: {
+ limit: 2,
+ },
+ });
+ var widthPage1 = list.$(`th[data-name=foo]`)[0].offsetWidth;
+
+ await cpHelpers.pagerNext(list);
+
+ var widthPage2 = list.$(`th[data-name=foo]`)[0].offsetWidth;
+ assert.ok(widthPage1 > widthPage2,
+ 'column widths should be computed dynamically according to the content');
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some of the fields should be hardcoded if no data', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(9);
+
+ this.data.foo.records = [];
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" width="25px"/>' +
+ '</tree>',
+ });
+
+ assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some fields should be hardcoded if no data, and list initially invisible', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(12);
+
+ this.data.foo.fields.foo_o2m = {string: "Foo O2M", type: "one2many", relation: "foo"};
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: `<form>
+ <sheet>
+ <notebook>
+ <page string="Page1"></page>
+ <page string="Page2">
+ <field name="foo_o2m">
+ <tree editable="bottom">
+ <field name="bar"/>
+ <field name="foo"/>
+ <field name="int_field"/>
+ <field name="qux"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ <field name="amount"/>
+ <field name="currency_id" width="25px"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ });
+
+ assert.isNotVisible(form.$('.o_field_one2many'));
+
+ await testUtils.dom.click(form.$('.nav-item:last-child .nav-link'));
+
+ assert.isVisible(form.$('.o_field_one2many'));
+
+ assert.containsNone(form, '.o_field_one2many .o_resize',
+ "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(form.$(`.o_field_one2many th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(form.$('.o_field_one2many th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(form.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ 'Currency field should have a fixed width of 25px (see arch)');
+ assert.strictEqual(form.el.querySelector('.o_list_record_remove_header').style.width, '32px');
+
+ form.destroy();
+ });
+
+ QUnit.test('empty editable list with the handle widget and no content help', async function (assert) {
+ assert.expect(4);
+
+ // no records for the foo model
+ this.data.foo.records = [];
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="int_field" widget="handle" />
+ <field name="foo" />
+ </tree>`,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a foo</p>'
+ }
+ },
+ });
+
+ // as help is being provided in the action, table won't be rendered until a record exists
+ assert.containsNone(list, '.o_list_table', " there should not be any records in the view.");
+ assert.containsOnce(list, '.o_view_nocontent', "should have no content help");
+
+ // click on create button
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+ const handleWidgetMinWidth = "33px";
+ const handleWidgetHeader = list.$('thead > tr > th.o_handle_cell');
+ assert.strictEqual(handleWidgetHeader.css('min-width'), handleWidgetMinWidth,
+ "While creating first record, min-width should be applied to handle widget.");
+
+ // creating one record
+ await testUtils.fields.editInput(list.$("tr.o_selected_row input[name='foo']"), 'test_foo');
+ await testUtils.dom.click(list.$('.o_list_button_save'));
+ assert.strictEqual(handleWidgetHeader.css('min-width'), handleWidgetMinWidth,
+ "After creation of the first record, min-width of the handle widget should remain as it is");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: overflowing table', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ // Just want to make sure that the table is overflowed
+ `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec est massa, gravida eget dapibus ac, eleifend eget libero.
+ Suspendisse feugiat sed massa eleifend vestibulum. Sed tincidunt
+ velit sed lacinia lacinia. Nunc in fermentum nunc. Vestibulum ante
+ ipsum primis in faucibus orci luctus et ultrices posuere cubilia
+ Curae; Nullam ut nisi a est ornare molestie non vulputate orci.
+ Nunc pharetra porta semper. Mauris dictum eu nulla a pulvinar. Duis
+ eleifend odio id ligula congue sollicitudin. Curabitur quis aliquet
+ nunc, ut aliquet enim. Suspendisse malesuada felis non metus
+ efficitur aliquet.`,
+ },
+ ],
+ };
+ const list = await createView({
+ arch: `
+ <tree editable="top">
+ <field name="titi"/>
+ <field name="grosminet" widget="char"/>
+ </tree>`,
+ data: this.data,
+ model: 'bar',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.$('table').width(), list.$('.o_list_view').width(),
+ "Table should not be stretched by its content");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: overflowing table (3 columns)', async function (assert) {
+ assert.expect(4);
+
+ const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec est massa, gravida eget dapibus ac, eleifend eget libero.
+ Suspendisse feugiat sed massa eleifend vestibulum. Sed tincidunt
+ velit sed lacinia lacinia. Nunc in fermentum nunc. Vestibulum ante
+ ipsum primis in faucibus orci luctus et ultrices posuere cubilia
+ Curae; Nullam ut nisi a est ornare molestie non vulputate orci.
+ Nunc pharetra porta semper. Mauris dictum eu nulla a pulvinar. Duis
+ eleifend odio id ligula congue sollicitudin. Curabitur quis aliquet
+ nunc, ut aliquet enim. Suspendisse malesuada felis non metus
+ efficitur aliquet.`;
+
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet1: { string: "Beeg char 1", type: "char", sortable: true },
+ grosminet2: { string: "Beeg char 2", type: "char", sortable: true },
+ grosminet3: { string: "Beeg char 3", type: "char", sortable: true },
+ },
+ records: [{
+ id: 1,
+ titi: "Tiny text",
+ grosminet1: longText,
+ grosminet2: longText + longText,
+ grosminet3: longText + longText + longText,
+ }],
+ };
+ const list = await createView({
+ arch: `
+ <tree editable="top">
+ <field name="titi"/>
+ <field name="grosminet1" class="large"/>
+ <field name="grosminet3" class="large"/>
+ <field name="grosminet2" class="large"/>
+ </tree>`,
+ data: this.data,
+ model: 'bar',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.$('table').width(), list.$('.o_list_view').width());
+ const largeCells = list.$('.o_data_cell.large');
+ assert.strictEqual(largeCells[0].offsetWidth, largeCells[1].offsetWidth);
+ assert.strictEqual(largeCells[1].offsetWidth, largeCells[2].offsetWidth);
+ assert.ok(list.$('.o_data_cell:not(.large)')[0].offsetWidth < largeCells[0].offsetWidth);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: list view in an initially unselected notebook page', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records = [{ id: 1, o2m: [1] }];
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Ut at nisi congue, facilisis neque nec, pulvinar nunc. ' +
+ 'Vivamus ac lectus velit.',
+ },
+ ],
+ };
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Page1"></page>' +
+ '<page string="Page2">' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="titi"/>' +
+ '<field name="grosminet"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [titi, grosminet] = form.el.querySelectorAll('.tab-pane:last-child th');
+ const one2many = form.el.querySelector('.o_field_one2many');
+
+ assert.isNotVisible(one2many,
+ "One2many field should be hidden");
+ assert.strictEqual(titi.style.width, "",
+ "width of small char should not be set yet");
+ assert.strictEqual(grosminet.style.width, "",
+ "width of large char should also not be set");
+
+ await testUtils.dom.click(form.el.querySelector('.nav-item:last-child .nav-link'));
+
+ assert.isVisible(one2many,
+ "One2many field should be visible");
+ assert.ok(
+ titi.style.width.split('px')[0] > 80 &&
+ grosminet.style.width.split('px')[0] > 700,
+ "list has been correctly frozen after being visible");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: list view hidden by an invisible modifier', async function (assert) {
+ assert.expect(5);
+
+ this.data.foo.records = [{ id: 1, bar: true, o2m: [1] }];
+ this.data.bar = {
+ fields: {
+ titi: { string: "Small char", type: "char", sortable: true },
+ grosminet: { string: "Beeg char", type: "char", sortable: true },
+ },
+ records: [
+ {
+ id: 1,
+ titi: "Tiny text",
+ grosminet:
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Ut at nisi congue, facilisis neque nec, pulvinar nunc. ' +
+ 'Vivamus ac lectus velit.',
+ },
+ ],
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<field name="o2m" attrs="{\'invisible\': [(\'bar\', \'=\', True)]}">' +
+ '<tree editable="bottom">' +
+ '<field name="titi"/>' +
+ '<field name="grosminet"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [titi, grosminet] = form.el.querySelectorAll('th');
+ const one2many = form.el.querySelector('.o_field_one2many');
+
+ assert.isNotVisible(one2many,
+ "One2many field should be hidden");
+ assert.strictEqual(titi.style.width, "",
+ "width of small char should not be set yet");
+ assert.strictEqual(grosminet.style.width, "",
+ "width of large char should also not be set");
+
+ await testUtils.dom.click(form.el.querySelector('.o_field_boolean input'));
+
+ assert.isVisible(one2many,
+ "One2many field should be visible");
+ assert.ok(
+ titi.style.width.split('px')[0] > 80 &&
+ grosminet.style.width.split('px')[0] > 700,
+ "list has been correctly frozen after being visible");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: updating list state while invisible', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ bar: function (obj) {
+ obj.o2m = [[5], [0, null, { display_name: "Whatever" }]];
+ },
+ };
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="bar"/>' +
+ '<notebook>' +
+ '<page string="Page 1"></page>' +
+ '<page string="Page 2">' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ await testUtils.dom.click(form.$('.o_field_boolean input'));
+
+ assert.strictEqual(form.el.querySelector('th').style.width, "",
+ "Column header should be initially unfrozen");
+
+ await testUtils.dom.click(form.$('.nav-item:last() .nav-link'));
+
+ assert.notEqual(form.el.querySelector('th').style.width, "",
+ "Column header should have been frozen");
+
+ form.destroy();
+ });
+
+ QUnit.test('empty list: state with nameless and stringless buttons', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button string="choucroute"/>
+ <button icon="fa-heart"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.el.querySelector('th[data-name="foo"]').style.width, '50%',
+ "Field column should be frozen");
+ assert.strictEqual(list.el.querySelector('th:last-child').style.width, '50%',
+ "Buttons column should be frozen");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: unnamed columns cannot be resized', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [{ id: 1, o2m: [1] }];
+ this.data.bar.records = [{ id: 1, display_name: "Oui" }];
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<button name="the_button" icon="fa-heart"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ const [charTh, buttonTh] = form.$('.o_field_one2many th');
+ const thRect = charTh.getBoundingClientRect();
+ const resizeRect = charTh.getElementsByClassName('o_resize')[0].getBoundingClientRect();
+
+ assert.strictEqual(thRect.x + thRect.width, resizeRect.x + resizeRect.width,
+ "First resize handle should be attached at the end of the first header");
+ assert.containsNone(buttonTh, '.o_resize',
+ "Columns without name should not have a resize handle");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list view, click on m2o dropdown do not close editable row', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('.o_selected_row .o_data_cell .o_field_many2one input'));
+ const $dropdown = list.$('.o_selected_row .o_data_cell .o_field_many2one input').autocomplete('widget');
+ await testUtils.dom.click($dropdown);
+ assert.containsOnce(list, '.o_selected_row', "should still have editable row");
+
+ await testUtils.dom.click($dropdown.find("li:first"));
+ assert.containsOnce(list, '.o_selected_row', "should still have editable row");
+
+ list.destroy();
+ });
+
+ QUnit.test('width of some of the fields should be hardcoded if no data (grouped case)', async function (assert) {
+ const assertions = [
+ { field: 'bar', expected: 70, type: 'Boolean' },
+ { field: 'int_field', expected: 74, type: 'Integer' },
+ { field: 'qux', expected: 92, type: 'Float' },
+ { field: 'date', expected: 92, type: 'Date' },
+ { field: 'datetime', expected: 146, type: 'Datetime' },
+ { field: 'amount', expected: 104, type: 'Monetary' },
+ ];
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" width="25px"/>' +
+ '</tree>',
+ groupBy: ['int_field'],
+ });
+
+ assert.containsNone(list, '.o_resize', "There shouldn't be any resize handle if no data");
+ assertions.forEach(a => {
+ assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
+ `Field ${a.type} should have a fixed width of ${a.expected} pixels`);
+ });
+ assert.strictEqual(list.$('th[data-name="foo"]')[0].style.width, '100%',
+ "Char field should occupy the remaining space");
+ assert.strictEqual(list.$('th[data-name="currency_id"]')[0].offsetWidth, 25,
+ "Currency field should have a fixed width of 25px (see arch)");
+
+ list.destroy();
+ });
+
+ QUnit.test('column width should depend on the widget', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records = []; // the width heuristic only applies when there are no records
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="datetime" widget="date"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, 92,
+ "should be the optimal width to display a date, not a datetime");
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when adding first record', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = []; // in this scenario, we start with no records
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsOnce(list, '.o_data_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when editing a record', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.containsOnce(list, '.o_selected_row');
+
+ var longVal = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed blandit, ' +
+ 'justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ipsum purus ' +
+ 'bibendum est.';
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=text]'), longVal);
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when switching records in edition', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="m2o"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ const width = list.$('th[data-name="m2o"]')[0].offsetWidth;
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(list.$('th[data-name="m2o"]')[0].offsetWidth, width);
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(list.$('th[data-name="m2o"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('column widths are re-computed on window resize', async function (assert) {
+ assert.expect(2);
+
+ testUtils.mock.patch(ListRenderer, {
+ RESIZE_DELAY: 0,
+ });
+
+ this.data.foo.records[0].text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+ 'Sed blandit, justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ' +
+ 'ipsum purus bibendum est.';
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom">
+ <field name="datetime"/>
+ <field name="text"/>
+ </tree>`,
+ });
+
+ const initialTextWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ const selectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth;
+
+ // simulate a window resize
+ list.$el.width(`${list.$el.width() / 2}px`);
+ core.bus.trigger('resize');
+ await testUtils.nextTick();
+
+ const postResizeTextWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ const postResizeSelectorWidth = list.$('th.o_list_record_selector')[0].offsetWidth;
+ assert.ok(postResizeTextWidth < initialTextWidth);
+ assert.strictEqual(selectorWidth, postResizeSelectorWidth);
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('columns with an absolute width are never narrower than that width', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' +
+ 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim ' +
+ 'veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' +
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum ' +
+ 'dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, ' +
+ 'sunt in culpa qui officia deserunt mollit anim id est laborum';
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="datetime"/>' +
+ '<field name="int_field" width="200px"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, 146);
+ assert.strictEqual(list.$('th[data-name="int_field"]')[0].offsetWidth, 200);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with data: text columns are not crushed', async function (assert) {
+ assert.expect(2);
+
+ const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do ' +
+ 'eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim ' +
+ 'veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' +
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum ' +
+ 'dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, ' +
+ 'sunt in culpa qui officia deserunt mollit anim id est laborum';
+ this.data.foo.records[0].foo = longText;
+ this.data.foo.records[0].text = longText;
+ this.data.foo.records[1].foo = "short text";
+ this.data.foo.records[1].text = "short text";
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="text"/></tree>',
+ });
+
+ const fooWidth = list.$('th[data-name="foo"]')[0].offsetWidth;
+ const textWidth = list.$('th[data-name="text"]')[0].offsetWidth;
+ assert.strictEqual(fooWidth, textWidth, "both columns should have been given the same width");
+
+ const firstRowHeight = list.$('.o_data_row:nth(0)')[0].offsetHeight;
+ const secondRowHeight = list.$('.o_data_row:nth(1)')[0].offsetHeight;
+ assert.ok(firstRowHeight > secondRowHeight,
+ "in the first row, the (long) text field should be properly displayed on several lines");
+
+ list.destroy();
+ });
+
+ QUnit.test("button in a list view with a default relative width", async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="the_button" icon="fa-heart" width="0.1"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.strictEqual(list.el.querySelector('.o_data_cell button').style.width, "",
+ "width attribute should not change the CSS style");
+
+ list.destroy();
+ });
+
+ QUnit.test("button columns in a list view don't have a max width", async function (assert) {
+ assert.expect(2);
+
+ testUtils.mock.patch(ListRenderer, {
+ RESIZE_DELAY: 0,
+ });
+
+ // set a long foo value s.t. the column can be squeezed
+ this.data.foo.records[0].foo = 'Lorem ipsum dolor sit amet';
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="foo"/>
+ <button name="b1" string="Do This"/>
+ <button name="b2" string="Do That"/>
+ <button name="b3" string="Or Rather Do Something Else"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // simulate a window resize (buttons column width should not be squeezed)
+ list.$el.width('300px');
+ core.bus.trigger('resize');
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('th:nth(1)').css('max-width'), '92px',
+ "max-width should be set on column foo to the minimum column width (92px)");
+ assert.strictEqual(list.$('th:nth(2)').css('max-width'), '100%',
+ "no max-width should be harcoded on the buttons column");
+
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('column widths are kept when editing multiple records', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="datetime"/>' +
+ '<field name="text"/>' +
+ '</tree>',
+ });
+
+ var width = list.$('th[data-name="datetime"]')[0].offsetWidth;
+
+ // select two records and edit
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+
+ assert.containsOnce(list, '.o_selected_row');
+ var longVal = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed blandit, ' +
+ 'justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus ipsum purus ' +
+ 'bibendum est.';
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=text]'), longVal);
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('th[data-name="datetime"]')[0].offsetWidth, width);
+
+ list.destroy();
+ });
+
+ QUnit.test('row height and width should not change when switching mode', async function (assert) {
+ // Warning: this test is css dependant
+ assert.expect(5);
+
+ var multiLang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+
+ this.data.foo.fields.foo.translate = true;
+ this.data.foo.fields.boolean = {type: 'boolean', string: 'Bool'};
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="boolean"/>' +
+ '<field name="date"/>' +
+ '<field name="text"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '<field name="m2o"/>' +
+ '<field name="m2m" widget="many2many_tags"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ // the width is hardcoded to make sure we have the same condition
+ // between debug mode and non debug mode
+ list.$el.width('1200px');
+ var startHeight = list.$('.o_data_row:first').outerHeight();
+ var startWidth = list.$('.o_data_row:first').outerWidth();
+
+ // start edition of first row
+ await testUtils.dom.click(list.$('.o_data_row:first > td:not(.o_list_record_selector)').first());
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ var editionHeight = list.$('.o_data_row:first').outerHeight();
+ var editionWidth = list.$('.o_data_row:first').outerWidth();
+
+ // leave edition
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ var readonlyHeight = list.$('.o_data_row:first').outerHeight();
+ var readonlyWidth = list.$('.o_data_row:first').outerWidth();
+
+ assert.strictEqual(startHeight, editionHeight);
+ assert.strictEqual(startHeight, readonlyHeight);
+ assert.strictEqual(startWidth, editionWidth);
+ assert.strictEqual(startWidth, readonlyWidth);
+
+ _t.database.multi_lang = multiLang;
+ list.destroy();
+ });
+
+ QUnit.test('fields are translatable in list view', async function (assert) {
+ assert.expect(3);
+
+ var multiLang = _t.database.multi_lang;
+ _t.database.multi_lang = true;
+ this.data.foo.fields.foo.translate = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
+ return Promise.resolve({
+ domain: [],
+ context: {search_default_name: 'foo,foo'},
+ });
+ }
+ if (route === "/web/dataset/call_kw/res.lang/get_installed") {
+ return Promise.resolve([["en_US","English"], ["fr_BE", "Frenglish"]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ arch: '<tree editable="top">' +
+ '<field name="foo" required="1"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first > td:not(.o_list_record_selector)').first());
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('input.o_field_translate+span.o_field_translate'));
+ await testUtils.nextTick();
+
+ assert.containsOnce($('body'), '.o_translation_dialog');
+ assert.containsN($('.o_translation_dialog'), '.translation>input.o_field_char', 2,
+ 'modal should have 2 languages to translate');
+
+ _t.database.multi_lang = multiLang;
+ list.destroy();
+ });
+
+ QUnit.test('long words in text cells should break into smaller lines', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records[0].text = "a";
+ this.data.foo.records[1].text = "pneumonoultramicroscopicsilicovolcanoconiosis"; // longest english word I could find
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="text"/></tree>',
+ });
+
+ // Intentionally set the table width to a small size
+ list.$('table').width('100px');
+ list.$('th:last').width('100px');
+ var shortText = list.$('.o_data_row:eq(0) td:last')[0].clientHeight;
+ var longText = list.$('.o_data_row:eq(1) td:last')[0].clientHeight;
+ var emptyText = list.$('.o_data_row:eq(2) td:last')[0].clientHeight;
+
+ assert.strictEqual(shortText, emptyText,
+ "Short word should not change the height of the cell");
+ assert.ok(longText > emptyText,
+ "Long word should change the height of the cell");
+
+ list.destroy();
+ });
+
+ QUnit.test('deleting one record', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ assert.hasClass($('body'),'modal-open', 'body should have modal-open clsss');
+
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.containsN(list, 'tbody td.o_list_record_selector', 3, "should have 3 records");
+ list.destroy();
+ });
+
+ QUnit.test('delete all records matching the domain', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ throw new Error('should not display a notification');
+ },
+ }),
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('delete all records matching the domain (limit reached)', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+ this.data.foo.records.push({id: 6, bar: true, foo: "yyy"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ assert.step('notify');
+ },
+ }),
+ },
+ session: {
+ active_ids_limit: 4,
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps(['notify']);
+
+ list.destroy();
+ });
+
+ QUnit.test('archiving one record', async function (assert) {
+ assert.expect(12);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ viewOptions: {hasActionMenus: true},
+ arch: '<tree><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ this.data.foo.records[0].active = false;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-secondary'));
+ assert.containsN(list, 'tbody td.o_list_record_selector', 4, "still should have 4 records");
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+ assert.containsN(list, 'tbody td.o_list_record_selector', 3, "should have 3 records");
+ assert.verifySteps(['/web/dataset/call_kw/foo/action_archive', '/web/dataset/search_read']);
+ list.destroy();
+ });
+
+ QUnit.test('archive all records matching the domain', async function (assert) {
+ assert.expect(6);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'action_archive') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ throw new Error('should not display a notification');
+ },
+ }),
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('archive all records matching the domain (limit reached)', async function (assert) {
+ assert.expect(8);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = {string: 'Active', type: 'boolean', default: true};
+ this.data.foo.records.push({id: 5, bar: true, foo: "xxx"});
+ this.data.foo.records.push({id: 6, bar: true, foo: "yyy"});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ domain: [['bar', '=', true]],
+ mockRPC: function (route, args) {
+ if (args.method === 'action_archive') {
+ assert.deepEqual(args.args[0], [1, 2, 3, 5]);
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: {
+ notification: NotificationService.extend({
+ notify: function () {
+ assert.step('notify');
+ },
+ }),
+ },
+ session: {
+ active_ids_limit: 4,
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, 'tbody td.o_list_record_selector', 2, "should have 2 records");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsOnce(list, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Archive");
+
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps(['notify']);
+
+ list.destroy();
+ });
+
+ QUnit.test('archive/unarchive handles returned action', async function (assert) {
+ assert.expect(6);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = { string: 'Active', type: 'boolean', default: true };
+
+ const actionManager = await createActionManager({
+ data: this.data,
+ actions: [{
+ id: 11,
+ name: 'Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ }],
+ archs: {
+ 'foo,3,list': '<tree><field name="foo"/></tree>',
+ 'foo,9,search': `
+ <search>
+ <filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
+ </search>`,
+ 'bar,false,form': '<form><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ return Promise.resolve({
+ 'type': 'ir.actions.act_window',
+ 'name': 'Archive Action',
+ 'res_model': 'bar',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'views': [[false, 'form']]
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ do_action: function (ev) {
+ actionManager.doAction(ev.data.action, {});
+ },
+ },
+ });
+
+ await actionManager.doAction(11);
+
+ assert.containsNone(actionManager, '.o_cp_action_menus', 'sidebar should be invisible');
+ assert.containsN(actionManager, 'tbody td.o_list_record_selector', 4, "should have 4 records");
+
+ await testUtils.dom.click(actionManager.$('tbody td.o_list_record_selector:first input'));
+
+ assert.containsOnce(actionManager, '.o_cp_action_menus', 'sidebar should be visible');
+
+ await testUtils.dom.click(actionManager.$('.o_cp_action_menus .o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(actionManager.$('.o_cp_action_menus a:contains(Archive)'));
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+ assert.strictEqual($('.modal').length, 2, 'a confirm modal should be displayed');
+ assert.strictEqual($('.modal:eq(1) .modal-title').text().trim(), 'Archive Action',
+ "action wizard should have been opened");
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('pager (ungrouped and grouped mode), default limit', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 80, "default limit should be 80 in List");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_pager');
+ assert.strictEqual(cpHelpers.getPagerSize(list), "4", "pager's size should be 4");
+ await list.update({ groupBy: ['bar']});
+ assert.strictEqual(cpHelpers.getPagerSize(list), "2", "pager's size should be 2");
+ list.destroy();
+ });
+
+ QUnit.test('can sort records when clicking on header', async function (assert) {
+ assert.expect(9);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ var nbSearchRead = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ nbSearchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(yop)').length,
+ "record 1 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(blip)').length,
+ "record 3 should be first");
+
+ nbSearchRead = 0;
+ await testUtils.dom.click(list.$('thead th:contains(Foo)'));
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ nbSearchRead = 0;
+ await testUtils.dom.click(list.$('thead th:contains(Foo)'));
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.ok(list.$('tbody tr:first td:contains(yop)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(blip)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('do not sort records when clicking on header with nolabel', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.foo.sortable = true;
+
+ let nbSearchRead = 0;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" nolabel="1"/><field name="int_field"/></tree>',
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ nbSearchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(nbSearchRead, 1, "should have done one search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "yop10blip9gnap17blip-4");
+
+ await testUtils.dom.click(list.$('thead th[data-name="int_field"]'));
+ assert.strictEqual(nbSearchRead, 2, "should have done one other search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "blip-4blip9yop10gnap17");
+
+ await testUtils.dom.click(list.$('thead th[data-name="foo"]'));
+ assert.strictEqual(nbSearchRead, 2, "shouldn't have done anymore search_read");
+ assert.strictEqual(list.$('.o_data_cell').text(), "blip-4blip9yop10gnap17");
+
+ list.destroy();
+ });
+
+ QUnit.test('use default_order', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree default_order="foo"><field name="foo"/><field name="bar"/></tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.sort, 'foo ASC',
+ "should correctly set the sort attribute");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('use more complex default_order', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree default_order="foo, bar desc, int_field">' +
+ '<field name="foo"/><field name="bar"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.strictEqual(args.sort, 'foo ASC, bar DESC, int_field ASC',
+ "should correctly set the sort attribute");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(list.$('tbody tr:first td:contains(blip)').length,
+ "record 3 should be first");
+ assert.ok(list.$('tbody tr:eq(3) td:contains(yop)').length,
+ "record 1 should be first");
+
+ list.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on save', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records[0].o2m = [1, 3];
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="display_name">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput($o2m.find('.o_field_widget'), "Value 2");
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ await testUtils.form.clickSave(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 2)').length,
+ "Value 2 should be second (should be sorted after saving)");
+ assert.ok(form.$('tbody tr:eq(2) td:contains(Value 3)').length,
+ "Value 3 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand', async function (assert) {
+ assert.expect(11);
+
+ this.data.foo.records[0].o2m = [1, 3];
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+ this.data.bar.records[0].name = "Value 1";
+ this.data.bar.records[2].name = "Value 3";
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="name">' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await testUtils.form.clickEdit(form);
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+ await testUtils.fields.editInput($o2m.find('.o_field_widget'), "Value 2");
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ await testUtils.dom.click(form.$('.o_form_sheet_bg'));
+
+ await testUtils.dom.click($o2m.find('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 1',
+ "Value 1 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
+ "Value 2 should be second (should be sorted after saving)");
+ assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 3',
+ "Value 3 should be third");
+
+ await testUtils.dom.click($o2m.find('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 3',
+ "Value 3 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
+ "Value 2 should be second (should be sorted after saving)");
+ assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 1',
+ "Value 1 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand in page', async function (assert) {
+ assert.expect(4);
+
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+
+ var ids = [];
+ for (var i=0; i<45; i++) {
+ var id = 4 + i;
+ ids.push(id);
+ this.data.bar.records.push({
+ id: id,
+ name: "Value " + (id < 10 ? '0' : '') + id,
+ });
+ }
+ this.data.foo.records[0].o2m = ids;
+
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" default_order="name">' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ await cpHelpers.pagerNext('.o_field_widget[name=o2m]');
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 44',
+ "record 44 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 48',
+ "record 48 should be last");
+
+ await testUtils.dom.click(form.$('.o_column_sortable'));
+ assert.strictEqual(form.$('tbody tr:first').text(), 'Value 08',
+ "record 48 should be first");
+ assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 04',
+ "record 44 should be first");
+
+ form.destroy();
+ });
+
+ QUnit.test('can display button in edit mode', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<button name="notafield" type="object" icon="fa-asterisk" class="o_yeah"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, 'tbody button[name=notafield]', 4);
+ assert.containsN(list, 'tbody button[name=notafield].o_yeah', 4, "class o_yeah should be set on the four button");
+ list.destroy();
+ });
+
+ QUnit.test('can display a list with a many2many field', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="m2m"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(route);
+ return this._super(route, args);
+ },
+ });
+ assert.verifySteps(['/web/dataset/search_read'], "should have done 1 search_read");
+ assert.ok(list.$('td:contains(3 records)').length,
+ "should have a td with correct formatted value");
+ list.destroy();
+ });
+
+ QUnit.test('list with group_by_no_leaf flag in context', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ context: {
+ group_by_no_leaf: true,
+ }
+ });
+
+ assert.containsNone(list, '.o_list_buttons', "should not have any buttons");
+ list.destroy();
+ });
+
+ QUnit.test('display a tooltip on a field', async function (assert) {
+ assert.expect(4);
+
+ var initialDebugMode = odoo.debug;
+ odoo.debug = false;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar" widget="toggle_button"/>' +
+ '</tree>',
+ });
+
+ // this is done to force the tooltip to show immediately instead of waiting
+ // 1000 ms. not totally academic, but a short test suite is easier to sell :(
+ list.$('th[data-name=foo]').tooltip('show', false);
+
+ list.$('th[data-name=foo]').trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 0, "should not have rendered a tooltip");
+
+ odoo.debug = true;
+ // it is necessary to rerender the list so tooltips can be properly created
+ await list.reload();
+ list.$('th[data-name=foo]').tooltip('show', false);
+ list.$('th[data-name=foo]').trigger($.Event('mouseenter'));
+ assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1, "should have rendered a tooltip");
+
+ await list.reload();
+ list.$('th[data-name=bar]').tooltip('show', false);
+ list.$('th[data-name=bar]').trigger($.Event('mouseenter'));
+ assert.containsOnce($, '.oe_tooltip_technical>li[data-item="widget"]',
+ 'widget should be present for this field');
+ assert.strictEqual($('.oe_tooltip_technical>li[data-item="widget"]')[0].lastChild.wholeText.trim(),
+ 'Button (toggle_button)', "widget description should be correct");
+
+ odoo.debug = initialDebugMode;
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree decoration-info="int_field > 5">' +
+ '<field name="foo"/><field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody tr.text-info', 3,
+ "should have 3 columns with text-info class");
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration (with unset numeric values)', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" decoration-danger="int_field &lt; 0">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsNone(list, 'tr.o_data_row.text-danger',
+ "the data row should not have .text-danger decoration (int_field is unset)");
+ await testUtils.fields.editInput(list.$('input[name="int_field"]'), '-3');
+ assert.containsOnce(list, 'tr.o_data_row.text-danger',
+ "the data row should have .text-danger decoration (int_field is negative)");
+ list.destroy();
+ });
+
+ QUnit.test('support row decoration with date', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.records[0].datetime = '2017-02-27 12:51:35';
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree decoration-info="datetime == \'2017-02-27 12:51:35\'" decoration-danger="datetime &gt; \'2017-02-27 12:51:35\' AND datetime &lt; \'2017-02-27 10:51:35\'">' +
+ '<field name="datetime"/><field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsOnce(list, 'tbody tr.text-info',
+ "should have 1 columns with text-info class with good datetime");
+
+ assert.containsNone(list, 'tbody tr.text-danger',
+ "should have 0 columns with text-danger class with wrong timezone datetime");
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ list.destroy();
+ });
+
+ QUnit.test('support field decoration', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="foo" decoration-danger="int_field > 5"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ assert.containsN(list, 'tbody tr', 4, "should have 4 rows");
+ assert.containsN(list, 'tbody td.o_list_char.text-danger', 3);
+ assert.containsNone(list, 'tbody td.o_list_number.text-danger');
+
+ list.destroy();
+ });
+
+ QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a record</p>'
+ }
+ },
+ });
+
+ assert.containsNone(list, '.o_view_nocontent');
+ await testUtils.dom.click(list.$('.o_list_view'));
+ assert.doesNotHaveClass(list.$('.o_list_button_add'), 'o_catch_attention');
+
+ await list.reload({ domain: [['id', '<', 0]] });
+ assert.containsOnce(list, '.o_view_nocontent');
+ await testUtils.dom.click(list.$('.o_view_nocontent'));
+ assert.hasClass(list.$('.o_list_button_add'), 'o_catch_attention');
+ list.destroy();
+ });
+
+ QUnit.test('no content helper when no data', async function (assert) {
+ assert.expect(5);
+
+ var records = this.data.foo.records;
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.containsOnce(list, '.o_view_nocontent',
+ "should display the no content helper");
+
+ assert.containsNone(list, 'table', "should not have a table in the dom");
+
+ assert.strictEqual(list.$('.o_view_nocontent p.hello:contains(add a partner)').length, 1,
+ "should have rendered no content helper from action");
+
+ this.data.foo.records = records;
+ await list.reload();
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+ list.destroy();
+ });
+
+ QUnit.test('no nocontent helper when no data and no help', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ });
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+
+ assert.containsNone(list, 'tr.o_data_row',
+ "should not have any data row");
+
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data", async function (assert) {
+ assert.expect(19);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ <field name="m2o"/>
+ <field name="m2m" widget="many2many_tags"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ </tree>`,
+ domain: [['id', '<', 0]], // such that no record matches the domain
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // Check list sample data
+ const firstRow = list.el.querySelector('.o_data_row');
+ const cells = firstRow.querySelectorAll(':scope > .o_data_cell');
+ assert.strictEqual(cells[0].innerText.trim(), "",
+ "Char field should yield an empty element"
+ );
+ assert.containsOnce(cells[1], '.custom-checkbox',
+ "Boolean field has been instantiated"
+ );
+ assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number");
+ assert.ok(cells[3].innerText.trim(), "Many2one field is a string");
+
+ const firstM2MTag = cells[4].querySelector(
+ ':scope span.o_badge_text'
+ ).innerText.trim();
+ assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag");
+
+ assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()),
+ "Date field should have the right format"
+ );
+ assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()),
+ "Datetime field should have the right format"
+ );
+
+ const textContent = list.$el.text();
+ await list.reload();
+ assert.strictEqual(textContent, list.$el.text(),
+ 'The content should be the same after reloading the view without change'
+ );
+
+ // reload with another domain -> should no longer display the sample records
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // reload with another domain matching records
+ await list.reload({ domain: Domain.TRUE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help .hello');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data: toggle optional field", async function (assert) {
+ assert.expect(9);
+
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="m2o" optional="hide"/>
+ </tree>`,
+ domain: Domain.FALSE_DOMAIN,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.ok(list.$('.o_data_row').length > 0);
+ assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
+ assert.containsN(list, 'th', 2, "should have 2 th, 1 for selector and 1 for foo");
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle');
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.ok(list.$('.o_data_row').length > 0);
+ assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled');
+ assert.containsN(list, 'th', 3);
+
+ list.destroy();
+ });
+
+ QUnit.test("empty list with sample data: keyboard navigation", async function (assert) {
+ assert.expect(11);
+
+ const list = await createView({
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Check keynav is disabled
+ assert.hasClass(
+ list.el.querySelector('.o_data_row'),
+ 'o_sample_data_disabled'
+ );
+ assert.hasClass(
+ list.el.querySelector('.o_list_table > tfoot'),
+ 'o_sample_data_disabled'
+ );
+ assert.hasClass(
+ list.el.querySelector('.o_list_table > thead .o_list_record_selector'),
+ 'o_sample_data_disabled'
+ );
+ assert.containsNone(list.renderer, 'input:not([tabindex="-1"])');
+
+ // From search bar
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.hasClass(document.activeElement, 'o_searchview_input');
+
+ // From 'Create' button
+ document.querySelector('.btn.o_list_button_add').focus();
+
+ assert.hasClass(document.activeElement, 'o_list_button_add');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.hasClass(document.activeElement, 'o_list_button_add');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'tab');
+
+ assert.containsNone(document.body, '.oe_tooltip_string');
+
+ // From column header
+ list.el.querySelector(':scope th[data-name="foo"]').focus();
+
+ assert.ok(document.activeElement.dataset.name === 'foo');
+
+ await testUtils.fields.triggerKeydown(document.activeElement, 'down');
+
+ assert.ok(document.activeElement.dataset.name === 'foo');
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty list with sample data", async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.TRUE_DOMAIN,
+ });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ // reload with another domain matching no record (should not display the sample records)
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ list.destroy();
+ });
+
+ QUnit.test('click on header in empty list with sample data', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.FALSE_DOMAIN,
+ });
+
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+
+ const content = list.$el.text();
+ await testUtils.dom.click(list.$('tr:first .o_column_sortable:first'));
+ assert.strictEqual(list.$el.text(), content, "the content should still be the same");
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty editable list with sample data: delete all records", async function (assert) {
+ assert.expect(7);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.TRUE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: all records displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete all records
+ await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: no more sample data, but nocontent helper displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) {
+ assert.expect(10);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Discard temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard'));
+
+ // Final state: table should be displayed with no data at all
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: create and delete record", async function (assert) {
+ assert.expect(13);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Save temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsOnce(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete newly created record
+ await testUtils.dom.click(list.el.querySelector('.o_data_row input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: there should be no table, but the no content helper
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+ list.destroy();
+ });
+
+ QUnit.test('Do not display nocontent when it is an empty html tag', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello"></p>'
+ }
+ },
+ });
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not display the no content helper");
+
+ assert.containsOnce(list, 'table', "should have a table in the dom");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button', async function (assert) {
+ assert.expect(14);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="object" name="button_method"/>' +
+ '</groupby>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (ev) {
+ assert.deepEqual(ev.data.env.currentID, 2,
+ 'should call with correct id');
+ assert.strictEqual(ev.data.env.model, 'res_currency',
+ 'should call with correct model');
+ assert.strictEqual(ev.data.action_data.name, 'button_method',
+ "should call correct method");
+ assert.strictEqual(ev.data.action_data.type, 'object',
+ 'should have correct type');
+ ev.data.on_success();
+ },
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, 'thead th:not(.o_list_record_selector)',
+ "there should be only one column");
+
+ await list.update({groupBy: ['currency_id']});
+
+ assert.verifySteps(['web_read_group']);
+ assert.containsN(list, '.o_group_header', 2,
+ "there should be 2 group headers");
+ assert.containsNone(list, '.o_group_header button', 0,
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, '.o_group_header button');
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button in inner groupbys', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="object" name="button_method"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['bar', 'currency_id'],
+ });
+
+ assert.containsN(list, '.o_group_header', 2,
+ "there should be 2 group headers");
+ assert.containsNone(list, '.o_group_header button',
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+
+ assert.containsN(list, 'tbody:eq(1) .o_group_header', 2,
+ "there should be 2 inner groups header");
+ assert.containsNone(list, 'tbody:eq(1) .o_group_header button',
+ "there should be no button in the header");
+
+ await testUtils.dom.click(list.$('tbody:eq(1) .o_group_header:eq(0)'));
+
+ assert.containsOnce(list, '.o_group_header button',
+ "there should be one button in the header");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button with modifiers', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'read' && args.model === 'res_currency') {
+ assert.deepEqual(args.args, [[2, 1], ['position']]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['currency_id'],
+ });
+
+ assert.verifySteps(['web_read_group', 'read']);
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(0)'));
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsOnce(list, '.o_group_header button.o_invisible_modifier',
+ "the first group (EUR) should have an invisible button");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ assert.containsN(list, '.o_group_header button', 2,
+ "there should be two buttons (one by header)");
+ assert.doesNotHaveClass(list, '.o_group_header:eq(1) button', 'o_invisible_modifier',
+ "the second header button should be visible");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with a button with modifiers using a many2one', async function (assert) {
+ assert.expect(5);
+
+ this.data.res_currency.fields.m2o = {string: "Currency M2O", type: "many2one", relation: "bar"};
+ this.data.res_currency.records[0].m2o = 1;
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree expand="1">
+ <field name="foo"/>
+ <groupby name="currency_id">
+ <field name="m2o"/>
+ <button string="Button 1" type="object" name="button_method" attrs='{"invisible": [("m2o", "=", false)]}'/>
+ </groupby>
+ </tree>`,
+ mockRPC(route, args) {
+ assert.step(args.method);
+ return this._super(...arguments);
+ },
+ groupBy: ['currency_id'],
+ });
+
+ assert.containsOnce(list, '.o_group_header:eq(0) button.o_invisible_modifier');
+ assert.containsOnce(list, '.o_group_header:eq(1) button:not(.o_invisible_modifier)');
+
+ assert.verifySteps(['web_read_group', 'read']);
+
+ list.destroy();
+ });
+
+ QUnit.test('reload list view with groupby node', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ });
+
+ assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
+ "there should be one visible button");
+
+ await list.reload({ domain: [] });
+ assert.containsOnce(list, '.o_group_header button:not(.o_invisible_modifier)',
+ "there should still be one visible button");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view with groupby node and modifiers', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1" editable="bottom">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<field name="position"/>' +
+ '<button string="Button 1" type="object" name="button_method" attrs=\'{"invisible": [("position", "=", "after")]}\'/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ });
+
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "first row should be in readonly mode");
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the row should be in edit mode");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the row should be back in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with edit button', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1">' +
+ '<field name="foo"/>' +
+ '<groupby name="currency_id">' +
+ '<button string="Button 1" type="edit" name="edit"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['currency_id'],
+ intercepts: {
+ do_action: function (event) {
+ assert.deepEqual(event.data.action, {
+ context: {create: false},
+ res_id: 2,
+ res_model: 'res_currency',
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ flags: {mode: 'edit'},
+ }, "should trigger do_action with correct action parameter");
+ }
+ },
+ });
+ await testUtils.dom.click(list.$('.o_group_header:eq(0) button'));
+ list.destroy();
+ });
+
+ QUnit.test('groupby node with subfields, and onchange', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.onchanges = {
+ foo: function () {},
+ };
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree editable="bottom" expand="1">
+ <field name="foo"/>
+ <field name="currency_id"/>
+ <groupby name="currency_id">
+ <field name="position" invisible="1"/>
+ </groupby>
+ </tree>`,
+ groupBy: ['currency_id'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[3], {
+ foo: "1",
+ currency_id: "",
+ }, 'onchange spec should not follow relation of many2one fields');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "new value");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, without data', async function (assert) {
+ assert.expect(12);
+
+ this.data.foo.records = [];
+
+ this.data.foo.fields.date.default = "2017-02-10";
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="date"/>' +
+ '<field name="m2o"/>' +
+ '<field name="foo"/>' +
+ '<button type="object" icon="fa-plus-square" name="method"/>' +
+ '</tree>',
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.ok(true, "should have created a record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(list, '.o_view_nocontent',
+ "should have a no content helper displayed");
+
+ assert.containsNone(list, 'div.table-responsive',
+ "should not have a div.table-responsive");
+ assert.containsNone(list, 'table', "should not have rendered a table");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsNone(list, '.o_view_nocontent',
+ "should not have a no content helper displayed");
+ assert.containsOnce(list, 'table', "should have rendered a table");
+
+ assert.hasClass(list.$('tbody tr:eq(0)'), 'o_selected_row',
+ "the date field td should be in edit mode");
+ assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "",
+ "the date field td should not have any content");
+
+ assert.strictEqual(list.$('tr.o_selected_row .o_list_record_selector input').prop('disabled'), true,
+ "record selector checkbox should be disabled while the record is not yet created");
+ assert.strictEqual(list.$('.o_list_button button').prop('disabled'), true,
+ "buttons should be disabled while the record is not yet created");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(list.$('tbody tr:eq(0) .o_list_record_selector input').prop('disabled'), false,
+ "record selector checkbox should not be disabled once the record is created");
+ assert.strictEqual(list.$('.o_list_button button').prop('disabled'), false,
+ "buttons should not be disabled once the record is created");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, with a button', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records = [];
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '<button string="abc" icon="fa-phone" type="object" name="schedule_another_phonecall"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ assert.containsOnce(list, 'table button.o_icon_button i.fa-phone',
+ "should have rendered a button");
+ list.destroy();
+ });
+
+ QUnit.test('list view with a button without icon', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '<button string="abc" type="object" name="schedule_another_phonecall"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('table button').first().text(), 'abc',
+ "should have rendered a button with string attribute as label");
+ list.destroy();
+ });
+
+ QUnit.test('list view, editable, can discard', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 0, "no input should be in the table");
+
+ await testUtils.dom.click(list.$('tbody td:not(.o_list_record_selector):first'));
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 1, "first cell should be editable");
+
+ assert.ok(list.$buttons.find('.o_list_button_discard').is(':visible'),
+ "discard button should be visible");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ assert.strictEqual(list.$('td:not(.o_list_record_selector) input').length, 0, "no input should be in the table");
+
+ assert.ok(!list.$buttons.find('.o_list_button_discard').is(':visible'),
+ "discard button should not be visible");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view, click on the list to save', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.date.default = "2017-02-10";
+ this.data.foo.records = [];
+
+ var createCount = 0;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Phonecalls" editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ createCount++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('.o_list_view'));
+
+ assert.strictEqual(createCount, 1, "should have created a record");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('tfoot'));
+
+ assert.strictEqual(createCount, 2, "should have created a record");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.dom.click(list.$('tbody tr').last());
+
+ assert.strictEqual(createCount, 3, "should have created a record");
+ list.destroy();
+ });
+
+ QUnit.test('click on a button in a list view', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<button string="a button" name="button_action" icon="fa-car" type="object"/>' +
+ '</tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (event) {
+ assert.deepEqual(event.data.env.currentID, 1,
+ 'should call with correct id');
+ assert.strictEqual(event.data.env.model, 'foo',
+ 'should call with correct model');
+ assert.strictEqual(event.data.action_data.name, 'button_action',
+ "should call correct method");
+ assert.strictEqual(event.data.action_data.type, 'object',
+ 'should have correct type');
+ event.data.on_closed();
+ },
+ },
+ });
+
+ assert.containsN(list, 'tbody .o_list_button', 4,
+ "there should be one button per row");
+ assert.containsOnce(list, 'tbody .o_list_button:first .o_icon_button .fa.fa-car',
+ 'buttons should have correct icon');
+
+ await testUtils.dom.click(list.$('tbody .o_list_button:first > button'));
+ assert.verifySteps(['/web/dataset/search_read', '/web/dataset/search_read'],
+ "should have reloaded the view (after the action is complete)");
+ list.destroy();
+ });
+
+ QUnit.test('invisible attrs in readonly and editable list', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<button string="a button" name="button_action" icon="fa-car" ' +
+ 'type="object" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="foo" attrs="{\'invisible\': [(\'id\',\'=\', 1)]}"/>' +
+ '</tree>',
+ });
+
+ assert.equal(list.$('tbody tr:nth(0) td:nth(4)').html(), "",
+ "td that contains an invisible field should be empty");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
+
+ // edit first row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2)'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(4) input.o_invisible_modifier').length, 1,
+ "td that contains an invisible field should not be empty in edition");
+ assert.hasClass(list.$('tbody tr:nth(0) td:nth(1) button'), "o_invisible_modifier",
+ "button with invisible attrs should be properly hidden");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+
+ // click on the invisible field's cell to edit first row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(4)'));
+ assert.hasClass(list.$('tbody tr:nth(0)'),'o_selected_row',
+ "first row should be in edition");
+ list.destroy();
+ });
+
+ QUnit.test('monetary fields are properly rendered', async function (assert) {
+ assert.expect(3);
+
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="id"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ assert.containsN(list, 'tbody tr:first td', 3,
+ "currency_id column should not be in the table");
+ assert.strictEqual(list.$('tbody tr:first td:nth(2)').text().replace(/\s/g, ' '),
+ '1200.00 €', "currency_id column should not be in the table");
+ assert.strictEqual(list.$('tbody tr:nth(1) td:nth(2)').text().replace(/\s/g, ' '),
+ '$ 500.00', "currency_id column should not be in the table");
+
+ list.destroy();
+ });
+
+ QUnit.test('simple list with date and datetime', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="date"/><field name="datetime"/></tree>',
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ });
+
+ assert.strictEqual(list.$('td:eq(1)').text(), "01/25/2017",
+ "should have formatted the date");
+ assert.strictEqual(list.$('td:eq(2)').text(), "12/12/2016 12:55:05",
+ "should have formatted the datetime");
+ list.destroy();
+ });
+
+ QUnit.test('edit a row by clicking on a readonly field', async function (assert) {
+ assert.expect(9);
+
+ this.data.foo.fields.foo.readonly = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ assert.hasClass(list.$('.o_data_row:first td:nth(1)'),'o_readonly_modifier',
+ "foo field cells should have class 'o_readonly_modifier'");
+
+ // edit the first row
+ await testUtils.dom.click(list.$('.o_data_row:first td:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be selected");
+ var $cell = list.$('.o_data_row:first td:nth(1)');
+ // review
+ assert.hasClass($cell, 'o_readonly_modifier');
+ assert.hasClass($cell.parent(),'o_selected_row');
+ assert.strictEqual(list.$('.o_data_row:first td:nth(1) span').text(), 'yop',
+ "a widget should have been rendered for readonly fields");
+ assert.hasClass(list.$('.o_data_row:first td:nth(2)').parent(),'o_selected_row',
+ "field 'int_field' should be in edition");
+ assert.strictEqual(list.$('.o_data_row:first td:nth(2) input').length, 1,
+ "a widget for field 'int_field should have been rendered'");
+
+ // click again on readonly cell of first line: nothing should have changed
+ await testUtils.dom.click(list.$('.o_data_row:first td:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be selected");
+ assert.strictEqual(list.$('.o_data_row:first td:nth(2) input').length, 1,
+ "a widget for field 'int_field' should have been rendered (only once)");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with nested groups', async function (assert) {
+ assert.expect(42);
+
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 2});
+
+ var nbRPCs = {readGroup: 0, searchRead: 0};
+ var envIDs = []; // the ids that should be in the environment during this test
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/><field name="int_field"/></tree>',
+ groupBy: ['m2o', 'foo'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ if (args.kwargs.groupby[0] === 'foo') { // nested read_group
+ // called twice (once when opening the group, once when sorting)
+ assert.deepEqual(args.kwargs.domain, [['m2o', '=', 1]],
+ "nested read_group should be called with correct domain");
+ }
+ nbRPCs.readGroup++;
+ } else if (route === '/web/dataset/search_read') {
+ // called twice (once when opening the group, once when sorting)
+ assert.deepEqual(args.domain, [['foo', '=', 'blip'], ['m2o', '=', 1]],
+ "nested search_read should be called with correct domain");
+ nbRPCs.searchRead++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 4,
+ "'switch_view' event has been triggered");
+ },
+ },
+ });
+
+ assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ // basic rendering tests
+ assert.containsOnce(list, 'tbody', "there should be 1 tbody");
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+ assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)',
+ "group should have correct name and count");
+ assert.containsN(list, '.o_group_name .fa-caret-right', 2,
+ "the carret of closed groups should be right");
+ assert.strictEqual(list.$('.o_group_name:first span').css('padding-left'),
+ '2px', "groups of level 1 should have a 2px padding-left");
+ assert.strictEqual(list.$('.o_group_header:first td:last').text(), '16',
+ "group aggregates are correctly displayed");
+
+ // open the first group
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.strictEqual(nbRPCs.readGroup, 1, "should have done one read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ var $openGroup = list.$('tbody:nth(1)');
+ assert.strictEqual(list.$('.o_group_name:first').text(), 'Value 1 (4)',
+ "group should have correct name and count (of records, not inner subgroups)");
+ assert.containsN(list, 'tbody', 3, "there should be 3 tbodys");
+ assert.containsOnce(list, '.o_group_name:first .fa-caret-down',
+ "the carret of open groups should be down");
+ assert.strictEqual($openGroup.find('.o_group_header').length, 3,
+ "open group should contain 3 groups");
+ assert.strictEqual($openGroup.find('.o_group_name:nth(2)').text(), 'blip (2)',
+ "group should have correct name and count");
+ assert.strictEqual($openGroup.find('.o_group_name:nth(2) span').css('padding-left'),
+ '22px', "groups of level 2 should have a 22px padding-left");
+ assert.strictEqual($openGroup.find('.o_group_header:nth(2) td:last').text(), '-11',
+ "inner group aggregates are correctly displayed");
+
+ // open subgroup
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [4, 5]; // the opened subgroup contains these two records
+ await testUtils.dom.click($openGroup.find('.o_group_header:nth(2)'));
+ assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
+ assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ var $openSubGroup = list.$('tbody:nth(2)');
+ assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
+ assert.strictEqual($openSubGroup.find('.o_data_row').length, 2,
+ "open subgroup should contain 2 data rows");
+ assert.strictEqual($openSubGroup.find('.o_data_row:first td:last').text(), '-4',
+ "first record in open subgroup should be res_id 4 (with int_field -4)");
+
+ // open a record (should trigger event 'open_record')
+ await testUtils.dom.click($openSubGroup.find('.o_data_row:first'));
+
+ // sort by int_field (ASC) and check that open groups are still open
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [5, 4]; // order of the records changed
+ await testUtils.dom.click(list.$('thead th:last'));
+ assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups");
+ assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ $openSubGroup = list.$('tbody:nth(2)');
+ assert.containsN(list, 'tbody', 4, "there should be 4 tbodys");
+ assert.strictEqual($openSubGroup.find('.o_data_row').length, 2,
+ "open subgroup should contain 2 data rows");
+ assert.strictEqual($openSubGroup.find('.o_data_row:first td:last').text(), '-7',
+ "first record in open subgroup should be res_id 5 (with int_field -7)");
+
+ // close first level group
+ nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = []; // the group being closed, there is no more record in the environment
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
+ assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
+ assert.deepEqual(list.exportState().resIds, envIDs);
+
+ assert.containsOnce(list, 'tbody', "there should be 1 tbody");
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+ assert.containsN(list, '.o_group_name .fa-caret-right', 2,
+ "the carret of closed groups should be right");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list on selection field at level 2', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/><field name="int_field"/></tree>',
+ groupBy: ['m2o', 'priority'],
+ });
+
+ assert.containsN(list, '.o_group_header', 2,
+ "should contain 2 groups at first level");
+
+ // open the first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ var $openGroup = list.$('tbody:nth(1)');
+ assert.strictEqual($openGroup.find('tr').length, 3,
+ "should have 3 subgroups");
+ assert.strictEqual($openGroup.find('tr').length, 3,
+ "should have 3 subgroups");
+ assert.strictEqual($openGroup.find('.o_group_name:first').text(), 'Low (3)',
+ "should display the selection name in the group header");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with a pager in a group', async function (assert) {
+ assert.expect(6);
+ this.data.foo.records[3].bar = true;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ viewOptions: {
+ limit: 3,
+ },
+ });
+ var headerHeight = list.$('.o_group_header').css('height');
+
+ // basic rendering checks
+ await testUtils.dom.click(list.$('.o_group_header'));
+ assert.strictEqual(list.$('.o_group_header').css('height'), headerHeight,
+ "height of group header shouldn't have changed");
+ assert.hasClass(list.$('.o_group_header th:eq(1) > nav'), 'o_pager',
+ "last cell of open group header should have classname 'o_pager'");
+
+ assert.strictEqual(cpHelpers.getPagerValue('.o_group_header'), '1-3',
+ "pager's value should be correct");
+ assert.containsN(list, '.o_data_row', 3,
+ "open group should display 3 records");
+
+ // go to next page
+ await cpHelpers.pagerNext('.o_group_header');
+ assert.strictEqual(cpHelpers.getPagerValue('.o_group_header'), '4-4',
+ "pager's value should be correct");
+ assert.containsOnce(list, '.o_data_row',
+ "open group should display 1 record");
+
+ list.destroy();
+ });
+
+ QUnit.test('edition: create new line, then discard', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 0,
+ "create button should be hidden");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 1,
+ "discard button should be visible");
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should still have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible again");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden again");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('invisible attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'invisible\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 3,
+ "there should be 3 invisible foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_invisible_modifier').length, 1,
+ "the foo field widget should have been rendered as invisible");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_invisible_modifier)').length, 1,
+ "the foo field widget should have been marked as non-invisible");
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 2,
+ "the foo field widget parent cell should not be invisible anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_invisible_modifier').length, 1,
+ "the foo field widget should have been marked as invisible again");
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 3,
+ "the foo field widget parent cell should now be invisible again");
+
+ // Reswitch the cell to editable and save the row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ await testUtils.dom.click(list.$('thead'));
+
+ assert.containsN(list, 'tbody td.o_invisible_modifier', 2,
+ "there should be 2 invisible foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('readonly attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'readonly\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 3,
+ "there should be 3 readonly foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rendered as readonly");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as editable");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "the foo field widget parent cell should not be readonly anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > span[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as readonly");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 3,
+ "the foo field widget parent cell should now be readonly again");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]').length, 1,
+ "the foo field widget should have been rerendered as editable again");
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "the foo field widget parent cell should not be readonly again");
+
+ // Click outside to leave edition mode
+ await testUtils.dom.click(list.$el);
+
+ assert.containsN(list, 'tbody td.o_readonly_modifier', 2,
+ "there should be 2 readonly foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('required attrs on fields are re-evaluated on field change', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, 'tbody td.o_required_modifier', 3,
+ "there should be 3 required foo cells in readonly mode");
+
+ // Make first line editable
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(1)'));
+
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_required_modifier').length, 1,
+ "the foo field widget should have been rendered as required");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"]:not(.o_required_modifier)').length, 1,
+ "the foo field widget should have been marked as non-required");
+ assert.containsN(list, 'tbody td.o_required_modifier', 2,
+ "the foo field widget parent cell should not be required anymore");
+
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ assert.strictEqual(list.$('tbody tr:nth(0) td:nth(1) > input[name="foo"].o_required_modifier').length, 1,
+ "the foo field widget should have been marked as required again");
+ assert.containsN(list, 'tbody td.o_required_modifier', 3,
+ "the foo field widget parent cell should now be required again");
+
+ // Reswitch the cell to editable and save the row
+ await testUtils.dom.click(list.$('tbody tr:nth(0) td:nth(2) input'));
+ await testUtils.dom.click(list.$('thead'));
+
+ assert.containsN(list, 'tbody td.o_required_modifier', 2,
+ "there should be 2 required foo cells in readonly mode");
+
+ list.destroy();
+ });
+
+ QUnit.test('leaving unvalid rows in edition', async function (assert) {
+ assert.expect(4);
+
+ var warnings = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ services: {
+ notification: NotificationService.extend({
+ notify: function (params) {
+ if (params.type === 'danger') {
+ warnings++;
+ }
+ }
+ }),
+ },
+ });
+
+ // Start first line edition
+ var $firstFooTd = list.$('tbody tr:nth(0) td:nth(1)');
+ await testUtils.dom.click($firstFooTd);
+
+ // Remove required foo field value
+ await testUtils.fields.editInput($firstFooTd.find('input'), "");
+
+ // Try starting other line edition
+ var $secondFooTd = list.$('tbody tr:nth(1) td:nth(1)');
+ await testUtils.dom.click($secondFooTd);
+ await testUtils.nextTick();
+
+ assert.strictEqual($firstFooTd.parent('.o_selected_row').length, 1,
+ "first line should still be in edition as invalid");
+ assert.containsOnce(list, 'tbody tr.o_selected_row',
+ "no other line should be in edition");
+ assert.strictEqual($firstFooTd.find('input.o_field_invalid').length, 1,
+ "the required field should be marked as invalid");
+ assert.strictEqual(warnings, 1,
+ "a warning should have been displayed");
+
+ list.destroy();
+ });
+
+ QUnit.test('open a virtual id', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ });
+
+ testUtils.mock.intercept(list, 'switch_view', function (event) {
+ assert.deepEqual(_.pick(event.data, 'mode', 'model', 'res_id', 'view_type'), {
+ mode: 'readonly',
+ model: 'event',
+ res_id: '2-20170808020000',
+ view_type: 'form',
+ }, "should trigger a switch_view event to the form view for the record virtual id");
+ });
+ testUtils.dom.click(list.$('td:contains(virtual)'));
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing enter on last line of editable list view', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // click on 3rd line
+ await testUtils.dom.click(list.$('td:contains(gnap)'));
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ // press enter in input
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'enter');
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'),'o_selected_row',
+ "4rd row should be selected");
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row',
+ "3rd row should no longer be selected");
+
+ // press enter on last row
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'enter');
+ assert.containsN(list, 'tr.o_data_row', 5, "should have created a 5th row");
+
+ assert.verifySteps(['/web/dataset/search_read', '/web/dataset/call_kw/foo/onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('pressing tab on last cell of editable list view', async function (assert) {
+ assert.expect(9);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('td:contains(blip)').last());
+ assert.strictEqual(document.activeElement.name, "foo",
+ "focus should be on an input with name = foo");
+
+ //it will not create a new line unless a modification is made
+ document.activeElement.value = "blip-changed";
+ $(document.activeElement).trigger({type: 'change'});
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.strictEqual(document.activeElement.name, "int_field",
+ "focus should be on an input with name = int_field");
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(4)'),'o_selected_row',
+ "5th row should be selected");
+ assert.strictEqual(document.activeElement.name, "foo",
+ "focus should be on an input with name = foo");
+
+ assert.verifySteps(['/web/dataset/search_read',
+ '/web/dataset/call_kw/foo/write',
+ '/web/dataset/call_kw/foo/read',
+ '/web/dataset/call_kw/foo/onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and read completes after default_get', async function (assert) {
+ assert.expect(8);
+
+ var onchangeGetPromise = testUtils.makeTestPromise();
+ var readPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read') {
+ return readPromise.then(function () {
+ return result;
+ });
+ }
+ if (args.method === 'onchange') {
+ return onchangeGetPromise.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ await testUtils.dom.click(list.$('td:contains(-4)').last());
+
+ await testUtils.fields.editInput(list.$('tr.o_selected_row input[name="int_field"]'), '1234');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="int_field"]'), 'tab');
+
+ onchangeGetPromise.resolve();
+ assert.containsN(list, 'tbody tr.o_data_row', 4,
+ "should have 4 data rows");
+
+ readPromise.resolve();
+ await testUtils.nextTick();
+ assert.containsN(list, 'tbody tr.o_data_row', 5,
+ "should have 5 data rows");
+ assert.strictEqual(list.$('td:contains(1234)').length, 1,
+ "should have a cell with new value");
+
+ // we trigger a tab to move to the second cell in the current row. this
+ // operation requires that this.currentRow is properly set in the
+ // list editable renderer.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(4)'),'o_selected_row',
+ "5th row should be selected");
+
+ assert.verifySteps(['write', 'read', 'onchange']);
+ list.destroy();
+ });
+
+ QUnit.test('display toolbar', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ toolbar: {
+ action: [{
+ model_name: 'event',
+ name: 'Action event',
+ type: 'ir.actions.server',
+ usage: 'ir_actions_server',
+ }],
+ print: [],
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await testUtils.dom.click(list.$('.o_list_record_selector:first input'));
+
+ await cpHelpers.toggleActionMenu(list);
+ assert.deepEqual(cpHelpers.getMenuItemTexts(list), ['Delete', 'Action event']);
+
+ list.destroy();
+ });
+
+ QUnit.test('execute ActionMenus actions with correct params (single page)', async function (assert) {
+ assert.expect(12);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ toolbar: {
+ action: [{
+ id: 44,
+ name: 'Custom Action',
+ type: 'ir.actions.server',
+ }],
+ print: [],
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/action/load') {
+ assert.step(JSON.stringify(args));
+ return Promise.resolve({});
+ }
+ return this._super(...arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ assert.containsN(list, '.o_data_row', 4);
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // unselect first record (will unselect the thead checkbox as well)
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:first input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // add a domain and select first two records
+ await list.reload({ domain: [['bar', '=', true]] });
+ assert.containsN(list, '.o_data_row', 3);
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:nth(0) input'));
+ await testUtils.dom.click(list.$('tbody .o_list_record_selector:nth(1) input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 2);
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ assert.verifySteps([
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":2,"active_ids":[2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[["bar","=",true]]}}',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('execute ActionMenus actions with correct params (multi pages)', async function (assert) {
+ assert.expect(13);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ toolbar: {
+ action: [{
+ id: 44,
+ name: 'Custom Action',
+ type: 'ir.actions.server',
+ }],
+ print: [],
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/action/load') {
+ assert.step(JSON.stringify(args));
+ return Promise.resolve({});
+ }
+ return this._super(...arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.containsNone(list.el, 'div.o_control_panel .o_cp_action_menus');
+ assert.containsN(list, '.o_data_row', 2);
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ assert.containsOnce(list, '.o_list_selection_box .o_list_select_domain');
+ assert.containsOnce(list.el, 'div.o_control_panel .o_cp_action_menus');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // select all domain
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ // add a domain
+ await list.reload({ domain: [['bar', '=', true]] });
+ assert.containsNone(list, '.o_list_selection_box .o_list_select_domain');
+
+ // select all domain
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 3);
+ assert.containsNone(list, '.o_list_selection_box .o_list_select_domain');
+
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Custom Action");
+
+ assert.verifySteps([
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3,4],"active_model":"foo","active_domain":[]}}',
+ '{"action_id":44,"context":{"active_id":1,"active_ids":[1,2,3],"active_model":"foo","active_domain":[["bar","=",true]]}}',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('edit list line after line deletion', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:not(.o_list_record_selector)').first());
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third row should be in edition");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.ok(list.$('.o_data_row:nth(0)').is('.o_selected_row'),
+ "first row should be in edition (creation)");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsNone(list, '.o_selected_row',
+ "no row should be selected");
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:not(.o_list_record_selector)').first());
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third row should be in edition");
+ assert.containsOnce(list, '.o_selected_row',
+ "no other row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable list with several fields [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:first .o_data_cell:first input')[0]);
+
+ // // Press 'Tab' -> should go to next cell (still in first row)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:first .o_data_cell:last input')[0]);
+
+ // // Press 'Tab' -> should go to next line (first cell)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="int_field"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell:first input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with several fields [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell:last input')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line (last cell)
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell:first input')[0]);
+
+ // Press 'shift-Tab' -> should go to previous cell
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell:last input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and readonly field (no modification)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we press TAB when the
+ // focus is on the first, then the focus skip the readonly cells and
+ // directly goes to the next line instead.
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)').last());
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'),'o_selected_row',
+ "2nd row should be selected");
+
+ // we do it again. This was broken because the this.currentRow variable
+ // was not properly set, and the second TAB could cause a crash.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab and readonly field (with modification)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we press TAB when the
+ // focus is on the first, then the focus skips the readonly cells and
+ // directly goes to the next line instead.
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+
+ //modity the cell content
+ testUtils.fields.editAndTrigger($(document.activeElement),
+ 'blip-changed', ['change']);
+
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'),'o_selected_row',
+ "2nd row should be selected");
+
+ // we do it again. This was broken because the this.currentRow variable
+ // was not properly set, and the second TAB could cause a crash.
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'),'o_selected_row',
+ "3rd row should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab on a list with create="0"', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "the list should contain 4 rows");
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'),'o_selected_row',
+ "third row should be in edition");
+
+ // Press 'Tab' -> should go to next line
+ // add a value in the cell because the Tab on an empty first cell would activate the next widget in the view
+ await testUtils.fields.editInput(list.$('.o_selected_row input').eq(1), 11);
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="display_name"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'),'o_selected_row',
+ "fourth row should be in edition");
+
+ // Press 'Tab' -> should go back to first line as the create action isn't available
+ await testUtils.fields.editInput(list.$('.o_selected_row input').eq(1), 11);
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row input[name="display_name"]'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'),'o_selected_row',
+ "first row should be in edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation with tab on a one2many list with create="0"', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.records[0].o2m = [1, 2];
+ var form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '<form><sheet>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom" create="0">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="foo"/>' +
+ '</sheet></form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.containsN(form, '.o_field_widget[name=o2m] .o_data_row', 2,
+ "there should be two records in the many2many");
+
+ await testUtils.dom.click(form.$('.o_field_widget[name=o2m] .o_data_cell:first'));
+ assert.hasClass(form.$('.o_field_widget[name=o2m] .o_data_row:first'),'o_selected_row',
+ "first row should be in edition");
+
+ // Press 'Tab' -> should go to next line
+ await testUtils.fields.triggerKeydown(form.$('.o_field_widget[name=o2m] .o_selected_row input'), 'tab');
+ assert.hasClass(form.$('.o_field_widget[name=o2m] .o_data_row:nth(1)'),'o_selected_row',
+ "second row should be in edition");
+
+ // Press 'Tab' -> should get out of the one to many and go to the next field of the form
+ await testUtils.fields.triggerKeydown(form.$('.o_field_widget[name=o2m] .o_selected_row input'), 'tab');
+ // use of owlCompatibilityNextTick because the x2many control panel is updated twice
+ await testUtils.owlCompatibilityNextTick();
+ assert.strictEqual(document.activeElement, form.$('input[name="foo"]')[0],
+ "the next field should be selected");
+
+ form.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly field)', async function (assert) {
+ // This test makes sure that if we have 2 cells in a row, the first in
+ // edit mode, and the second one readonly, then if we edit and press TAB,
+ // (before debounce), the save operation is properly done (before
+ // selecting the next row)
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ });
+
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ await testUtils.fields.editSelect(list.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.strictEqual(list.$('tbody tr:first td:contains(new value)').length, 1,
+ "should have the new value visible in dom");
+ assert.verifySteps(["write", "read"]);
+ list.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly field and onchange)', async function (assert) {
+ // This test makes sure that if we have a read-only cell in a row, in
+ // case the keyboard navigation move over it and there a unsaved changes
+ // (which will trigger an onchange), the focus of the next activable
+ // field will not crash
+ assert.expect(4);
+
+ this.data.bar.onchanges = {
+ o2m: function () {},
+ };
+ this.data.bar.fields.o2m = {string: "O2M field", type: "one2many", relation: "foo"};
+ this.data.bar.records[0].o2m = [1, 4];
+
+ var form = await createView({
+ View: FormView,
+ model: 'bar',
+ res_id: 1,
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="date" readonly="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.method + ':' + args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var jq_evspecial_focus_trigger = $.event.special.focus.trigger;
+ // As KeyboardEvent will be triggered by JS and not from the
+ // User-Agent itself, the focus event will not trigger default
+ // action (event not being trusted), we need to manually trigger
+ // 'change' event on the currently focused element
+ $.event.special.focus.trigger = function () {
+ if (this !== document.activeElement && this.focus) {
+ var activeElement = document.activeElement;
+ this.focus();
+ $(activeElement).trigger('change');
+ }
+ };
+
+ // editable list, click on first td and press TAB
+ await testUtils.dom.click(form.$('.o_data_cell:contains(yop)'));
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="foo"]')[0],
+ "focus should be on an input with name = foo");
+ await testUtils.fields.editInput(form.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ var tabEvent = $.Event("keydown", { which: $.ui.keyCode.TAB });
+ await testUtils.dom.triggerEvents(form.$('tr.o_selected_row input[name="foo"]'), [tabEvent]);
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="int_field"]')[0],
+ "focus should be on an input with name = int_field");
+
+ // Restore origin jQuery special trigger for 'focus'
+ $.event.special.focus.trigger = jq_evspecial_focus_trigger;
+
+ assert.verifySteps(["onchange:bar"], "onchange method should have been called");
+ form.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // start on 'qux', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(2)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=qux]')[0]);
+
+ // Press 'shift-Tab' -> should go to first cell (same line)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in first column [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field" readonly="1"/>' +
+ '<field name="foo"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // start on 'foo', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line (last cell)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=qux]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in last column [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="int_field"/>' +
+ '<field name="foo"/>' +
+ '<field name="qux" readonly="1"/>' +
+ '</tree>',
+ });
+
+ // start on 'int_field', line 3
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=int_field]')[0]);
+
+ // Press 'shift-Tab' -> should go to previous line ('foo' field)
+ $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
+ await testUtils.nextTick();
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=foo]')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('skip invisible fields when navigating list view with TAB', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar" invisible="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('td:contains(gnap)'));
+ assert.strictEqual(list.$('input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('input[name="int_field"]')[0], document.activeElement,
+ "int_field should be focused");
+
+ list.destroy();
+ });
+
+ QUnit.test('skip buttons when navigating list view with TAB (end)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('tbody tr:eq(2) td:eq(1)'));
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('tbody tr:eq(2) input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('tbody tr:eq(3) input[name="foo"]')[0], document.activeElement,
+ "next line should be selected");
+
+ list.destroy();
+ });
+
+ QUnit.test('skip buttons when navigating list view with TAB (middle)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ // Adding a button column makes conversions between column and field position trickier
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '<field name="foo"/>' +
+ '<button name="kikou" string="Kikou" type="object"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ res_id: 1,
+ });
+
+ await testUtils.dom.click(list.$('tbody tr:eq(2) td:eq(2)'));
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="foo"]')[0], document.activeElement,
+ "foo should be focused");
+ await testUtils.fields.triggerKeydown(list.$('tbody tr:eq(2) input[name="foo"]'), 'tab');
+ assert.strictEqual(list.$('tbody tr:eq(2) input[name="int_field"]')[0], document.activeElement,
+ "int_field should be focused");
+
+ list.destroy();
+ });
+
+ QUnit.test('navigation: not moving down with keydown', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'),'o_selected_row',
+ "1st row should be selected");
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'down');
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'),'o_selected_row',
+ "1st row should still be selected");
+ list.destroy();
+ });
+
+ QUnit.test('navigation: moving right with keydown from text field does not move the focus', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.foo.type = 'text';
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>',
+ });
+
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ var textarea = list.$('textarea[name="foo"]')[0];
+ assert.strictEqual(document.activeElement, textarea,
+ "textarea should be focused");
+ assert.strictEqual(textarea.selectionStart, 0,
+ "textarea selection start should be at the beginning");
+ assert.strictEqual(textarea.selectionEnd, 3,
+ "textarea selection end should be at the end");
+ textarea.selectionStart = 3; // Simulate browser keyboard right behavior (unselect)
+ assert.strictEqual(document.activeElement, textarea,
+ "textarea should still be focused");
+ assert.ok(textarea.selectionStart === 3 && textarea.selectionEnd === 3,
+ "textarea value ('yop') should not be selected and cursor should be at the end");
+ await testUtils.fields.triggerKeydown($(textarea), 'right');
+ assert.strictEqual(document.activeElement, list.$('textarea[name="foo"]')[0],
+ "next field (checkbox) should now be focused");
+ list.destroy();
+ });
+
+ QUnit.test('discarding changes in a row properly updates the rendering', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "yop",
+ "first cell should contain 'yop'");
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('input[name="foo"]'), "hello");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.strictEqual($('.modal:visible').length, 1,
+ "a modal to ask for discard should be visible");
+
+ await testUtils.dom.click($('.modal:visible .btn-primary'));
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "yop",
+ "first cell should still contain 'yop'");
+
+ list.destroy();
+ });
+
+ QUnit.test('numbers in list are right-aligned', async function (assert) {
+ assert.expect(2);
+
+ var currencies = {};
+ _.each(this.data.res_currency.records, function (currency) {
+ currencies[currency.id] = currency;
+ });
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="qux"/>' +
+ '<field name="amount" widget="monetary"/>' +
+ '<field name="currency_id" invisible="1"/>' +
+ '</tree>',
+ session: {
+ currencies: currencies,
+ },
+ });
+
+ var nbCellRight = _.filter(list.$('.o_data_row:first > .o_data_cell'), function (el) {
+ var style = window.getComputedStyle(el);
+ return style.textAlign === 'right';
+ }).length;
+ assert.strictEqual(nbCellRight, 2,
+ "there should be two right-aligned cells");
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ var nbInputRight = _.filter(list.$('.o_data_row:first > .o_data_cell input'), function (el) {
+ var style = window.getComputedStyle(el);
+ return style.textAlign === 'right';
+ }).length;
+ assert.strictEqual(nbInputRight, 2,
+ "there should be two right-aligned input");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with another grouped list parent, click unfold', async function (assert) {
+ assert.expect(3);
+ this.data.bar.fields = {
+ cornichon: {string: 'cornichon', type: 'char'},
+ };
+
+ var rec = this.data.bar.records[0];
+ // create records to have the search more button
+ var newRecs = [];
+ for (var i=0; i<8; i++) {
+ var newRec = _.extend({}, rec);
+ newRec.id = 1 + i;
+ newRec.cornichon = 'extra fin';
+ newRecs.push(newRec);
+ }
+ this.data.bar.records = newRecs;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="m2o"/></tree>',
+ groupBy: ['bar'],
+ archs: {
+ 'bar,false,list': '<tree><field name="cornichon"/></tree>',
+ 'bar,false,search': '<search><filter context="{\'group_by\': \'cornichon\'}" string="cornichon"/></search>',
+ },
+ });
+
+ await list.update({groupBy: []});
+
+ await testUtils.dom.clickFirst(list.$('.o_data_cell'));
+
+ await testUtils.fields.many2one.searchAndClickItem('m2o', { item: 'Search More' });
+
+ assert.containsOnce($('body'), '.modal-content');
+
+ assert.containsNone($('body'), '.modal-content .o_group_name', 'list in modal not grouped');
+
+ await testUtils.dom.click($('body .modal-content button:contains(Group By)'));
+
+ await testUtils.dom.click($('body .modal-content .o_menu_item a:contains(cornichon)'));
+
+ await testUtils.dom.click($('body .modal-content .o_group_header'));
+
+ assert.containsOnce($('body'), '.modal-content .o_group_open');
+
+ list.destroy();
+ });
+
+ QUnit.test('field values are escaped', async function (assert) {
+ assert.expect(1);
+ var value = '<script>throw Error();</script>';
+
+ this.data.foo.records[0].foo = value;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ });
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), value,
+ "value should have been escaped");
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC discard the current line changes', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.containsN(list, 'tr.o_data_row', 5,
+ "should currently adding a 5th data row");
+
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have only 4 data row after escape");
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row',
+ "no rows should be selected");
+ assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
+ "should not have a visible save button");
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC discard the current line changes (with required)', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ });
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ assert.containsN(list, 'tr.o_data_row', 5,
+ "should currently adding a 5th data row");
+
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ assert.containsN(list, 'tr.o_data_row', 4,
+ "should have only 4 data row after escape");
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row',
+ "no rows should be selected");
+ assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
+ "should not have a visible save button");
+ list.destroy();
+ });
+
+ QUnit.test('field with password attribute', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" password="True"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td.o_data_cell:eq(0)').text(), '***',
+ "should display string as password");
+ assert.strictEqual(list.$('td.o_data_cell:eq(1)').text(), '****',
+ "should display string as password");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle widget', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.strictEqual(args.offset, -4,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2 , 3],
+ "should write the sequence in correct order");
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "new first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "new second record should have amount 0");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "new third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
+ "new fourth record should have amount 300");
+
+ list.destroy();
+ });
+
+ QUnit.test('result of consecutive resequences is correctly sorted', async function (assert) {
+ assert.expect(9);
+ this.data = { // we want the data to be minimal to have a minimal test
+ foo: {
+ fields: {int_field: {string: "int_field", type: "integer", sortable: true}},
+ records: [
+ {id: 1, int_field: 11},
+ {id: 2, int_field: 12},
+ {id: 3, int_field: 13},
+ {id: 4, int_field: 14},
+ ]
+ }
+ };
+ var moves = 0;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="id"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ if (moves === 0) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 3],
+ offset: 13,
+ field: "int_field",
+ });
+ }
+ if (moves === 1) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 2],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ if (moves === 2) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [2, 4],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ if (moves === 3) {
+ assert.deepEqual(args, {
+ model: "foo",
+ ids: [4, 2],
+ offset: 12,
+ field: "int_field",
+ });
+ }
+ moves += 1;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(list.$('tbody tr td.o_list_number').text(), '1234',
+ "default should be sorted by id");
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').eq(2),
+ {position: 'top'}
+ );
+ assert.strictEqual(list.$('tbody tr td.o_list_number').text(), '1243',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(2),
+ list.$('tbody tr').eq(1),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1423',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(1),
+ list.$('tbody tr').eq(3),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1243',
+ "the int_field (sequence) should have been correctly updated");
+
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(2),
+ list.$('tbody tr').eq(1),
+ {position: 'top'}
+ );
+ assert.deepEqual(list.$('tbody tr td.o_list_number').text(), '1423',
+ "the int_field (sequence) should have been correctly updated");
+ list.destroy();
+ });
+
+ QUnit.test('editable list with handle widget', async function (assert) {
+ assert.expect(12);
+
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" default_order="int_field">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ assert.strictEqual(args.offset, 1,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2, 3],
+ "should write the sequence in correct order");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "new first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "new second record should have amount 0");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "new third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '300',
+ "new fourth record should have amount 300");
+
+ await testUtils.dom.click(list.$('tbody tr:eq(1) td:last'));
+
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last input').val(), '0',
+ "the edited record should be the good one");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list, handle widget locks and unlocks on sort', async function (assert) {
+ assert.expect(6);
+
+ // we need another sortable field to lock/unlock the handle
+ this.data.foo.fields.amount.sortable = true;
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" default_order="int_field">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float"/>' +
+ '</tree>',
+ });
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.00500.00300.000.00',
+ "default should be sorted by int_field");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ // Handle should be unlocked at this point
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.000.00500.00300.00',
+ "drag and drop should have succeeded, as the handle is unlocked");
+
+ // Sorting by a field different for int_field should lock the handle
+ await testUtils.dom.click(list.$('.o_column_sortable').eq(1));
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '0.00300.00500.001200.00',
+ "should have been sorted by amount");
+
+ // Drag and drop the fourth line in second position (not)
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '0.00300.00500.001200.00',
+ "drag and drop should have failed as the handle is locked");
+
+ // Sorting by int_field should unlock the handle
+ await testUtils.dom.click(list.$('.o_column_sortable').eq(0));
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.000.00500.00300.00',
+ "records should be ordered as per the previous resequence");
+
+ // Drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ assert.strictEqual(list.$('tbody span[name="amount"]').text(), '1200.00300.000.00500.00',
+ "drag and drop should have worked as the handle is unlocked");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with handle widget with slow network', async function (assert) {
+ assert.expect(15);
+
+ // resequence makes sense on a sequence field, not on arbitrary fields
+ this.data.foo.records[0].int_field = 0;
+ this.data.foo.records[1].int_field = 1;
+ this.data.foo.records[2].int_field = 2;
+ this.data.foo.records[3].int_field = 3;
+
+ var prom = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="amount" widget="float" digits="[5,0]"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/resequence') {
+ var _super = this._super.bind(this);
+ assert.strictEqual(args.offset, 1,
+ "should write the sequence starting from the lowest current one");
+ assert.strictEqual(args.field, 'int_field',
+ "should write the right field as sequence");
+ assert.deepEqual(args.ids, [4, 2, 3],
+ "should write the sequence in correct order");
+ return prom.then(function () {
+ return _super(route, args);
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "default first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '500',
+ "default second record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '300',
+ "default third record should have amount 300");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '0',
+ "default fourth record should have amount 0");
+
+ // drag and drop the fourth line in second position
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(3),
+ list.$('tbody tr').first(),
+ {position: 'bottom'}
+ );
+
+ // edit moved row before the end of resequence
+ await testUtils.dom.click(list.$('tbody tr:eq(3) td:last'));
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 0,
+ "shouldn't edit the line before resequence");
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').length, 1,
+ "should edit the line after resequence");
+
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '300',
+ "fourth record should have amount 300");
+
+ await testUtils.fields.editInput(list.$('tbody tr:eq(3) td:last input'), 301);
+ await testUtils.dom.click(list.$('tbody tr:eq(0) td:last'));
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(list.$('tbody tr:eq(0) td:last').text(), '1200',
+ "first record should have amount 1200");
+ assert.strictEqual(list.$('tbody tr:eq(1) td:last').text(), '0',
+ "second record should have amount 1");
+ assert.strictEqual(list.$('tbody tr:eq(2) td:last').text(), '500',
+ "third record should have amount 500");
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last').text(), '301',
+ "fourth record should have amount 301");
+
+ await testUtils.dom.click(list.$('tbody tr:eq(3) td:last'));
+ assert.strictEqual(list.$('tbody tr:eq(3) td:last input').val(), '301',
+ "fourth record should have amount 301");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle widget, create, move and discard', async function (assert) {
+ // When there are less than 4 records in the table, empty lines are added
+ // to have at least 4 rows. This test ensures that the empty line added
+ // when a new record is discarded is correctly added on the bottom of
+ // the list, even if the discarded record wasn't.
+ assert.expect(11);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="bottom">
+ <field name="int_field" widget="handle"/>
+ <field name="foo" required="1"/>
+ </tree>`,
+ domain: [['bar', '=', false]],
+ });
+
+ assert.containsOnce(list, '.o_data_row');
+ assert.containsN(list, 'tbody tr', 4);
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+ assert.containsN(list, '.o_data_row', 2);
+ assert.doesNotHaveClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Drag and drop the first line after creating record row
+ await testUtils.dom.dragAndDrop(
+ list.$('.ui-sortable-handle').eq(0),
+ list.$('tbody tr.o_data_row').eq(1),
+ { position: 'bottom' }
+ );
+ assert.containsN(list, '.o_data_row', 2);
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_list_button_discard'));
+ assert.containsOnce(list, '.o_data_row');
+ assert.hasClass(list.$('tbody tr:first'), 'o_data_row');
+ assert.containsN(list, 'tbody tr', 4);
+
+ list.destroy();
+ });
+
+ QUnit.test('multiple clicks on Add do not create invalid rows', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ m2o: function () {},
+ };
+
+ var prom = testUtils.makeTestPromise();
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="m2o" required="1"/></tree>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return prom.then(function () {
+ return result;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(list, '.o_data_row', 4,
+ "should contain 4 records");
+
+ // click on Add twice, and delay the onchange
+ testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_data_row', 5,
+ "only one record should have been created");
+
+ list.destroy();
+ });
+
+ QUnit.test('reference field rendering', async function (assert) {
+ assert.expect(4);
+
+ this.data.foo.records.push({
+ id: 5,
+ reference: 'res_currency,2',
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="reference"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.step(args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['bar', 'res_currency'], "should have done 1 name_get by model in reference values");
+ assert.strictEqual(list.$('tbody td:not(.o_list_record_selector)').text(), "Value 1USDEUREUR",
+ "should have the display_name of the reference");
+ list.destroy();
+ });
+
+ QUnit.test('reference field batched in grouped list', async function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records= [
+ // group 1
+ {id: 1, foo: '1', reference: 'bar,1'},
+ {id: 2, foo: '1', reference: 'bar,2'},
+ {id: 3, foo: '1', reference: 'res_currency,1'},
+ //group 2
+ {id: 4, foo: '2', reference: 'bar,2'},
+ {id: 5, foo: '2', reference: 'bar,3'},
+ ];
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree expand="1">
+ <field name="foo" invisible="1"/>
+ <field name="reference"/>
+ </tree>`,
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'name_get') {
+ if (args.model === 'bar') {
+ assert.deepEqual(args.args[0], [1, 2 ,3]);
+ }
+ if (args.model === "res_currency") {
+ assert.deepEqual(args.args[0], [1]);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.verifySteps([
+ 'web_read_group',
+ 'name_get',
+ 'name_get',
+ ]);
+ assert.containsN(list, '.o_group_header', 2);
+ const allNames = Array.from(list.el.querySelectorAll('.o_data_cell'), node => node.textContent);
+ assert.deepEqual(allNames, [
+ 'Value 1',
+ 'Value 2',
+ 'USD',
+ 'Value 2',
+ 'Value 3',
+ ]);
+ list.destroy();
+ });
+
+ QUnit.test('multi edit reference field batched in grouped list', async function (assert) {
+ assert.expect(18);
+
+ this.data.foo.records= [
+ // group 1
+ {id: 1, foo: '1', reference: 'bar,1'},
+ {id: 2, foo: '1', reference: 'bar,2'},
+ //group 2
+ {id: 3, foo: '2', reference: 'res_currency,1'},
+ {id: 4, foo: '2', reference: 'bar,2'},
+ {id: 5, foo: '2', reference: 'bar,3'},
+ ];
+ // Field boolean_toggle just to simplify the test flow
+ let nameGetCount = 0;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `<tree expand="1" multi_edit="1">
+ <field name="foo" invisible="1"/>
+ <field name="bar" widget="boolean_toggle"/>
+ <field name="reference"/>
+ </tree>`,
+ groupBy: ['foo'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1,2,3], {bar: true}]);
+ }
+ if (args.method === 'name_get') {
+ if (nameGetCount === 2) {
+ assert.strictEqual(args.model, 'bar');
+ assert.deepEqual(args.args[0], [1,2]);
+ }
+ if (nameGetCount === 3) {
+ assert.strictEqual(args.model, 'res_currency');
+ assert.deepEqual(args.args[0], [1]);
+ }
+ nameGetCount++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps([
+ 'web_read_group',
+ 'name_get',
+ 'name_get',
+ ]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[0]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[1]);
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input')[2]);
+ await testUtils.dom.click(list.$('.o_data_row .o_field_boolean')[0]);
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+ assert.containsNone(document.body, '.modal');
+ assert.verifySteps([
+ 'write',
+ 'read',
+ 'name_get',
+ 'name_get',
+ ]);
+ assert.containsN(list, '.o_group_header', 2);
+
+ const allNames = Array.from(list.el.querySelectorAll('.o_data_cell'))
+ .filter(node => !node.children.length).map(n=>n.textContent);
+ assert.deepEqual(allNames, [
+ 'Value 1',
+ 'Value 2',
+ 'USD',
+ 'Value 2',
+ 'Value 3',
+ ]);
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: contexts are correctly sent', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ var context;
+ if (route === '/web/dataset/search_read') {
+ context = args.context;
+ } else {
+ context = args.kwargs.context;
+ }
+ assert.strictEqual(context.active_field, 2, "context should be correct");
+ assert.strictEqual(context.someKey, 'some value', "context should be correct");
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: {someKey: 'some value'},
+ },
+ viewOptions: {
+ context: {active_field: 2},
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'abc');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: contexts with multiple edit', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/foo/write' ||
+ route === '/web/dataset/call_kw/foo/read') {
+ var context = args.kwargs.context;
+ assert.strictEqual(context.active_field, 2, "context should be correct");
+ assert.strictEqual(context.someKey, 'some value', "context should be correct");
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: {someKey: 'some value'},
+ },
+ viewOptions: {
+ context: {active_field: 2},
+ },
+ });
+
+ // Uses the main selector to select all lines.
+ await testUtils.dom.click(list.$('.o_content input:first'));
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ // Edits first record then confirms changes.
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'legion');
+ await testUtils.dom.click($('.modal-dialog button.btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: single edition with selected records', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `<tree editable="top" multi_edit="1"><field name="foo"/></tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Select first record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+
+ // Edit the second
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:first()'));
+ await testUtils.fields.editInput(list.$('.o_data_row:eq(1) .o_data_cell:first() input'), "oui");
+ await testUtils.dom.click($('.o_list_button_save'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell:first()').text(), "yop",
+ "First row should remain unchanged");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell:first()').text(), "oui",
+ "Second row should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition', async function (assert) {
+ assert.expect(26);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should write on multi records");
+ } else if (args.method === 'read') {
+ if (args.args[0].length !== 1) {
+ assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']],
+ "should batch the read");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line witout modifying a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.hasClass(list.$('.o_data_row:eq(0)'), 'o_selected_row',
+ "the first row should be selected");
+ await testUtils.dom.click('body');
+ assert.containsNone(list, '.o_selected_row', "no row should be selected");
+
+ // create a record and edit its value
+ await testUtils.dom.click($('.o_list_button_add'));
+ assert.verifySteps(['onchange']);
+
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), 123);
+ assert.containsNone(document.body, '.modal', "the multi edition should not be triggered during creation");
+
+ await testUtils.dom.click($('.o_list_button_save'));
+ assert.verifySteps(['create', 'read']);
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsOnce(document.body, '.modal', "modal appears when switching cells");
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+ assert.containsN(list, '.o_list_record_selector input:checked', 2,
+ "Selection should remain unchanged");
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10',
+ "changes have been discarded and row is back to readonly");
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0],
+ "focus should be given to the most recently edited cell after discard");
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+ assert.ok($('.modal').text().includes('those 2 records'), "the number of records should be correctly displayed");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no field should be editable anymore");
+ assert.containsNone(list, '.o_list_record_selector input:checked', "no record should be selected anymore");
+ assert.verifySteps(['write', 'read']);
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666",
+ "the second row should be updated");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no field should be editable anymore");
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')[0],
+ "focus should be given to the most recently edited cell after confirm");
+
+ list.destroy();
+ });
+
+ QUnit.test('create in multi editable list', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ intercepts: {
+ switch_view: function (ev) {
+ assert.strictEqual(ev.data.view_type, 'form');
+ },
+ },
+ });
+
+ // click on CREATE (should trigger a switch_view)
+ await testUtils.dom.click($('.o_list_button_add'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition cannot call onchanges', async function (assert) {
+ assert.expect(15);
+
+ this.data.foo.onchanges = {
+ foo: function (obj) {
+ obj.int_field = obj.foo.length;
+ },
+ };
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ args.args[1].int_field = args.args[1].foo.length;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select and edit a single record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hi');
+
+ assert.containsNone(document.body, '.modal');
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "hi2");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip9");
+
+ assert.verifySteps(['write', 'read']);
+
+ // select the second record (the first one is still selected)
+ assert.containsNone(list, '.o_list_record_selector input:checked');
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit foo, first row
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), 'hello');
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+
+ assert.containsOnce(document.body, '.modal'); // save dialog
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "hello5");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "hello5");
+
+ assert.verifySteps(['write', 'read'], "should not perform the onchange in multi edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition error and cancellation handling', async function (assert) {
+ assert.expect(12);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo" required="1"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line and cancel
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc");
+ await testUtils.dom.click($('.modal .btn:contains("Cancel")'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "first cell should have discarded any change");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // edit a line with an invalid format type
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), "hahaha");
+ assert.containsOnce(document.body, '.modal', "there should be an opened modal");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // edit a line with an invalid value
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_list_record_selector input:enabled');
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "");
+ assert.containsOnce(document.body, '.modal', "there should be an opened modal");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', "changes should be discarded");
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('multi edition: many2many_tags in many2many field', async function (assert) {
+ assert.expect(5);
+
+ for (let i = 4; i <= 10; i++) {
+ this.data.bar.records.push({ id: i, display_name: "Value" + i});
+ }
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1"><field name="m2m" widget="many2many_tags"/></tree>',
+ archs: {
+ 'bar,false,list': '<tree><field name="name"/></tree>',
+ 'bar,false,search': '<search></search>',
+ },
+ });
+
+ assert.containsN(list, '.o_list_record_selector input:enabled', 5);
+
+ // select two records and enter edit mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell'));
+
+ await testUtils.fields.many2one.clickOpenDropdown("m2m");
+ await testUtils.fields.many2one.clickItem("m2m", "Search More");
+ assert.containsOnce(document.body, '.modal .o_list_view', "should have open the modal");
+
+ await testUtils.dom.click($('.modal .o_list_view .o_data_row:first'));
+
+ assert.containsOnce(document.body, ".modal [role='alert']", "should have open the confirmation modal");
+ assert.containsN(document.body, ".modal .o_field_many2manytags .badge", 3);
+ assert.strictEqual($(".modal .o_field_many2manytags .badge:last").text().trim(), "Value 3",
+ "should have display_name in badge");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition of many2one: set same value', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2, 3, 4], { m2o: 1 }],
+ "should force write value on all selected records");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 2Value 1Value 1");
+
+ // select all records (the first one has value 1 for m2o)
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ // set m2o to 1 in first record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.many2one.searchAndClickItem('m2o', {search: 'Value 1'});
+
+ assert.containsOnce(document.body, '.modal');
+
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.strictEqual(list.$('.o_list_many2one').text(), "Value 1Value 1Value 1Value 1");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: clicking on "Discard changes" in multi edition', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ const $discardButton = list.$buttons.find('.o_list_button_discard');
+
+ // Simulates an actual click (event chain is: mousedown > change > blur > focus > mouseup > click)
+ await testUtils.dom.triggerEvents($discardButton, ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($discardButton, ['focus']);
+ $discardButton[0].dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.dom.click($discardButton);
+
+ assert.ok($('.modal').text().includes("Warning"), "Modal should ask to discard changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:first() .o_data_cell:first()').text(), "yop");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view (multi edition): mousedown on "Discard", but mouseup somewhere else', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ // Simulates a pseudo drag and drop
+ await testUtils.dom.triggerEvents(list.$buttons.find('.o_list_button_discard'), ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($(document.body), ['focus']);
+ window.dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.nextTick();
+
+ assert.ok($('.modal').text().includes("Confirmation"), "Modal should ask to save changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view (multi edition): writable fields in readonly (force save)', async function (assert) {
+ assert.expect(7);
+
+ // boolean toogle widget allows for writing on the record even in readonly mode
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="bar" widget="boolean_toggle"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ mockRPC(route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1,3], {bar: false}]);
+ }
+ return this._super(...arguments);
+ }
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row .o_field_boolean')[0]);
+
+ assert.ok($('.modal').text().includes("Confirmation"), "Modal should ask to save changes");
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.verifySteps([
+ 'write',
+ 'read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: click Discard, Cancel discard dialog and then Save in multi edition', async function (assert) {
+ assert.expect(5);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ await testUtils.dom.click(list.$('.o_data_row:first() .o_data_cell:first()'));
+ list.$('.o_data_row:first() .o_data_cell:first() input').val("oof");
+
+ const $discardButton = list.$buttons.find('.o_list_button_discard');
+
+ // Simulates an actual click (event chain is: mousedown > change > blur > focus > mouseup > click)
+ await testUtils.dom.triggerEvents($discardButton, ['mousedown']);
+ await testUtils.dom.triggerEvents(list.$('.o_data_row:first() .o_data_cell:first() input'),
+ ['change', 'blur', 'focusout']);
+ await testUtils.dom.triggerEvents($discardButton, ['focus']);
+ $discardButton[0].dispatchEvent(new MouseEvent('mouseup'));
+ await testUtils.dom.click($discardButton);
+
+ assert.ok($('.modal').text().includes("Warning"), "Modal should ask to discard changes");
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row',
+ "the first row should still be selected");
+
+ await testUtils.dom.click($('.o_list_button_save'));
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.containsNone(list, '.o_selected_row');
+ assert.strictEqual(list.$('.o_data_row .o_data_cell').text(), "oofoofgnapblip");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition with readonly modifiers', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="id"/>' +
+ '<field name="foo"/>' +
+ '<field name="int_field" attrs=\'{"readonly": [("id", ">" , 2)]}\'/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should only write on the valid records");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // select all records
+ await testUtils.dom.click(list.$('th.o_list_record_selector input'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+
+ const modalText = $('.modal-body').text()
+ .split(" ").filter(w => w.trim() !== '').join(" ")
+ .split("\n").join('');
+ assert.strictEqual(modalText,
+ "Among the 4 selected records, 2 are valid for this update. Are you sure you want to " +
+ "perform the following update on those 2 records ? Field: int_field Update to: 666");
+ assert.strictEqual(document.querySelector('.modal .o_modal_changes .o_field_widget').style.pointerEvents, 'none',
+ "pointer events should be deactivated on the demo widget");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "1yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "2blip666",
+ "the second row should be updated");
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition when the domain is selected', async function (assert) {
+ assert.expect(1);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree multi_edit="1" limit="2">
+ <field name="id"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ // select all records, and then select all domain
+ await testUtils.dom.click(list.$('th.o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_list_selection_box .o_list_select_domain'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+
+ assert.ok($('.modal-body').text().includes('This update will only consider the records of the current page.'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: many2one with readonly modifier', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch:
+ `<tree editable="top">
+ <field name="m2o" readonly="1"/>
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(list, '.o_data_row:eq(0) .o_data_cell:eq(0) a[name="m2o"]');
+ assert.strictEqual(document.activeElement, list.$('.o_data_row:eq(0) .o_data_cell:eq(1) input')[0],
+ "focus should go to the char input");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition server error handling', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo" required="1"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ return Promise.reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line and confirm
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc");
+ await testUtils.dom.click('body');
+ await testUtils.dom.click($('.modal .btn-primary'));
+ // Server error: if there was a crash manager, there would be an open error at this point...
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop',
+ "first cell should have discarded any change");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), 'blip',
+ "second selected record should not have changed");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no field should be editable anymore");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: navigation', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select 2 records
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ // toggle a row mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(1)'));
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected");
+
+ // Keyboard navigation only interracts with selected elements
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget[name="int_field"]'), 'enter');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected again");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected again");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no row should be editable anymore");
+ // Clicking on an unselected record while no row is being edited will open the record (switch_view)
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition: edit and validate last row', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree multi_edit="1">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ // in this test, we want to accurately mock what really happens, that is, input
+ // fields only trigger their changes on 'change' event, not on 'input'
+ fieldDebounce: 100000,
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+
+ // select all records
+ await testUtils.dom.click(list.$('.o_list_view thead .o_list_record_selector input'));
+
+ // edit last cell of last line
+ await testUtils.dom.click(list.$('.o_data_row:last .o_data_cell:last'));
+ testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), '666');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_data_cell:last input'), 'enter');
+
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.containsN(list, '.o_data_row', 4,
+ "should not create a new row as we were in multi edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: navigation in grouped list', async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo"/>
+ </tree>`,
+ data: this.data,
+ groupBy: ['bar'],
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Open both groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_header:last'));
+
+ // select 2 records
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input'));
+
+ // toggle a row mode
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected");
+
+ // Keyboard navigation only interracts with selected elements
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget'), 'enter');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row',
+ "the second row should be selected again");
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'tab');
+ assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row',
+ "the fourth row should be selected again");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+ assert.containsNone(list, '.o_data_cell input.o_field_widget', "no row should be editable anymore");
+ await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)'));
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: single edition does not behave like a multi-edition', async function (assert) {
+ assert.expect(3);
+
+ const list = await createView({
+ arch: `
+ <tree multi_edit="1">
+ <field name="foo" required="1"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+
+ // edit a field (invalid input)
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "");
+
+ assert.containsOnce($('body'),'.modal', "should have a modal (invalid fields)");
+ await testUtils.dom.click($('.modal button.btn'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "bar");
+
+ assert.containsNone($('body'),'.modal', "should not have a modal");
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "bar",
+ "the first row should be updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable readonly list view: multi edition', async function (assert) {
+ assert.expect(14);
+
+ const list = await createView({
+ arch:
+ `<tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should write on multi records");
+ } else if (args.method === 'read') {
+ if (args.args[0].length !== 1) {
+ assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']],
+ "should batch the read");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(document.body, '.modal',
+ "modal appears when switching cells");
+
+ await testUtils.dom.click($('.modal .btn:contains(Cancel)'));
+
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10',
+ "changes have been discarded and row is back to readonly");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)'));
+
+ assert.containsOnce(document.body, '.modal',
+ "there should be an opened modal");
+ assert.ok($('.modal').text().includes('those 2 records'),
+ "the number of records should be correctly displayed");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.verifySteps(['write', 'read']);
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666",
+ "the second row should be updated");
+ assert.containsNone(list, '.o_data_cell input.o_field_widget',
+ "no field should be editable anymore");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: m2m tags in grouped list', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="bar"/>
+ <field name="m2m" widget="many2many_tags"/>
+ </tree>`,
+ data: this.data,
+ groupBy: ['bar'],
+ model: 'foo',
+ View: ListView,
+ });
+
+ // Opens first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ assert.notEqual(list.$('.o_data_row:first').text(), list.$('.o_data_row:last').text(),
+ "First row and last row should have different values");
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector:first input'));
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:eq(1)'));
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_many2manytags .o_delete:first'));
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_row:first').text(), list.$('.o_data_row:last').text(),
+ "All rows should have been correctly updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: edit many2one from external link', async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" multi_edit="1">
+ <field name="m2o"/>
+ </tree>`,
+ archs: {
+ 'bar,false,form': '<form string="Bar"><field name="display_name"/></form>',
+ },
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return Promise.resolve(false);
+ }
+ return this._super(route, args);
+ },
+ model: 'foo',
+ View: ListView,
+ });
+
+ await testUtils.dom.click(list.$('thead .o_list_record_selector:first input'));
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:eq(0)'));
+ await testUtils.dom.click(list.$('.o_external_button:first'));
+
+ // Change the M2O value in the Form dialog
+ await testUtils.fields.editInput($('.modal input:first'), "OOF");
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual($('.modal .o_field_widget[name=m2o]').text(), "OOF",
+ "Value of the m2o should be updated in the confirmation dialog");
+
+ // Close the confirmation dialog
+ await testUtils.dom.click($('.modal .btn-primary'));
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), "OOF",
+ "Value of the m2o should be updated in the list");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with fields with readonly modifier', async function (assert) {
+ assert.expect(8);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree editable="top">
+ <field name="bar"/>
+ <field name="foo" attrs="{'readonly': [['bar','=',True]]}"/>
+ <field name="m2o" attrs="{'readonly': [['bar','=',False]]}"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ await testUtils.dom.click(list.$('.o_list_button_add'));
+
+ assert.containsOnce(list, '.o_selected_row');
+ assert.notOk(list.$('.o_selected_row .o_field_boolean input').is(':checked'));
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_list_char'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_selected_row .o_list_many2one'), 'o_readonly_modifier');
+
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_boolean input'));
+
+ assert.ok(list.$('.o_selected_row .o_field_boolean input').is(':checked'));
+ assert.hasClass(list.$('.o_selected_row .o_list_char'), 'o_readonly_modifier');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_list_many2one'), 'o_readonly_modifier');
+
+ await testUtils.dom.click(list.$('.o_selected_row .o_field_many2one input'));
+
+ assert.strictEqual(document.activeElement, list.$('.o_selected_row .o_field_many2one input')[0]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list with many2one: click out does not discard the row', async function (assert) {
+ // In this test, we simulate a long click by manually triggering a mousedown and later on
+ // mouseup and click events
+ assert.expect(5);
+
+ this.data.bar.fields.m2o = {string: "M2O field", type: "many2one", relation: "foo"};
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="display_name"/>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="m2o" required="1"/>
+ </tree>
+ </field>
+ </form>`,
+ });
+
+ assert.containsNone(form, '.o_data_row');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ assert.containsOnce(form, '.o_data_row');
+
+ // focus and write something in the m2o
+ form.$('.o_field_many2one input').focus().val('abcdef').trigger('keyup');
+ await testUtils.nextTick();
+
+ // then simulate a mousedown outside
+ form.$('.o_field_widget[name="display_name"]').focus().trigger('mousedown');
+ await testUtils.nextTick();
+ assert.containsOnce(document.body, '.modal', "should ask confirmation to create a record");
+
+ // trigger the mouseup and the click
+ form.$('.o_field_widget[name="display_name"]').trigger('mouseup').trigger('click');
+ await testUtils.nextTick();
+
+ assert.containsOnce(document.body, '.modal', "modal should still be displayed");
+ assert.containsOnce(form, '.o_data_row', "the row should still be there");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list alongside html field: click out to unselect the row', async function (assert) {
+ assert.expect(5);
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <field name="text" widget="html"/>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ </tree>
+ </field>
+ </form>`,
+ });
+
+ assert.containsNone(form, '.o_data_row');
+
+ await testUtils.dom.click(form.$('.o_field_x2many_list_row_add > a'));
+ assert.containsOnce(form, '.o_data_row');
+ assert.hasClass(form.$('.o_data_row'), 'o_selected_row');
+
+ // click outside to unselect the row
+ await testUtils.dom.click(document.body);
+ assert.containsOnce(form, '.o_data_row');
+ assert.doesNotHaveClass(form.$('.o_data_row'), 'o_selected_row');
+
+ form.destroy();
+ });
+
+ QUnit.test('list grouped by date:month', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="date"/></tree>',
+ groupBy: ['date:month'],
+ });
+
+ assert.strictEqual(list.$('tbody').text(), "January 2017 (1)Undefined (3)",
+ "the group names should be correct");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list edition with toggle_button widget', async function (assert) {
+ assert.expect(3);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="bar" widget="toggle_button"/></tree>',
+ groupBy: ['m2o'],
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {bar: false},
+ "should write the correct value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsOnce(list, '.o_data_row:first .o_toggle_button_success',
+ "boolean value of the first record should be true");
+ await testUtils.dom.click(list.$('.o_data_row:first .o_icon_button'));
+ assert.strictEqual(list.$('.o_data_row:first .text-muted:not(.o_toggle_button_success)').length, 1,
+ "boolean button should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view, indentation for empty group', async function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/></tree>',
+ groupBy: ['priority', 'm2o'],
+ mockRPC: function (route, args) {
+ // Override of the read_group to display the row even if there is no record in it,
+ // to mock the behavihour of some fields e.g stage_id on the sale order.
+ if (args.method === 'web_read_group' && args.kwargs.groupby[0] === "m2o") {
+ return Promise.resolve({
+ groups: [{
+ id: 8,
+ m2o: [1, "Value 1"],
+ m2o_count: 0
+ }, {
+ id: 2,
+ m2o: [2, "Value 2"],
+ m2o_count: 1
+ }],
+ length: 1,
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // open the first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().length, 1,
+ "There should be an empty element creating the indentation for the subgroup.");
+ assert.hasClass(list.$('th.o_group_name').eq(1).children().eq(0), 'fa',
+ "The first element of the row name should have the fa class");
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).is('span'), true,
+ "The first element of the row name should be a span");
+ list.destroy();
+ });
+
+ QUnit.test('basic support for widgets', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ init: function (parent, dataPoint) {
+ this.data = dataPoint.data;
+ },
+ start: function () {
+ this.$el.text(JSON.stringify(this.data));
+ },
+ });
+ widgetRegistry.add('test', MyWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="int_field"/><widget name="test"/></tree>',
+ });
+
+ assert.strictEqual(list.$('.o_widget').first().text(), '{"foo":"yop","int_field":10,"id":1}',
+ "widget should have been instantiated");
+
+ list.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('use the limit attribute in arch', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.limit, 2,
+ 'should use the correct limit value');
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2');
+ assert.strictEqual(cpHelpers.getPagerSize(list), '4');
+
+ assert.containsN(list, '.o_data_row', 2,
+ 'should display 2 data rows');
+ list.destroy();
+ });
+
+ QUnit.test('check if the view destroys all widgets and instances', async function (assert) {
+ assert.expect(2);
+
+ var instanceNumber = 0;
+ testUtils.mock.patch(mixins.ParentedMixin, {
+ init: function () {
+ instanceNumber++;
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ if (!this.isDestroyed()) {
+ instanceNumber--;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var params = {
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="date"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '<field name="m2o"/>' +
+ '<field name="o2m"/>' +
+ '<field name="m2m"/>' +
+ '<field name="amount"/>' +
+ '<field name="currency_id"/>' +
+ '<field name="datetime"/>' +
+ '<field name="reference"/>' +
+ '</tree>',
+ };
+
+ var list = await createView(params);
+ assert.ok(instanceNumber > 0);
+
+ list.destroy();
+ assert.strictEqual(instanceNumber, 0);
+
+ testUtils.mock.unpatch(mixins.ParentedMixin);
+ });
+
+ QUnit.test('concurrent reloads finishing in inverse order', async function (assert) {
+ assert.expect(4);
+
+ var blockSearchRead = false;
+ var prom = testUtils.makeTestPromise();
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ mockRPC: function (route) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read' && blockSearchRead) {
+ return prom.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should contain 4 records");
+
+ // reload with a domain (this request is blocked)
+ blockSearchRead = true;
+ list.reload({domain: [['foo', '=', 'yop']]});
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records (search_read being blocked)");
+
+ // reload without the domain
+ blockSearchRead = false;
+ list.reload({domain: []});
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records");
+
+ // unblock the RPC
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_list_view .o_data_row', 4,
+ "list view should still contain 4 records");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view on a "noCache" model', async function (assert) {
+ assert.expect(9);
+
+ testUtils.mock.patch(BasicModel, {
+ noCacheModels: BasicModel.prototype.noCacheModels.concat(['foo']),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (_.contains(['create', 'unlink', 'write'], args.method)) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+ core.bus.on('clear_cache', list, assert.step.bind(assert, 'clear_cache'));
+
+ // create a new record
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget'), 'some value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // edit an existing record
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget'), 'new value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // delete a record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_list_record_selector input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ assert.verifySteps([
+ 'create',
+ 'clear_cache',
+ 'write',
+ 'clear_cache',
+ 'unlink',
+ 'clear_cache',
+ ]);
+
+ list.destroy();
+ testUtils.mock.unpatch(BasicModel);
+
+ assert.verifySteps(['clear_cache']); // triggered by the test environment on destroy
+ });
+
+ QUnit.test('list view move to previous page when all records from last page deleted', async function (assert) {
+ assert.expect(5);
+
+ let checkSearchRead = false;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (checkSearchRead && route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 3, "limit should 3");
+ assert.notOk(args.offset, "offset should not be passed i.e. offset 0 by default");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ });
+
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 4',
+ "should have 2 pages and current page should be first page");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_pager_next'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '4-4 / 4',
+ "should be on second page");
+
+ // delete a record
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ checkSearchRead = true;
+ await testUtils.dom.click(list.$('.o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Delete)'));
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 3',
+ "should have 1 page only");
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list view move to previous page of group when all records from last page deleted', async function (assert) {
+ assert.expect(7);
+
+ let checkSearchRead = false;
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="2">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ if (checkSearchRead && route === '/web/dataset/search_read') {
+ assert.strictEqual(args.limit, 2, "limit should 2");
+ assert.notOk(args.offset, "offset should not be passed i.e. offset 0 by default");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ groupBy: ['m2o'],
+ });
+
+ assert.strictEqual(list.$('th:contains(Value 1 (3))').length, 1,
+ "Value 1 should contain 3 records");
+ assert.strictEqual(list.$('th:contains(Value 2 (1))').length, 1,
+ "Value 2 should contain 1 record");
+
+ await testUtils.dom.click(list.$('th.o_group_name:nth(0)'));
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '1-2 / 3',
+ "should have 2 pages and current page should be first page");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_group_header .o_pager_next'));
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '3-3 / 3',
+ "should be on second page");
+
+ // delete a record
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ checkSearchRead = true;
+ await testUtils.dom.click(list.$('.o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Delete)'));
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.strictEqual(list.$('th.o_group_name:eq(0) .o_pager_counter').text().trim(), '',
+ "should be on first page now");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view move to previous page when all records from last page archive/unarchived', async function (assert) {
+ assert.expect(9);
+
+ // add active field on foo model and make all records active
+ this.data.foo.fields.active = { string: 'Active', type: 'boolean', default: true };
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3"><field name="display_name"/></tree>',
+ viewOptions: {
+ hasActionMenus: true,
+ },
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/foo/action_archive') {
+ this.data.foo.records[3].active = false;
+ return Promise.resolve();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 4',
+ "should have 2 pages and current page should be first page");
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 3,
+ "should have 3 records");
+
+ // move to next page
+ await testUtils.dom.click(list.$('.o_pager_next'));
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '4-4 / 4',
+ "should be on second page");
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 1,
+ "should have 1 records");
+ assert.containsNone(list, '.o_cp_action_menus', 'sidebar should not be available');
+
+ await testUtils.dom.click(list.$('tbody .o_data_row:first td.o_list_record_selector:first input'));
+ assert.containsOnce(list, '.o_cp_action_menus', 'sidebar should be available');
+
+ // archive all records of current page
+ await testUtils.dom.click(list.$('.o_cp_action_menus .o_dropdown_toggler_btn:contains(Action)'));
+ await testUtils.dom.click(list.$('a:contains(Archive)'));
+ assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
+
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+ assert.strictEqual(list.$('tbody td.o_list_record_selector').length, 3,
+ "should have 3 records");
+ assert.strictEqual(list.$('.o_pager_counter').text().trim(), '1-3 / 3',
+ "should have 1 page only");
+
+ list.destroy();
+ });
+
+ QUnit.test('list should ask to scroll to top on page changes', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree limit="3">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ intercepts: {
+ scrollTo: function (ev) {
+ assert.strictEqual(ev.data.top, 0,
+ "should ask to scroll to top");
+ assert.step('scroll');
+ },
+ },
+ });
+
+
+ // switch pages (should ask to scroll)
+ await cpHelpers.pagerNext(list);
+ await cpHelpers.pagerPrevious(list);
+ assert.verifySteps(['scroll', 'scroll'],
+ "should ask to scroll when switching pages");
+
+ // change the limit (should not ask to scroll)
+ await cpHelpers.setPagerValue(list, '1-2');
+ await testUtils.nextTick();
+ assert.strictEqual(cpHelpers.getPagerValue(list), '1-2');
+ assert.verifySteps([], "should not ask to scroll when changing the limit");
+
+ // switch pages again (should still ask to scroll)
+ await cpHelpers.pagerNext(list);
+
+ assert.verifySteps(['scroll'], "this is still working after a limit change");
+
+ list.destroy();
+ });
+
+ QUnit.test('list with handle field, override default_get, bottom when inline', async function (assert) {
+ assert.expect(2);
+
+ this.data.foo.fields.int_field.default = 10;
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch:
+ '<tree editable="bottom" default_order="int_field">'
+ + '<field name="int_field" widget="handle"/>'
+ + '<field name="foo"/>'
+ +'</tree>',
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell').text(), "blipblipyopgnap");
+
+ // click add a new line
+ // save the record
+ // check line is at the correct place
+
+ var inputText = 'ninja';
+ await testUtils.dom.click($('.o_list_button_add'));
+ await testUtils.fields.editInput(list.$('.o_input[name="foo"]'), inputText);
+ await testUtils.dom.click($('.o_list_button_save'));
+ await testUtils.dom.click($('.o_list_button_add'));
+
+ assert.strictEqual($('.o_data_cell').text(), "blipblipyopgnap" + inputText);
+
+ list.destroy();
+ });
+
+ QUnit.test('create record on list with modifiers depending on id', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="id" invisible="1"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'id\',\'!=\',False]]}"/>' +
+ '<field name="int_field" attrs="{\'invisible\': [[\'id\',\'!=\',False]]}"/>' +
+ '</tree>',
+ });
+
+ // add a new record
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ // modifiers should be evaluted to false
+ assert.containsOnce(list, '.o_selected_row');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
+ assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ // set a value and save
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name=foo]'), 'some value');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ // modifiers should be evaluted to true
+ assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_data_row:first .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ // edit again the just created record
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ // modifiers should be evaluted to true
+ assert.containsOnce(list, '.o_selected_row');
+ assert.hasClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
+ assert.hasClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
+
+ list.destroy();
+ });
+
+ QUnit.test('readonly boolean in editable list is readonly', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="bar" attrs="{\'readonly\': [(\'foo\', \'!=\', \'yop\')]}"/>' +
+ '</tree>',
+ });
+
+ // clicking on disabled checkbox with active row does not work
+ var $disabledCell = list.$('.o_data_row:eq(1) .o_data_cell:last-child');
+ await testUtils.dom.click($disabledCell.prev());
+ assert.containsOnce($disabledCell, ':disabled:checked');
+ var $disabledLabel = $disabledCell.find('.custom-control-label');
+ await testUtils.dom.click($disabledLabel);
+ assert.containsOnce($disabledCell, ':checked',
+ "clicking disabled checkbox did not work"
+ );
+ assert.ok(
+ $(document.activeElement).is('input[type="text"]'),
+ "disabled checkbox is not focused after click"
+ );
+
+ // clicking on enabled checkbox with active row toggles check mark
+ var $enabledCell = list.$('.o_data_row:eq(0) .o_data_cell:last-child');
+ await testUtils.dom.click($enabledCell.prev());
+ assert.containsOnce($enabledCell, ':checked:not(:disabled)');
+ var $enabledLabel = $enabledCell.find('.custom-control-label');
+ await testUtils.dom.click($enabledLabel);
+ assert.containsNone($enabledCell, ':checked',
+ "clicking enabled checkbox worked and unchecked it"
+ );
+ assert.ok(
+ $(document.activeElement).is('input[type="checkbox"]'),
+ "enabled checkbox is focused after click"
+ );
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with async widget', async function (assert) {
+ assert.expect(4);
+
+ var prom = testUtils.makeTestPromise();
+ var AsyncWidget = Widget.extend({
+ willStart: function () {
+ return prom;
+ },
+ start: function () {
+ this.$el.text('ready');
+ },
+ });
+ widgetRegistry.add('asyncWidget', AsyncWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><widget name="asyncWidget"/></tree>',
+ groupBy: ['int_field'],
+ });
+
+ assert.containsNone(list, '.o_data_row', "no group should be open");
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ assert.containsNone(list, '.o_data_row',
+ "should wait for async widgets before opening the group");
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, '.o_data_row', 1, "group should be open");
+ assert.strictEqual(list.$('.o_data_row .o_data_cell').text(), 'ready',
+ "async widget should be correctly displayed");
+
+ list.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('grouped lists with groups_limit attribute', async function (assert) {
+ assert.expect(8);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree groups_limit="3"><field name="foo"/></tree>',
+ groupBy: ['int_field'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(list, '.o_group_header', 3); // page 1
+ assert.containsNone(list, '.o_data_row');
+ assert.containsOnce(list, '.o_pager'); // has a pager
+
+ await cpHelpers.pagerNext(list); // switch to page 2
+
+ assert.containsN(list, '.o_group_header', 1); // page 2
+ assert.containsNone(list, '.o_data_row');
+
+ assert.verifySteps([
+ 'web_read_group', // read_group page 1
+ 'web_read_group', // read_group page 2
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list with expand attribute', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.containsN(list, '.o_group_header', 2);
+ assert.containsN(list, '.o_data_row', 4);
+ assert.strictEqual(list.$('.o_data_cell').text(), 'yopblipgnapblip');
+
+ assert.verifySteps([
+ 'web_read_group', // records are fetched alongside groups
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped list (two levels) with expand attribute', async function (assert) {
+ // the expand attribute only opens the first level groups
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['bar', 'int_field'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.containsN(list, '.o_group_header', 6);
+
+ assert.verifySteps([
+ 'web_read_group', // global
+ 'web_read_group', // first group
+ 'web_read_group', // second group
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped lists with expand attribute and a lot of groups', async function (assert) {
+ assert.expect(8);
+
+ for (var i = 0; i < 15; i++) {
+ this.data.foo.records.push({foo: 'record ' + i, int_field: i});
+ }
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree expand="1"><field name="foo"/></tree>',
+ groupBy: ['int_field'],
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsN(list, '.o_group_header', 10); // page 1
+ assert.containsN(list, '.o_data_row', 11); // one group contains two records
+ assert.containsOnce(list, '.o_pager'); // has a pager
+
+ await cpHelpers.pagerNext(list); // switch to page 2
+
+ assert.containsN(list, '.o_group_header', 7); // page 2
+ assert.containsN(list, '.o_data_row', 7);
+
+ assert.verifySteps([
+ 'web_read_group', // read_group page 1
+ 'web_read_group', // read_group page 2
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('add filter in a grouped list with a pager', async function (assert) {
+ assert.expect(11);
+
+ const actionManager = await createActionManager({
+ data: this.data,
+ actions: [{
+ id: 11,
+ name: 'Action 11',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[3, 'list']],
+ search_view_id: [9, 'search'],
+ flags: {
+ context: { group_by: ['int_field'] },
+ },
+ }],
+ archs: {
+ 'foo,3,list': '<tree groups_limit="3"><field name="foo"/></tree>',
+ 'foo,9,search': `
+ <search>
+ <filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
+ </search>`,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'web_read_group') {
+ assert.step(JSON.stringify(args.kwargs.domain) + ', ' + args.kwargs.offset);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await actionManager.doAction(11);
+
+ assert.containsOnce(actionManager, '.o_list_view');
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '1-3 / 4');
+ assert.containsN(actionManager, '.o_group_header', 3); // page 1
+
+ await testUtils.dom.click(actionManager.$('.o_pager_next')); // switch to page 2
+
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '4-4 / 4');
+ assert.containsN(actionManager, '.o_group_header', 1); // page 2
+
+ // toggle a filter -> there should be only one group left (on page 1)
+ await cpHelpers.toggleFilterMenu(actionManager);
+ await cpHelpers.toggleMenuItem(actionManager, 0);
+
+ assert.strictEqual(actionManager.$('.o_pager_counter').text().trim(), '1-1 / 1');
+ assert.containsN(actionManager, '.o_group_header', 1); // page 1
+
+ assert.verifySteps([
+ '[], undefined',
+ '[], 3',
+ '[["bar","=",false]], undefined',
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('editable grouped lists', async function (assert) {
+ assert.expect(4);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+
+ // enter edition (grouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // click on the body should leave the edition
+ await testUtils.dom.click($('body'));
+ assert.containsNone(list, '.o_selected_row');
+
+ // reload without groupBy
+ await list.reload({groupBy: []});
+
+ // enter edition (ungrouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // click on the body should leave the edition
+ await testUtils.dom.click($('body'));
+ assert.containsNone(list, '.o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('grouped lists are editable (ungrouped first)', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ });
+
+ // enter edition (ungrouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ // reload with groupBy
+ await list.reload({groupBy: ['bar']});
+
+ // open first group
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+
+ // enter edition (grouped case)
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
+
+ list.destroy();
+ });
+
+ QUnit.test('char field edition in editable grouped list', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+
+ assert.strictEqual(this.data.foo.records[0].foo, 'pla',
+ "the edition should have been properly saved");
+ assert.containsOnce(list, '.o_data_row:first:contains(pla)');
+
+ list.destroy();
+ });
+
+ QUnit.test('control panel buttons in editable grouped list views', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ assert.isNotVisible(list.$buttons.find('.o_list_button_add'));
+
+ // reload without groupBy
+ await list.reload({groupBy: []});
+ assert.isVisible(list.$buttons.find('.o_list_button_add'));
+
+ list.destroy();
+ });
+
+ QUnit.test('control panel buttons in multi editable grouped list views', async function (assert) {
+ assert.expect(8);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ groupBy: ['foo'],
+ arch:
+ `<tree multi_edit="1">
+ <field name="foo"/>
+ <field name="int_field"/>
+ </tree>`,
+ });
+
+ assert.containsNone(list, '.o_data_row', "all groups should be closed");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 1, "first group should be opened");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ assert.containsOnce(list, '.o_data_row:eq(0) .o_list_record_selector input:enabled',
+ "should have selected first record");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ await testUtils.dom.click(list.$('.o_group_header:last'));
+ assert.containsN(list, '.o_data_row', 2, "two groups should be opened");
+ assert.isVisible(list.$buttons.find('.o_list_button_add'),
+ "should have a visible Create button");
+
+ list.destroy();
+ });
+
+ QUnit.test('edit a line and discard it in grouped editable', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third group row should be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ await testUtils.dom.click(list.$('.o_data_row:nth(0) > td:contains(yop)'));
+ assert.ok(list.$('.o_data_row:eq(0)').is('.o_selected_row'),
+ "first group row should be in edition");
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsNone(list, '.o_selected_row');
+
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
+ assert.containsOnce(list, '.o_selected_row');
+ assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
+ "third group row should be in edition");
+
+ list.destroy();
+ });
+
+ QUnit.test('add and discard a record in a multi-level grouped list view', async function (assert) {
+ assert.expect(7);
+
+ testUtils.mock.patch(basicFields.FieldChar, {
+ destroy: function () {
+ assert.step('destroy');
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['foo', 'bar'],
+ });
+
+ // unfold first subgroup
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
+ assert.hasClass(list.$('.o_group_header:first'), 'o_group_open');
+ assert.hasClass(list.$('.o_group_header:eq(1)'), 'o_group_open');
+ assert.containsOnce(list, '.o_data_row');
+
+ // add a record to first subgroup
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));
+ assert.containsN(list, '.o_data_row', 2);
+
+ // discard
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ assert.containsOnce(list, '.o_data_row');
+
+ assert.verifySteps(['destroy']);
+
+ testUtils.mock.unpatch(basicFields.FieldChar);
+ list.destroy();
+ });
+
+ QUnit.test('inputs are disabled when unselecting rows in grouped editable', async function (assert) {
+ assert.expect(1);
+
+ var $input;
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual($input.prop('disabled'), true,
+ "input should be disabled");
+ }
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ $input = list.$('tr.o_selected_row input[name="foo"]');
+ await testUtils.fields.editAndTrigger($input, 'lemon', 'input');
+ await testUtils.fields.triggerKeydown($input, 'tab');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC in editable grouped list should discard the current line changes', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, 'tr.o_data_row', 3);
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ // update name by "foo"
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'new_value', 'input');
+ // discard by pressing ESC
+ await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.containsOnce(list, 'tbody tr td:contains(yop)');
+ assert.containsN(list, 'tr.o_data_row', 3);
+ assert.containsNone(list, 'tr.o_data_row.o_selected_row');
+ assert.isNotVisible(list.$buttons.find('.o_list_button_save'));
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable="bottom" grouped list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable="top" grouped list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing TAB in editable grouped list with create=0', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to next line (still in first group)
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go to first line of next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // Press 'Tab' -> should go back to first line of first group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable="bottom" grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable="top" grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing SHIFT-TAB in editable grouped list with create="0"', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // navigate inside a group
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // navigate between groups
+ await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
+
+ // press Shft+tab
+ list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
+ await testUtils.nextTick();
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ list.destroy();
+ });
+
+ QUnit.test('editing then pressing TAB in editable grouped list', async function (assert) {
+ assert.expect(19);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ // open two groups
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
+ assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
+
+ // select and edit last row of first group
+ await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell'));
+ assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new value');
+
+ // Press 'Tab' -> should create a new record as we edited the previous one
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ // fill foo field for the new record and press 'tab' -> should create another record
+ await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new record');
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+
+ assert.containsN(list, '.o_data_row', 6);
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ // leave this new row empty and press tab -> should discard the new record and move to the
+ // next group
+ await testUtils.fields.triggerKeydown(list.$('.o_selected_row .o_input'), 'tab');
+ assert.containsN(list, '.o_data_row', 5);
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'write',
+ 'read',
+ 'onchange',
+ 'create',
+ 'read',
+ 'onchange',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('editing then pressing TAB (with a readonly field) in grouped list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ fieldDebounce: 1
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ // click on first td and press TAB
+ await testUtils.dom.click(list.$('td:contains(yop)'));
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'new value', 'input');
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
+
+ assert.containsOnce(list, 'tbody tr td:contains(new value)');
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ 'write',
+ 'read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable="bottom" grouped list view', async function (assert) {
+ assert.expect(11);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should create a new record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.containsN(list, 'tr.o_data_row', 5);
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'onchange',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable="top" grouped list view', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should move to first record of next group
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ENTER in editable grouped list view with create=0', async function (assert) {
+ assert.expect(10);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
+ assert.containsN(list, 'tr.o_data_row', 4);
+ await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter in input should move to next record
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
+
+ // press enter on last row should move to first record of next group
+ await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row .o_input'), 'enter');
+
+ assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
+
+ assert.verifySteps([
+ 'web_read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ list.destroy();
+ });
+
+ QUnit.test('cell-level keyboard navigation in non-editable list', async function (assert) {
+ assert.expect(16);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" required="1"/></tree>',
+ intercepts: {
+ switch_view: function (event) {
+ assert.strictEqual(event.data.res_id, 3,
+ "'switch_view' event has been triggered");
+ },
+ },
+ });
+
+ assert.ok(document.activeElement.classList.contains('o_searchview_input'),
+ 'default focus should be in search view');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be on the record selector');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.ok(document.activeElement.classList.contains('o_searchview_input'),
+ 'focus should have come back to the search view');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be in first row input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus should now be in field TD');
+ assert.strictEqual(document.activeElement.textContent, 'yop',
+ 'focus should now be in first row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'yop',
+ 'should not cycle at end of line');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should now be in second row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'gnap',
+ 'focus should now be in third row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should now be in last row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should still be in last row field (arrows do not cycle)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'focus should still be in last row field (arrows still do not cycle)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be in last row input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'should not cycle at start of line');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.textContent, 'gnap',
+ 'focus should now be in third row field');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ list.destroy();
+ });
+
+ QUnit.test('removing a groupby while adding a line from list', async function (assert) {
+ assert.expect(1);
+
+ let checkUnselectRow = false;
+ testUtils.mock.patch(ListRenderer, {
+ unselectRow(options = {}) {
+ if (checkUnselectRow) {
+ assert.step('unselectRow');
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree multi_edit="1" editable="bottom">
+ <field name="display_name"/>
+ <field name="foo"/>
+ </tree>
+ `,
+ archs: {
+ 'foo,false,search': `
+ <search>
+ <field name="foo"/>
+ <group expand="1" string="Group By">
+ <filter name="groupby_foo" context="{'group_by': 'foo'}"/>
+ </group>
+ </search>
+ `,
+ },
+ });
+
+ await cpHelpers.toggleGroupByMenu(list);
+ await cpHelpers.toggleMenuItem(list, 0);
+ // expand group
+ await testUtils.dom.click(list.el.querySelector('th.o_group_name'));
+ await testUtils.dom.click(list.el.querySelector('td.o_group_field_row_add a'));
+ checkUnselectRow = true;
+ await testUtils.dom.click($('.o_searchview_facet .o_facet_remove'));
+ assert.verifySteps([]);
+ testUtils.mock.unpatch(ListRenderer);
+ list.destroy();
+ });
+
+ QUnit.test('cell-level keyboard navigation in editable grouped list', async function (assert) {
+ assert.expect(56);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
+ await testUtils.dom.click(list.$('td:contains(blip)')); // select row of first group
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be opened');
+
+ var $secondRowInput = list.$('tr.o_data_row:eq(1) td:eq(1) input');
+ assert.strictEqual($secondRowInput.val(), 'blip',
+ 'second record should be in edit mode');
+
+ await testUtils.fields.editAndTrigger($secondRowInput, 'blipbloup', 'input');
+ assert.strictEqual($secondRowInput.val(), 'blipbloup',
+ 'second record should be changed but not saved yet');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+
+ assert.hasClass($('body'), 'modal-open',
+ 'record has been modified, are you sure modal should be opened');
+ await testUtils.dom.click($('body .modal button span:contains(Ok)'));
+
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be closed');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of second record should be focused');
+ assert.strictEqual(list.$('tr.o_data_row:eq(1) td:eq(1)').text(), 'blip',
+ 'change should not have been saved');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'record selector should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in first record td');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ var $firstRowInput = list.$('tr.o_data_row:eq(0) td:eq(1) input');
+ assert.hasClass(list.$('tr.o_data_row:eq(0)'), 'o_selected_row',
+ 'first row should be selected');
+ assert.strictEqual($firstRowInput.val(), 'yop',
+ 'first record should be in edit mode');
+
+ await testUtils.fields.editAndTrigger($firstRowInput, 'Zipadeedoodah', 'input');
+ assert.strictEqual($firstRowInput.val(), 'Zipadeedoodah',
+ 'first record should be changed but not saved yet');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row:eq(0) td:eq(1)').text(), 'Zipadeedoodah',
+ 'first record should be saved');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(0)'), 'o_selected_row',
+ 'first row should be closed');
+ assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be opened');
+ assert.strictEqual(list.$('tr.o_data_row:eq(1) td:eq(1) input').val(), 'blip',
+ 'second record should be in edit mode');
+
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should still be focused (arrows movements are disabled in edit)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.value, 'blip',
+ 'second record input should still be focused (arrows movements are still disabled in edit)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row',
+ 'second row should be closed');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of second record should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+
+ assert.strictEqual(document.activeElement.textContent, 'false (1)',
+ 'focus should be on second group header');
+ assert.strictEqual(list.$('tr.o_data_row').length, 3,
+ 'should have 3 rows displayed');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 4,
+ 'should have 4 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'false (1)',
+ 'focus should still be on second group header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of last record should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'arrow navigation should not cycle (focus still on last row)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ await testUtils.fields.editAndTrigger($('tr.o_data_row:eq(4) td:eq(1) input'),
+ 'cheateur arrete de cheater', 'input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 6,
+ 'should have 6 rows displayed (new record + new edit line)');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'escape');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+
+ // come back to the top
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+
+ assert.strictEqual(document.activeElement.tagName, 'TH',
+ 'focus is in table header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus is in header input');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'focus is in field td');
+ assert.strictEqual(document.activeElement.textContent, 'Zipadeedoodah',
+ 'second field of first record should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus should be on first group header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed (first group should be closed)');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus should still be on first group header');
+
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(list.$('tr.o_data_row').length, 5,
+ 'should have 5 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'right');
+ assert.strictEqual(list.$('tr.o_data_row').length, 5,
+ 'should have 5 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'left');
+ assert.strictEqual(list.$('tr.o_data_row').length, 2,
+ 'should have 2 rows displayed');
+ assert.strictEqual(document.activeElement.textContent, 'true (3)',
+ 'focus is still in header');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'false (2)',
+ 'focus should now be on second group header');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'TD',
+ 'record td should be focused');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of first record of second group should be focused');
+
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'cheateur arrete de cheater',
+ 'second field of last record of second group should be focused');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'A',
+ 'should focus the "Add a line" button');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'cheateur arrete de cheater',
+ 'second field of last record of second group should be focused (special case: the first td of the "Add a line" line was skipped');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.strictEqual(document.activeElement.textContent, 'blip',
+ 'second field of first record of second group should be focused');
+
+ list.destroy();
+ });
+
+ QUnit.test('execute group header button with keyboard navigation', async function (assert) {
+ assert.expect(13);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<groupby name="m2o">' +
+ '<button type="object" name="some_method" string="Do this"/>' +
+ '</groupby>' +
+ '</tree>',
+ groupBy: ['m2o'],
+ intercepts: {
+ execute_action: function (ev) {
+ assert.strictEqual(ev.data.action_data.name, 'some_method');
+ },
+ },
+ });
+
+ assert.containsNone(list, '.o_data_row', "all groups should be closed");
+
+ // focus create button as a starting point
+ list.$('.o_list_button_add').focus();
+ assert.ok(document.activeElement.classList.contains('o_list_button_add'));
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should now be on the record selector (list header)');
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.textContent, 'Value 1 (3)',
+ 'focus should be on first group header');
+
+ // unfold first group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.containsN(list, '.o_data_row', 3, "first group should be open");
+
+ // move to first record of opened group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'down');
+ assert.strictEqual(document.activeElement.tagName, 'INPUT',
+ 'focus should be in first row checkbox');
+
+ // move back to the group header
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'up');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should be back on first group header');
+
+ // fold the group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should still be on first group header');
+ assert.containsNone(list, '.o_data_row', "first group should now be folded");
+
+ // unfold the group
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.ok(document.activeElement.classList.contains('o_group_name'),
+ 'focus should still be on first group header');
+ assert.containsN(list, '.o_data_row', 3, "first group should be open");
+
+ // simulate a move to the group header button with tab (we can't trigger a native event
+ // programmatically, see https://stackoverflow.com/a/32429197)
+ list.$('.o_group_header .o_group_buttons button:first').focus();
+ assert.strictEqual(document.activeElement.tagName, 'BUTTON',
+ 'focus should be on the group header button');
+
+ // click on the button by pressing enter
+ await testUtils.fields.triggerKeydown($(document.activeElement), 'enter');
+ assert.containsN(list, '.o_data_row', 3, "first group should still be open");
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in grouped editable="top" list', async function (assert) {
+ assert.expect(7);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
+ assert.strictEqual(list.$('.o_selected_row .o_input[name=foo]')[0], document.activeElement,
+ 'The first input of the line should have the focus');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
+
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ assert.strictEqual(list.$('.o_group_name:eq(1)').text(), 'false (2)',
+ "group should have correct name and count");
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in grouped editable="bottom" list', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
+ assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
+
+ await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
+ assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
+
+ list.destroy();
+ });
+
+ QUnit.test('add and discard a line through keyboard navigation without crashing', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ // Triggers ENTER on "Add a line" wrapper cell
+ await testUtils.fields.triggerKeydown(list.$('.o_group_field_row_add'), 'enter');
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 4, "new data row should be created");
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
+ // At this point, a crash manager should appear if no proper link targetting
+ assert.containsN(list, 'tbody:nth(1) .o_data_row', 3,"new data row should be discarded.");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable grouped list with create="0"', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
+ groupBy: ['bar'],
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ assert.containsNone(list, '.o_group_field_row_add a',
+ "Add a line should not be available in readonly");
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in (selection) grouped editable list', async function (assert) {
+ assert.expect(6);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="priority"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ groupBy: ['priority'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.kwargs.context.default_priority.toString());
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first')); // open group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a')); // add a new row
+ await testUtils.dom.click($('body')); // unselect row
+ assert.verifySteps(['1']);
+ assert.strictEqual(list.$('.o_data_row .o_data_cell:eq(1)').text(), 'Low',
+ "should have a column name with a value from the groupby");
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('.o_data_row:nth(5) .o_data_cell:eq(1)').text(), 'Medium',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['2']);
+
+ list.destroy();
+ });
+
+ QUnit.test('add a new row in (m2o) grouped editable list', async function (assert) {
+ assert.expect(6);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="m2o"/>' +
+ '</tree>',
+ groupBy: ['m2o'],
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.kwargs.context.default_m2o.toString());
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ await testUtils.dom.click(list.$('.o_group_header:first'));
+ await testUtils.dom.click(list.$('.o_group_field_row_add a'));
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('tbody:eq(1) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 1',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['1']);
+
+ await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
+ await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
+ await testUtils.dom.click($('body')); // unselect row
+ assert.strictEqual(list.$('tbody:eq(3) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 2',
+ "should have a column name with a value from the groupby");
+ assert.verifySteps(['2']);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering', async function (assert) {
+ assert.expect(12);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ translateParameters: {
+ direction: 'ltr',
+ }
+ });
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle',
+ "should have the optional columns dropdown toggle inside the table");
+
+ const optionalFieldsToggler = list.el.querySelector('table').lastElementChild;
+ assert.ok(optionalFieldsToggler.classList.contains('o_optional_columns_dropdown_toggle'),
+ 'The optional fields toggler is the second last element');
+ const optionalFieldsDropdown = list.el.querySelector('.o_list_view').lastElementChild;
+ assert.ok(optionalFieldsDropdown.classList.contains('o_optional_columns'),
+ 'The optional fields dropdown is the last element');
+
+ assert.ok(list.$('.o_optional_columns .dropdown-menu').hasClass('dropdown-menu-right'),
+ 'In LTR, the dropdown should be anchored to the right and expand to the left');
+
+ // optional fields
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2,
+ "dropdown have 2 optional field foo with checked and bar with unchecked");
+
+ // enable optional field
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ // 5 th (1 for checkbox, 4 for columns)
+ assert.containsN(list, 'th', 4, "should have 4 th");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // disable optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.strictEqual(list.$('div.o_optional_columns div.dropdown-item:first input:checked')[0],
+ list.$('div.o_optional_columns div.dropdown-item [name="m2o"]')[0],
+ "m2o advanced field check box should be checked in dropdown");
+
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ // 3 th (1 for checkbox, 2 for columns)
+ assert.containsN(list, 'th', 3, "should have 3 th");
+ assert.notOk(list.$('th:contains(M2O field)').is(':visible'),
+ "should not have a visible m2o field"); //m2o field not displayed
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering in RTL mode', async function (assert) {
+ assert.expect(4);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ translateParameters: {
+ direction: 'rtl',
+ }
+ });
+
+ assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle',
+ "should have the optional columns dropdown toggle inside the table");
+
+ const optionalFieldsToggler = list.el.querySelector('table').lastElementChild;
+ assert.ok(optionalFieldsToggler.classList.contains('o_optional_columns_dropdown_toggle'),
+ 'The optional fields toggler is the last element');
+ const optionalFieldsDropdown = list.el.querySelector('.o_list_view').lastElementChild;
+ assert.ok(optionalFieldsDropdown.classList.contains('o_optional_columns'),
+ 'The optional fields is the last element');
+
+ assert.ok(list.$('.o_optional_columns .dropdown-menu').hasClass('dropdown-menu-left'),
+ 'In RTL, the dropdown should be anchored to the left and expand to the right');
+
+ list.destroy();
+ });
+
+ QUnit.test('optional fields do not disappear even after listview reload', async function (assert) {
+ assert.expect(7);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="amount"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ // enable optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 4,
+ "should have 4 th 1 for selector, 3 for columns");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // reload listview
+ await list.reload();
+ assert.containsN(list, 'th', 4,
+ "should have 4 th 1 for selector, 3 for columns ever after listview reload");
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field even after listview reload");
+
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.ok(list.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+
+ list.destroy();
+ });
+
+ QUnit.test('selection is kept when optional fields are toggled', async function (assert) {
+ assert.expect(7);
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 2);
+
+ // select a record
+ await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input:first'));
+
+ assert.containsOnce(list, '.o_list_record_selector input:checked');
+
+ // add an optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 3);
+
+ assert.containsOnce(list, '.o_list_record_selector input:checked');
+
+ // select all records
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ // remove an optional field
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input'));
+ assert.containsN(list, 'th', 2);
+
+ assert.containsN(list, '.o_list_record_selector input:checked', 5);
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with optional fields and async rendering', async function (assert) {
+ assert.expect(14);
+
+ const prom = testUtils.makeTestPromise();
+ const FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ async _render() {
+ assert.ok(true, 'the rendering must be async');
+ this._super(...arguments);
+ await prom;
+ },
+ }));
+
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree>
+ <field name="m2o"/>
+ <field name="foo" widget="asyncwidget" optional="hide"/>
+ </tree>`,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+
+ assert.containsN(list, 'th', 2);
+ assert.isNotVisible(list.$('.o_optional_columns_dropdown'));
+
+ // add an optional field (we click on the label on purpose, as it will trigger
+ // a second event on the input)
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsNone(list.$('.o_optional_columns_dropdown'), 'input:checked');
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first label'));
+
+ assert.containsN(list, 'th', 2);
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsNone(list.$('.o_optional_columns_dropdown'), 'input:checked');
+
+ prom.resolve();
+ await testUtils.nextTick();
+
+ assert.containsN(list, 'th', 3);
+ assert.isVisible(list.$('.o_optional_columns_dropdown'));
+ assert.containsOnce(list.$('.o_optional_columns_dropdown'), 'input:checked');
+
+ list.destroy();
+ delete fieldRegistry.map.asyncwidget;
+ });
+
+ QUnit.test('open list optional fields dropdown position to right place', async function (assert) {
+ assert.expect(1);
+
+ this.data.bar.fields.name = { string: "Name", type: "char", sortable: true };
+ this.data.bar.fields.foo = { string: "Foo", type: "char", sortable: true };
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <sheet>
+ <notebook>
+ <page string="Page 1">
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="foo"/>
+ <field name="name" optional="hide"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ res_id: 1,
+ });
+
+ const listWidth = form.el.querySelector('.o_list_view').offsetWidth;
+
+ await testUtils.dom.click(form.el.querySelector('.o_optional_columns_dropdown_toggle'));
+ assert.strictEqual(form.el.querySelector('.o_optional_columns').offsetLeft, listWidth,
+ "optional fields dropdown should opened at right place");
+
+ form.destroy();
+ });
+
+ QUnit.test('change the viewType of the current action', async function (assert) {
+ assert.expect(25);
+
+ this.actions = [{
+ id: 1,
+ name: 'Partners Action 1',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[1, 'kanban']],
+ }, {
+ id: 2,
+ name: 'Partners',
+ res_model: 'foo',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list'], [1, 'kanban']],
+ }];
+
+ this.archs = {
+ 'foo,1,kanban': '<kanban><templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click"><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+
+ 'foo,false,list': '<tree limit="3">' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="o2m" optional="show"/></tree>',
+
+ 'foo,false,search': '<search><field name="foo" string="Foo"/></search>',
+ };
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+
+ var actionManager = await testUtils.createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ services: {
+ local_storage: RamStorageService,
+ },
+ });
+ await actionManager.doAction(2);
+
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should have rendered a list view");
+
+ assert.containsN(actionManager, 'th', 3, "should display 3 th (selector + 2 fields)");
+
+ // enable optional field
+ await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle'));
+ assert.notOk(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked"));
+ await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:first'));
+ assert.containsN(actionManager, 'th', 4, "should display 4 th (selector + 3 fields)");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ // switch to kanban view
+ await actionManager.loadState({
+ action: 2,
+ view_type: 'kanban',
+ });
+
+ assert.containsNone(actionManager, '.o_list_view',
+ "should not display the list view anymore");
+ assert.containsOnce(actionManager, '.o_kanban_view',
+ "should have switched to the kanban view");
+
+ // switch back to list view
+ await actionManager.loadState({
+ action: 2,
+ view_type: 'list',
+ });
+
+ assert.containsNone(actionManager, '.o_kanban_view',
+ "should not display the kanban view anymoe");
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should display the list view");
+
+ assert.containsN(actionManager, 'th', 4, "should display 4 th");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.ok(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+
+ // disable optional field
+ await testUtils.dom.click(actionManager.$('table .o_optional_columns_dropdown_toggle'));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="m2o"]').is(":checked"));
+ assert.ok(actionManager.$('div.o_optional_columns div.dropdown-item [name="o2m"]').is(":checked"));
+ await testUtils.dom.click(actionManager.$('div.o_optional_columns div.dropdown-item:last input'));
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+ assert.containsN(actionManager, 'th', 3, "should display 3 th");
+
+ await actionManager.doAction(1);
+
+ assert.containsNone(actionManager, '.o_list_view',
+ "should not display the list view anymore");
+ assert.containsOnce(actionManager, '.o_kanban_view',
+ "should have switched to the kanban view");
+
+ await actionManager.doAction(2);
+
+ assert.containsNone(actionManager, '.o_kanban_view',
+ "should not havethe kanban view anymoe");
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should display the list view");
+
+ assert.containsN(actionManager, 'th', 3, "should display 3 th");
+ assert.ok(actionManager.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+ assert.notOk(actionManager.$('th:contains(O2M field)').is(':visible'),
+ "should have a visible o2m field"); //m2o field
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('list view with optional fields rendering and local storage mock', async function (assert) {
+ assert.expect(12);
+
+ var forceLocalStorage = true;
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return forceLocalStorage ? '["m2o"]' : this._super.apply(this, arguments);
+ },
+ setItem: function (key, value) {
+ assert.step('setItem ' + key + ' to ' + value);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="m2o" optional="hide"/>' +
+ '<field name="reference" optional="show"/>' +
+ '</tree>',
+ services: {
+ local_storage: RamStorageService,
+ },
+ view_id: 42,
+ });
+
+ var localStorageKey = 'optional_fields,foo,list,42,foo,m2o,reference';
+
+ assert.verifySteps(['getItem ' + localStorageKey]);
+
+ assert.containsN(list, 'th', 3,
+ "should have 3 th, 1 for selector, 2 for columns");
+
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ assert.notOk(list.$('th:contains(Reference Field)').is(':visible'),
+ "should not have a visible reference field");
+
+ // optional fields
+ await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle'));
+ assert.containsN(list, 'div.o_optional_columns div.dropdown-item', 2,
+ "dropdown have 2 optional fields");
+
+ forceLocalStorage = false;
+ // enable optional field
+ await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:eq(1) input'));
+
+ assert.verifySteps([
+ 'setItem ' + localStorageKey + ' to ["m2o","reference"]',
+ 'getItem ' + localStorageKey,
+ ]);
+
+ // 4 th (1 for checkbox, 3 for columns)
+ assert.containsN(list, 'th', 4, "should have 4 th");
+
+ assert.ok(list.$('th:contains(M2O field)').is(':visible'),
+ "should have a visible m2o field"); //m2o field
+
+ assert.ok(list.$('th:contains(Reference Field)').is(':visible'),
+ "should have a visible reference field");
+
+ list.destroy();
+ });
+ QUnit.test("quickcreate in a many2one in a list", async function (assert) {
+ assert.expect(2);
+
+ const list = await createView({
+ arch: '<tree editable="top"><field name="m2o"/></tree>',
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
+
+ const $input = list.$('.o_data_row:first .o_data_cell:first input');
+ await testUtils.fields.editInput($input, "aaa");
+ $input.trigger('keyup');
+ $input.trigger('blur');
+ document.body.click();
+
+ await testUtils.nextTick();
+
+ assert.containsOnce(document.body, '.modal', "the quick_create modal should appear");
+
+ await testUtils.dom.click($('.modal .btn-primary:first'));
+ await testUtils.dom.click(document.body);
+
+ assert.strictEqual(list.el.getElementsByClassName('o_data_cell')[0].innerHTML, "aaa",
+ "value should have been updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('float field render with digits attribute on listview', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo"/><field name="qux" digits="[12,6]"/></tree>',
+ });
+
+ assert.strictEqual(list.$('td.o_list_number:eq(0)').text(), "0.400000", "should contain 6 digits decimal precision");
+ list.destroy();
+ });
+ // TODO: write test on:
+ // - default_get with a field not in view
+
+ QUnit.test('editable list: resize column headers', async function (assert) {
+ assert.expect(2);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ });
+
+ // Target handle
+ const th = list.el.getElementsByTagName('th')[1];
+ const optionalDropdown = list.el.getElementsByClassName('o_optional_columns')[0];
+ const optionalInitialX = optionalDropdown.getBoundingClientRect().x;
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const originalWidth = th.offsetWidth;
+ const expectedWidth = Math.floor(originalWidth / 2 + resizeHandle.offsetWidth / 2);
+ const delta = originalWidth - expectedWidth;
+
+ await testUtils.dom.dragAndDrop(resizeHandle, th, { mousemoveTarget: window, mouseupTarget: window });
+ const optionalFinalX = Math.floor(optionalDropdown.getBoundingClientRect().x);
+
+ assert.strictEqual(th.offsetWidth, expectedWidth,
+ // 1px for the cell right border
+ "header width should be halved (plus half the width of the handle)");
+ assert.strictEqual(optionalFinalX, optionalInitialX - delta,
+ "optional columns dropdown should have moved the same amount");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list: resize column headers with max-width', async function (assert) {
+ // This test will ensure that, on resize list header,
+ // the resized element have the correct size and other elements are not resized
+ assert.expect(2);
+ this.data.foo.records[0].foo = "a".repeat(200);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '<field name="reference" optional="hide"/>' +
+ '</tree>',
+ });
+
+ // Target handle
+ const th = list.el.getElementsByTagName('th')[1];
+ const thNext = list.el.getElementsByTagName('th')[2];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const nextResizeHandle = thNext.getElementsByClassName('o_resize')[0];
+ const thOriginalWidth = th.offsetWidth;
+ const thNextOriginalWidth = thNext.offsetWidth;
+ const thExpectedWidth = Math.floor(thOriginalWidth + thNextOriginalWidth);
+
+ await testUtils.dom.dragAndDrop(resizeHandle, nextResizeHandle, { mousemoveTarget: window, mouseupTarget: window });
+
+ const thFinalWidth = th.offsetWidth;
+ const thNextFinalWidth = thNext.offsetWidth;
+ const thWidthDiff = Math.abs(thExpectedWidth - thFinalWidth)
+
+ assert.ok(thWidthDiff <= 1, "Wrong width on resize");
+ assert.ok(thNextOriginalWidth === thNextFinalWidth, "Width must not have been changed");
+
+ list.destroy();
+ });
+
+ QUnit.test('resize column with several x2many lists in form group', async function (assert) {
+ assert.expect(3);
+
+ this.data.bar.fields.text = {string: "Text field", type: "char"};
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <group>
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="text"/>
+ </tree>
+ </field>
+ <field name="m2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="text"/>
+ </tree>
+ </field>
+ </group>
+ </form>`,
+ res_id: 1,
+ });
+
+ const th = form.el.getElementsByTagName('th')[0];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const firstTableInitialWidth = form.el.querySelectorAll('.o_field_x2many_list table')[0].offsetWidth;
+ const secondTableInititalWidth = form.el.querySelectorAll('.o_field_x2many_list table')[1].offsetWidth;
+
+ assert.strictEqual(firstTableInitialWidth, secondTableInititalWidth,
+ "both table columns have same width");
+
+ await testUtils.dom.dragAndDrop(resizeHandle, form.el.getElementsByTagName('th')[1], { position: "right" });
+
+ assert.notEqual(firstTableInitialWidth, form.el.querySelectorAll('thead')[0].offsetWidth,
+ "first o2m table is resized and width of table has changed");
+ assert.strictEqual(secondTableInititalWidth, form.el.querySelectorAll('thead')[1].offsetWidth,
+ "second o2m table should not be impacted on first o2m in group resized");
+
+ form.destroy();
+ });
+
+ QUnit.test('resize column with x2many list with several fields in form notebook', async function (assert) {
+ assert.expect(1);
+
+ this.data.foo.records[0].o2m = [1, 2];
+
+ const form = await createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <form>
+ <sheet>
+ <notebook>
+ <page string="Page 1">
+ <field name="o2m">
+ <tree editable="bottom">
+ <field name="display_name"/>
+ <field name="display_name"/>
+ <field name="display_name"/>
+ <field name="display_name"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ </form>`,
+ res_id: 1,
+ });
+
+ const th = form.el.getElementsByTagName('th')[0];
+ const resizeHandle = th.getElementsByClassName('o_resize')[0];
+ const listInitialWidth = form.el.querySelector('.o_list_view').offsetWidth;
+
+ await testUtils.dom.dragAndDrop(resizeHandle, form.el.getElementsByTagName('th')[1], { position: "right" });
+
+ assert.strictEqual(form.el.querySelector('.o_list_view').offsetWidth, listInitialWidth,
+ "resizing the column should not impact the width of list");
+
+ form.destroy();
+ });
+
+ QUnit.test('enter edition in editable list with <widget>', async function (assert) {
+ assert.expect(1);
+
+ var MyWidget = Widget.extend({
+ start: function () {
+ this.$el.html('<i class="fa fa-info"/>');
+ },
+ });
+ widgetRegistry.add('some_widget', MyWidget);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<widget name="some_widget"/>' +
+ '<field name="int_field"/>' +
+ '<field name="qux"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(1)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ delete widgetRegistry.map.test;
+ });
+
+ QUnit.test('enter edition in editable list with multi_edit = 0', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" multi_edit="0">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(0)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ });
+
+ QUnit.test('enter edition in editable list with multi_edit = 1', async function (assert) {
+ assert.expect(1);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top" multi_edit="1">' +
+ '<field name="int_field"/>' +
+ '</tree>',
+ });
+
+ // click on int_field cell of first row
+ await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:nth(0)'));
+ assert.strictEqual(document.activeElement.name, "int_field");
+
+ list.destroy();
+ });
+
+ QUnit.test('list view with field component: mounted and willUnmount calls', async function (assert) {
+ // this test could be removed as soon as the list view will be written in Owl
+ assert.expect(7);
+
+ let mountedCalls = 0;
+ let willUnmountCalls = 0;
+ class MyField extends AbstractFieldOwl {
+ mounted() {
+ mountedCalls++;
+ }
+ willUnmount() {
+ willUnmountCalls++;
+ }
+ }
+ MyField.template = owl.tags.xml`<span>Hello World</span>`;
+ fieldRegistryOwl.add('my_owl_field', MyField);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="foo" widget="my_owl_field"/></tree>',
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.strictEqual(mountedCalls, 4);
+ assert.strictEqual(willUnmountCalls, 0);
+
+ await list.reload();
+ assert.strictEqual(mountedCalls, 8);
+ assert.strictEqual(willUnmountCalls, 4);
+
+ list.destroy();
+ assert.strictEqual(mountedCalls, 8);
+ assert.strictEqual(willUnmountCalls, 8);
+ });
+
+ QUnit.test('editable list view: multi edition of owl field component', async function (assert) {
+ // this test could be removed as soon as all field widgets will be written in owl
+ assert.expect(5);
+
+ const list = await createView({
+ arch: '<tree multi_edit="1"><field name="bar"/></tree>',
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsN(list, '.o_data_cell .custom-checkbox input:checked', 3);
+
+ // select all records and edit the boolean field
+ await testUtils.dom.click(list.$('thead .o_list_record_selector input'));
+ assert.containsN(list, '.o_data_row .o_list_record_selector input:checked', 4);
+ await testUtils.dom.click(list.$('.o_data_cell:first'));
+ await testUtils.dom.click(list.$('.o_data_cell .o_field_boolean input'));
+
+ assert.containsOnce(document.body, '.modal');
+ await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
+
+ assert.containsNone(list, '.o_data_cell .custom-checkbox input:checked');
+
+ list.destroy();
+ });
+
+ QUnit.test("Date in evaluation context works with date field", async function (assert) {
+ assert.expect(11);
+
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0);
+ testUtils.mock.patch(BasicModel, {
+ _getEvalContext() {
+ const evalContext = this._super(...arguments);
+ assert.ok(dateRegex.test(evalContext.today));
+ assert.strictEqual(evalContext.current_date, evalContext.today);
+ return evalContext;
+ },
+ });
+
+ this.data.foo.fields.birthday = { string: "Birthday", type: 'date' };
+ this.data.foo.records[0].birthday = "1997-01-08";
+ this.data.foo.records[1].birthday = "1997-01-09";
+ this.data.foo.records[2].birthday = "1997-01-10";
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="birthday" decoration-danger="birthday > today"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsOnce(list, ".o_data_row .text-danger");
+
+ list.destroy();
+ unpatchDate();
+ testUtils.mock.unpatch(BasicModel);
+ });
+
+ QUnit.test("Datetime in evaluation context works with datetime field", async function (assert) {
+ assert.expect(6);
+
+ const datetimeRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
+ const unpatchDate = testUtils.mock.patchDate(1997, 0, 9, 12, 0, 0);
+ testUtils.mock.patch(BasicModel, {
+ _getEvalContext() {
+ const evalContext = this._super(...arguments);
+ assert.ok(datetimeRegex.test(evalContext.now));
+ return evalContext;
+ },
+ });
+
+ /**
+ * Returns "1997-01-DD HH:MM:00" with D, H and M holding current UTC values
+ * from patched date + (deltaMinutes) minutes.
+ * This is done to allow testing from any timezone since UTC values are
+ * calculated with the offset of the current browser.
+ */
+ function dateStringDelta(deltaMinutes) {
+ const d = new Date(Date.now() + 1000 * 60 * deltaMinutes);
+ return `1997-01-${
+ String(d.getUTCDate()).padStart(2, '0')
+ } ${
+ String(d.getUTCHours()).padStart(2, '0')
+ }:${
+ String(d.getUTCMinutes()).padStart(2, '0')
+ }:00`;
+ }
+
+ // "datetime" field may collide with "datetime" object in context
+ this.data.foo.fields.birthday = { string: "Birthday", type: 'datetime' };
+ this.data.foo.records[0].birthday = dateStringDelta(-30);
+ this.data.foo.records[1].birthday = dateStringDelta(0);
+ this.data.foo.records[2].birthday = dateStringDelta(+30);
+
+ const list = await createView({
+ arch: `
+ <tree>
+ <field name="birthday" decoration-danger="birthday > now"/>
+ </tree>`,
+ data: this.data,
+ model: 'foo',
+ View: ListView,
+ });
+
+ assert.containsOnce(list, ".o_data_row .text-danger");
+
+ list.destroy();
+ unpatchDate();
+ testUtils.mock.unpatch(BasicModel);
+ });
+
+ QUnit.test("update control panel while list view is mounting", async function (assert) {
+ const ControlPanel = require('web.ControlPanel');
+ const ListController = require('web.ListController');
+
+ let mountedCounterCall = 0;
+
+ ControlPanel.patch('test.ControlPanel', T => {
+ class ControlPanelPatchTest extends T {
+ mounted() {
+ mountedCounterCall = mountedCounterCall + 1;
+ assert.step(`mountedCounterCall-${mountedCounterCall}`);
+ super.mounted(...arguments);
+ }
+ }
+ return ControlPanelPatchTest;
+ });
+
+ const MyListView = ListView.extend({
+ config: Object.assign({}, ListView.prototype.config, {
+ Controller: ListController.extend({
+ async start() {
+ await this._super(...arguments);
+ this.renderer._updateSelection();
+ },
+ }),
+ }),
+ });
+
+ assert.expect(2);
+
+ const list = await createView({
+ View: MyListView,
+ model: 'event',
+ data: this.data,
+ arch: '<tree><field name="name"/></tree>',
+ });
+
+ assert.verifySteps([
+ 'mountedCounterCall-1',
+ ]);
+
+ ControlPanel.unpatch('test.ControlPanel');
+
+ list.destroy();
+ });
+
+ QUnit.test('edition, then navigation with tab (with a readonly re-evaluated field and onchange)', async function (assert) {
+ // This test makes sure that if we have a cell in a row that will become
+ // read-only after editing another cell, in case the keyboard navigation
+ // move over it before it becomes read-only and there are unsaved changes
+ // (which will trigger an onchange), the focus of the next activable
+ // field will not crash
+ assert.expect(4);
+
+ this.data.bar.onchanges = {
+ o2m: function () {},
+ };
+ this.data.bar.fields.o2m = {string: "O2M field", type: "one2many", relation: "foo"};
+ this.data.bar.records[0].o2m = [1, 4];
+
+ var form = await createView({
+ View: FormView,
+ model: 'bar',
+ res_id: 1,
+ data: this.data,
+ arch: '<form>' +
+ '<group>' +
+ '<field name="display_name"/>' +
+ '<field name="o2m">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="date" attrs="{\'readonly\': [(\'foo\', \'!=\', \'yop\')]}"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.method + ':' + args.model);
+ }
+ return this._super.apply(this, arguments);
+ },
+ fieldDebounce: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var jq_evspecial_focus_trigger = $.event.special.focus.trigger;
+ // As KeyboardEvent will be triggered by JS and not from the
+ // User-Agent itself, the focus event will not trigger default
+ // action (event not being trusted), we need to manually trigger
+ // 'change' event on the currently focused element
+ $.event.special.focus.trigger = function () {
+ if (this !== document.activeElement && this.focus) {
+ var activeElement = document.activeElement;
+ this.focus();
+ $(activeElement).trigger('change');
+ }
+ };
+
+ // editable list, click on first td and press TAB
+ await testUtils.dom.click(form.$('.o_data_cell:contains(yop)'));
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="foo"]')[0],
+ "focus should be on an input with name = foo");
+ testUtils.fields.editInput(form.$('tr.o_selected_row input[name="foo"]'), 'new value');
+ var tabEvent = $.Event("keydown", { which: $.ui.keyCode.TAB });
+ await testUtils.dom.triggerEvents(form.$('tr.o_selected_row input[name="foo"]'), [tabEvent]);
+ assert.strictEqual(document.activeElement, form.$('tr.o_selected_row input[name="int_field"]')[0],
+ "focus should be on an input with name = int_field");
+
+ // Restore origin jQuery special trigger for 'focus'
+ $.event.special.focus.trigger = jq_evspecial_focus_trigger;
+
+ assert.verifySteps(["onchange:bar"], "onchange method should have been called");
+ form.destroy();
+ });
+});
+
+});